Coverage for pyrc\postprocessing\parser.py: 27%

360 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-29 15:57 +0200

1# ------------------------------------------------------------------------------- 

2# Copyright (C) 2026 Joel Kimmich, Tim Jourdan 

3# ------------------------------------------------------------------------------ 

4# License 

5# This file is part of PyRC, distributed under GPL-3.0-or-later. 

6# ------------------------------------------------------------------------------ 

7 

8import time 

9from abc import abstractmethod 

10from collections.abc import Callable 

11from copy import copy 

12from datetime import datetime, timedelta 

13from typing import Any 

14 

15import numpy as np 

16 

17import gc 

18import os 

19 

20from pyrc.core.components.capacitor import Capacitor 

21from pyrc.core.network import RCNetwork 

22from pyrc.core.nodes import Node 

23from pyrc.core.settings import Settings 

24from pyrc.core.components.templates import RCSolution 

25from pyrc.dataHandler.weather import WeatherData 

26from pyrc.tools.science import get_free_ram 

27from pyrc.visualization.plot import seconds_to_dates 

28 

29 

30def parse_direction(direction: np.ndarray | str) -> np.ndarray: 

31 """ 

32 Makes one direction vector out of the input. 

33 

34 Parameters 

35 ---------- 

36 direction 

37 

38 Returns 

39 ------- 

40 np.ndarray : 

41 The corresponding array for the string (or numpy array). 

42 """ 

43 if isinstance(direction, str): 

44 sign = 1 

45 if len(direction) == 2: 

46 if direction[0] == "-": 

47 sign = -1 

48 direction = direction[1] 

49 assert len(direction) == 1 

50 match direction.lower(): 

51 case "x": 

52 direction = np.array((1, 0, 0)) 

53 case "y": 

54 direction = np.array((0, 1, 0)) 

55 case "z": 

56 direction = np.array((0, 0, 1)) 

57 case _: 

58 raise ValueError("Invalid direction input.") 

59 direction = sign * direction 

60 return direction 

61 

62 

63class Filter: 

64 @abstractmethod 

65 def apply_filter(self, matrix: np.ndarray, axis=None) -> np.ndarray: 

66 pass 

67 

68 

69class FilterMixin(Filter): 

70 def __init__(self, values: list | np.ndarray, settings: Settings): 

71 self.network_settings = settings 

72 if isinstance(values, list): 

73 values = np.array(values) 

74 self.values: np.ndarray = values.flatten() 

75 self.number = self.values.shape[0] 

76 self.mask = np.full(self.number, False, dtype=bool) 

77 

78 @abstractmethod 

79 def __copy__(self): 

80 return FilterMixin(self.values, self.network_settings) 

81 

82 def invert(self): 

83 """ 

84 Inverts the mask of the given axis. 

85 """ 

86 self.mask = np.invert(self.mask) 

87 

88 def _add_mask(self, mask): 

89 """ 

90 Adds the mask to the current mask. So True + False = True 

91 

92 Parameters 

93 ---------- 

94 mask : np.ndarray 

95 The mask to add. 

96 Where True the current mask is also set to True. 

97 """ 

98 self._apply_mask(mask, add=True) 

99 

100 def _subtract_mask(self, mask): 

101 """ 

102 Subtract the mask from the current mask. So True + False = False 

103 

104 Parameters 

105 ---------- 

106 mask : np.ndarray 

107 The mask to add. 

108 Where False the current mask is also set to False. 

109 """ 

110 self._apply_mask(mask, add=False) 

111 

112 def _apply_mask(self, mask_vector: np.ndarray, add=True): 

113 """ 

114 Applies the provided boolean mask array to self.mask_row or self.mask_col using logical and/or (&/|). 

115 

116 Parameters 

117 ---------- 

118 mask_vector : np.ndarray 

119 The mask to add. 

120 add : bool, optional 

121 Whether to add the boolean mask array or subtract it. 

122 Add: current | mask_vector 

123 Not add: current & mask_vector 

124 

125 """ 

126 mask_vector = mask_vector.reshape( 

127 -1, 

128 ) 

129 assert mask_vector.shape[0] == self.number 

130 if add: 

131 self.mask = mask_vector | self.mask 

132 else: 

133 self.mask = mask_vector & self.mask 

134 

135 def apply_filter(self, matrix: np.ndarray, axis=None) -> np.ndarray: 

136 """ 

137 Returns the filtered matrix. 

138 

139 This does not save any data to the class so the RAM usage is not affected. 

140 

141 Parameters 

142 ---------- 

143 matrix : np.ndarray 

144 The matrix to be filtered. 

145 axis : int, optional 

146 If 0 the mask is applied to the row mask, to the column mask either. 

147 If None, it is added to the mask of same length. 

148 

149 Returns 

150 ------- 

151 np.ndarray : 

152 The filtered matrix. 

153 """ 

154 if axis is None: 

155 if matrix.shape[0] == self.number: 

156 axis = 0 

157 elif matrix.shape[1] == self.number: 

158 axis = 1 

159 else: 

160 raise ValueError("Length of mask_vector must match one of the dimensions of rows or columns.") 

161 if axis == 0: 

162 assert matrix.shape[0] == self.number 

163 return matrix[self.mask] 

164 else: 

165 assert matrix.shape[1] == self.number 

166 return matrix[:, self.mask] 

167 

168 def __add__(self, other): 

169 import copy 

170 

171 result = copy.copy(self) 

172 result._add_mask(other.mask) 

173 return result 

174 

175 @property 

176 def filtered_values(self) -> np.ndarray: 

177 return self.values[self.mask] 

178 

179 

180class NodeFilter(FilterMixin): 

181 def __init__(self, nodes: list[Capacitor] | np.ndarray, settings: Settings): 

182 """ 

183 Initially: filter out everything. 

184 

185 Parameters 

186 ---------- 

187 nodes : list[Capacitor] | np.ndarray 

188 The nodes which solutions are represented in the columns. 

189 settings: Settings 

190 The `Settings` object that matches the settings of the network. 

191 Is used to get the start_date and the weather_data_path 

192 """ 

193 super().__init__(values=nodes, settings=settings) 

194 

195 def __copy__(self): 

196 return NodeFilter(self.values, self.network_settings) 

197 

198 def add_nodes(self, nodes: list[Node] | str): 

199 """ 

200 Adds the nodes to the current node mask. If string is given, the corresponding group is added. 

201 

202 Parameters 

203 ---------- 

204 nodes : list[Node] | Node | np.ndarray 

205 The list with the node objects. 

206 """ 

207 if isinstance(nodes, Node): 

208 nodes = [nodes] 

209 elif isinstance(nodes, np.ndarray): 

210 nodes = nodes.tolist() 

211 assert isinstance(nodes, list) 

212 indices = [node.index for node in nodes] 

213 self.mask[indices] = True 

214 

215 def subtract_nodes(self, nodes: list[Node] | str): 

216 """ 

217 Subtract the nodes to the current node mask. If string is given, the corresponding group is subtracted. 

218 

219 Parameters 

220 ---------- 

221 nodes : list[Capacitor] | Node | np.ndarray 

222 The list with the node objects. 

223 """ 

224 if isinstance(nodes, Node): 

225 nodes = [nodes] 

226 elif isinstance(nodes, np.ndarray): 

227 nodes = nodes.tolist() 

228 indices = [node.index for node in nodes] 

229 self.mask[indices] = False 

230 

231 def apply_filter(self, matrix: np.ndarray, axis=1) -> np.ndarray: 

232 return super().apply_filter(matrix, axis) 

233 

234 

235class WeatherFilter(FilterMixin): 

236 def __init__(self, settings: Settings): 

237 # TODO: create datetime vector and pass it to super init 

238 super().__init__(values=[], settings=settings) 

239 self._weather = None 

240 

241 def __copy__(self): 

242 result = WeatherFilter(self.network_settings) 

243 result._weather = self._weather 

244 return result 

245 

246 @property 

247 def weather(self) -> WeatherData: 

248 if self._weather is None: 

249 self._weather = self.network_settings.weather_data 

250 return self._weather 

251 

252 

253class TimeFilter(FilterMixin): 

254 """ 

255 A class that contains the filter for one RCSolution, especially nodes (columns) and dates (rows). 

256 """ 

257 

258 def __init__(self, seconds: np.ndarray, settings: Settings, time_accuracy="ms", initial_mask_value=False): 

259 """ 

260 Initially: filter out everything. 

261 

262 Parameters 

263 ---------- 

264 seconds : np.ndarray 

265 The row index as increasing seconds. 

266 settings : Settings 

267 The `Settings` object that matches the settings of the network. 

268 Is used to get the start_date and the weather_data_path 

269 time_accuracy : str, optional 

270 The time accuracy used in numpy.datetime64 calculations. E.g.: "ms", "s", "m" 

271 """ 

272 self.time_accuracy = time_accuracy 

273 time_mult = np.timedelta64(1, "s") / np.timedelta64(1, time_accuracy) 

274 values: np.ndarray = ( 

275 np.datetime64(settings.start_date) + np.timedelta64(1, time_accuracy) * np.array(seconds) * time_mult 

276 ) 

277 super().__init__(values=values, settings=settings) 

278 if initial_mask_value: 

279 self.invert() 

280 

281 def __copy__(self): 

282 result: TimeFilter = type(self).__new__(type(self)) 

283 # Copy all attributes from parent class 

284 super(TimeFilter, result).__init__(self.values, self.network_settings) 

285 result.mask = self.mask.copy() 

286 # Copy specific attributes from this class 

287 result.time_accuracy = self.time_accuracy 

288 # Copy any other attributes you have 

289 return result 

290 

291 @property 

292 def datetime(self): 

293 """ 

294 Returns the date times from filtered values as vector with datetime.datetime objects (instead of np.datetime64). 

295 

296 Returns 

297 ------- 

298 np.ndarray(datetime.datetime) : 

299 Date times of filtered values. 

300 """ 

301 filtered_values = self.values[self.mask] 

302 return np.array([dt.astype(datetime) for dt in filtered_values]) 

303 

304 def daterange(self, datetime1, datetime2=None): 

305 """ 

306 Filters rows using a datetime range. The current row mask is overwritten. 

307 

308 If datetime2 is None, the same day is used as end of the range. 

309 If datetime2 is not None, the exact datetime is used as end (included). So if you want the same result as 

310 with "None" then you have to use datetime1 + np.timedelta64(1, "D"). 

311 

312 Parameters 

313 ---------- 

314 datetime1 : np.datetime64 | datetime.datetime | Any 

315 The start of the range. Is included in the range. 

316 Is converted to np.datetime64. 

317 datetime2 : np.datetime64 | datetime.datetime | Any, optional 

318 The end of the range. Is included in the range (but with time. So if you want the whole day you have to 

319 use the next day at 00:00:00). 

320 If None, the same day as datetime1 is used as end of the range. 

321 Is converted to np.datetime64. 

322 

323 Examples 

324 -------- 

325 To apply a range of three days: 

326 >>> self.range(datetime(2022,4,1), "2022-04-03") 

327 which will result in the range 1.4.22 00:00:00 up to 4.4.22 00:00:00. 

328 """ 

329 datetime1 = np.datetime64(datetime1) 

330 if datetime2 is None: 

331 datetime2 = datetime1.astype("datetime64[D]") + np.timedelta64(1, "D") 

332 else: 

333 datetime2 = np.datetime64(datetime2) 

334 # if datetime2 - datetime2.astype("datetime64[D]") == 0: 

335 # # only day is given, no hours/minutes/seconds 

336 # datetime2 = datetime2.astype("datetime64[D]") + np.timedelta64(1, "D") 

337 

338 mask = (self.values >= datetime1.astype(f"datetime64[{self.time_accuracy}]")) & ( 

339 self.values <= datetime2.astype(f"datetime64[{self.time_accuracy}]") 

340 ) 

341 

342 self._add_mask(mask) 

343 

344 def apply_filter(self, matrix: np.ndarray, axis=0) -> np.ndarray: 

345 return super().apply_filter(matrix, axis) 

346 

347 

348class NetworkFilter(Filter): 

349 """ 

350 Combines a TimeFilter for the row and a NodeFilter for the columns to one filter. 

351 """ 

352 

353 def __init__(self, seconds: np.ndarray, nodes: list[Capacitor], settings: Settings, time_accuracy="ms"): 

354 """ 

355 Initially: filter out everything. 

356 

357 Parameters 

358 ---------- 

359 seconds : np.ndarray 

360 The row index as increasing seconds. 

361 nodes : list[Capacitor] 

362 The nodes which solutions are represented in the columns. 

363 settings: Settings 

364 The `Settings` object that matches the settings of the network. 

365 Is used to get the start_date and the weather_data_path 

366 """ 

367 self.number_rows = len(seconds.flatten()) 

368 self.number_columns = len(nodes) 

369 self.network_settings: Settings = settings 

370 

371 self.filter_row: TimeFilter = TimeFilter(seconds, self.network_settings, time_accuracy) 

372 self.filter_column: NodeFilter = NodeFilter(nodes, self.network_settings) 

373 self.filter_column.invert() # activate all nodes initially 

374 

375 def apply_filter(self, matrix, axis=None): 

376 if axis is None: 

377 # For maximum performance always filter columns first and then rows! NumPy arrays use row-major (C-style) 

378 # memory layout by default. 

379 column_filtered = self.apply_column_filter(matrix) 

380 return self.apply_row_filter(column_filtered) 

381 elif axis == 0: 

382 return self.apply_row_filter(matrix) 

383 else: 

384 return self.apply_column_filter(matrix) 

385 

386 def apply_row_filter(self, matrix): 

387 return self.filter_row.apply_filter(matrix) 

388 

389 def apply_column_filter(self, matrix): 

390 return self.filter_column.apply_filter(matrix) 

391 

392 

393class FilteredRCSolution: 

394 def __init__(self, rc_solution: RCSolution, filter_obj: Filter): 

395 self._rc_solution: RCSolution = rc_solution 

396 self.filter: Filter = filter_obj 

397 

398 def __getattr__(self, item): 

399 """ 

400 Returns the attribute from RCSolution. But for some attributes it returns the filtered version. 

401 """ 

402 attr = getattr(self._rc_solution, item) 

403 

404 if item in ["result_vectors", "temperature_vectors", "y"]: 

405 attr = self.filter.apply_filter(attr) 

406 elif item in ["t", "time_steps", "input_vectors"]: 

407 if isinstance(self.filter, TimeFilter): 

408 attr = self.filter.apply_filter(attr) 

409 elif isinstance(self.filter, NetworkFilter): 

410 attr = self.filter.apply_row_filter(attr) 

411 return attr 

412 

413 

414class FastParser: 

415 """ 

416 Class to process the solutions of an RC-network. 

417 

418 Here all calculations for a single RC-network are performed. Also, this class should make filtering easy and the 

419 processing fast without a lot of RAM usage. For this, the calculation should be done in a queue and after this 

420 the network solution is removed from the memory to free RAM and only the requested calculation/solution data is 

421 kept in memory. 

422 

423 To compare several RCNetwork Solutions use the class `MultiParser` which processes multiple FastParser instances. 

424 """ 

425 

426 _total_reserved_memory = 0 # in bytes 

427 

428 def __init__(self, network_solution_path_tuple: tuple[RCNetwork, str], solution_size=None): 

429 self.network = network_solution_path_tuple[0] 

430 self.solution_path = network_solution_path_tuple[1] # the pickle file of the solution containing the RCSolution 

431 self._solution_size = solution_size 

432 

433 self._blocked_ram = 0 

434 

435 self._filters: list[NetworkFilter | TimeFilter | NodeFilter] = [] 

436 self._filter_names: list[str] = [] 

437 

438 def __copy__(self): 

439 result: FastParser = type(self).__new__(type(self)) 

440 result.network = self.network 

441 result.solution_path = self.solution_path 

442 result._solution_size = self._solution_size 

443 result._blocked_ram = self._blocked_ram 

444 

445 result._filters = [copy(f) for f in self._filters] 

446 result._filter_names = self._filter_names 

447 return result 

448 

449 def __parse_filter_index(self, entry): 

450 if isinstance(entry, str): 

451 entry = self._filter_names.index(entry) 

452 return entry 

453 

454 @property 

455 def result_vectors(self): 

456 if not self.solution_exist: 

457 self.load_solution_safe() 

458 return self.network.rc_solution.result_vectors 

459 

460 @property 

461 def time_vector(self): 

462 if not self.solution_exist: 

463 self.load_solution_safe() 

464 return np.array( 

465 seconds_to_dates(self.network.rc_solution.time_steps, self.network.settings.weather_data.start_time) 

466 ) 

467 

468 @property 

469 def input_vectors(self): 

470 if not self.solution_exist: 

471 self.load_solution_safe() 

472 return self.network.rc_solution.input_vectors 

473 

474 @property 

475 def filters(self) -> list[NetworkFilter | TimeFilter | NodeFilter]: 

476 return self._filters 

477 

478 @property 

479 def time_filters(self) -> list[TimeFilter]: 

480 return [f for f in self.filters if isinstance(f, TimeFilter)] 

481 

482 @property 

483 def network_filter(self) -> list[NetworkFilter]: 

484 return [f for f in self.filters if isinstance(f, NetworkFilter)] 

485 

486 @property 

487 def node_filter(self) -> list[NodeFilter]: 

488 return [f for f in self.filters if isinstance(f, NodeFilter)] 

489 

490 def filter(self, entry: int | Any | str = -1) -> NetworkFilter | TimeFilter | NodeFilter: 

491 """ 

492 Returns a `Filter` object specified by entry. If entry is not given the last `Filter` is used. 

493 

494 Parameters 

495 ---------- 

496 entry : int | Any, optional 

497 If an int the index of the filter in the filter list self._filter. 

498 If a string the name of the filter in self._filter_names. Is parsed to an index. 

499 If None, the last `Filter` is used. 

500 """ 

501 return self._filters[self.__parse_filter_index(entry)] 

502 

503 def _add_filter(self, filter_class: type, name: str = None): 

504 """ 

505 Adds a new `Filter` object. It initially filters out everything (empty matrix). 

506 

507 The `Filter` objects are used to create different sets of data using the same data source. You can 

508 manipulate the filter/mask using the methods of the `Filter` class. 

509 

510 Parameters 

511 ---------- 

512 filter_class : type 

513 The Filter class to be added to self._filters 

514 name : str, optional 

515 The name of the filter to add. 

516 If None, the filter is only accessible by its index. 

517 

518 Returns 

519 ------- 

520 int : 

521 The index of the just added `Filter` object that can be used to get the filter using 

522 self.filter(index) 

523 """ 

524 if not self.solution_exist: 

525 self.load_solution_safe() 

526 assert self.network.rc_solution.exist 

527 kwargs = {"settings": self.network.settings} 

528 if filter_class is TimeFilter or filter_class is NetworkFilter: 

529 kwargs["seconds"] = self.network.rc_solution.time_steps 

530 if filter_class is NodeFilter or filter_class is NetworkFilter: 

531 kwargs["nodes"] = self.network.nodes 

532 self._filters.append(filter_class(**kwargs)) 

533 self._filter_names.append(name) 

534 return len(self.filters) - 1 

535 

536 def add_filter(self, name: str = None, return_index=False): 

537 """ 

538 Adds a new `NetworkFilter` object. It initially filters out everything (empty matrix). 

539 

540 The `NetworkFilter` objects are used to create different sets of data using the same data source. You can 

541 manipulate the filter/mask using the methods of the `NetworkFilter` class. 

542 

543 Parameters 

544 ---------- 

545 name : str, optional 

546 The name of the filter to add. 

547 If None, the filter is only accessible by its index. 

548 return_index : bool, optional 

549 If True the index of the added `NetworkFilter` is returned. 

550 

551 Returns 

552 ------- 

553 None | int : 

554 If return_index: the index of the just added `NetworkFilter` object that can be used to get the filter using 

555 self.filter(index) 

556 """ 

557 result = self._add_filter(NetworkFilter, name) 

558 if return_index: 

559 return result 

560 

561 def add_time_filter(self, name: str = None, return_index=False): 

562 """ 

563 Adds a new `TimeFilter` object. It initially filters out everything (empty matrix). 

564 

565 The `TimeFilter` objects are used to create different sets of data using the same data source. You can 

566 manipulate the filter/mask using the methods of the `TimeFilter` class. 

567 

568 Parameters 

569 ---------- 

570 name : str, optional 

571 The name of the filter to add. 

572 If None, the filter is only accessible by its index. 

573 return_index : bool, optional 

574 If True the index of the added `TimeFilter` is returned. 

575 

576 Returns 

577 ------- 

578 None | int : 

579 If return_index: the index of the just added `TimeFilter` object that can be used to get the filter using 

580 self.filter(index) 

581 """ 

582 result = self._add_filter(TimeFilter, name) 

583 if return_index: 

584 return result 

585 

586 def add_node_filter(self, name: str = None, return_index=False): 

587 """ 

588 Adds a new `NodeFilter` object. It initially filters out everything (empty matrix). 

589 

590 The `NodeFilter` objects are used to create different sets of data using the same data source. You can 

591 manipulate the filter/mask using the methods of the `NodeFilter` class. 

592 

593 Parameters 

594 ---------- 

595 name : str, optional 

596 The name of the filter to add. 

597 If None, the filter is only accessible by its index. 

598 return_index : bool, optional 

599 If True the index of the added `NodeFilter` is returned. 

600 

601 Returns 

602 ------- 

603 None | int : 

604 If return_index: the index of the just added `NodeFilter` object that can be used to get the filter using 

605 self.filter(index) 

606 """ 

607 result = self._add_filter(NodeFilter, name) 

608 if return_index: 

609 return result 

610 

611 def remove_filter(self, entry: int | Any = -1): 

612 """ 

613 Removes the desired filter from the filters list. 

614 

615 Remember: Previously passed filter indexes might change. 

616 

617 Parameters 

618 ---------- 

619 entry : int | Any, optional 

620 If an int the index of the filter in the filter list self._filter. 

621 If a string the name of the filter in self._filter_names. Is parsed to an index. 

622 If None, the last filter is used. 

623 """ 

624 index = self.__parse_filter_index(entry) 

625 for l in [self._filters, self._filter_names]: 

626 l.pop(index) 

627 

628 def add_time_filters( 

629 self, days=None, weeks=None, months=None, years=None, filter_name_add_on="", return_names=False 

630 ): 

631 """ 

632 Adds time filters for all passed days, weeks, months and years. 

633 

634 The time filters are named like "day{index of this day in list}{filter_name_add_on}". 

635 The weeks, months and years are represented by their start day. 

636 

637 If no value is passed no filter is created. 

638 

639 Parameters 

640 ---------- 

641 days : list[datetime] | datetime, optional 

642 The days that should be plotted. 

643 weeks : list[datetime] | datetime, optional 

644 The weeks that should be plotted. 

645 Each week is represented by its start date. 

646 months : list[datetime] | datetime, optional 

647 The months that should be plotted. 

648 Each month is represented by its start date. 

649 years : list[datetime] | datetime, optional 

650 The years that should be plotted. 

651 Each year is represented by its start date. 

652 filter_name_add_on : str, optional 

653 A name add-on for the time filter that is added. 

654 return_names : bool, optional 

655 If True the names of the time filters are returned as dictionary with the entries days, weeks, 

656 months and years and the corresponding list. 

657 

658 Returns 

659 ------- 

660 dict | None : 

661 If return_names, a dict with the layout: 

662 {"days": [], "weeks": [], "months": [], "years": []} 

663 with the names of the filters in the lists. 

664 """ 

665 import calendar 

666 

667 names = { 

668 "days": days or [], 

669 "weeks": weeks or [], 

670 "months": months or [], 

671 "years": years or [], 

672 } 

673 for i, day in enumerate(days): 

674 names["days"].append(f"day{i}{filter_name_add_on}") 

675 self.add_time_filter(names["days"][-1]) 

676 time_filter: TimeFilter = self.filter(names["days"][-1]) 

677 time_filter.daterange(datetime1=day) 

678 for i, week in enumerate(weeks): 

679 names["weeks"].append(f"week{i}{filter_name_add_on}") 

680 self.add_time_filter(names["weeks"][-1]) 

681 time_filter: TimeFilter = self.filter(names["weeks"][-1]) 

682 time_filter.daterange(datetime1=week, datetime2=week + timedelta(days=7)) 

683 for i, dt in enumerate(months): 

684 names["months"].append(f"month{i}{filter_name_add_on}") 

685 self.add_time_filter(names["months"][-1]) 

686 time_filter: TimeFilter = self.filter(names["months"][-1]) 

687 if dt.month == 12: 

688 next_year = dt.year + 1 

689 next_month = 1 

690 else: 

691 next_year = dt.year 

692 next_month = dt.month + 1 

693 

694 max_day = calendar.monthrange(next_year, next_month)[1] 

695 next_day = min(dt.day, max_day) 

696 

697 datetime2 = dt.replace(year=next_year, month=next_month, day=next_day) 

698 time_filter.daterange(datetime1=dt, datetime2=datetime2) 

699 for i, dt in enumerate(years): 

700 names["years"].append(f"year{i}{filter_name_add_on}") 

701 self.add_time_filter(names["years"][-1]) 

702 time_filter: TimeFilter = self.filter(names["years"][-1]) 

703 # Handle leap year edge case for Feb 29 

704 if dt.month == 2 and dt.day == 29 and not calendar.isleap(dt.year + 1): 

705 new_dt = dt.replace(year=dt.year + 1, month=3, day=1) 

706 else: 

707 new_dt = dt.replace(year=dt.year + 1) 

708 time_filter.daterange(datetime1=dt, datetime2=new_dt) 

709 if return_names: 

710 return names 

711 

712 def _block_memory(self): 

713 self._blocked_ram = self.solution_size * 1.01 

714 FastParser._total_reserved_memory += self.solution_size 

715 

716 def _free_memory(self): 

717 FastParser._total_reserved_memory -= self._blocked_ram 

718 self._blocked_ram = 0 

719 

720 @property 

721 def solution_exist(self) -> bool: 

722 return self.network.rc_solution.exist 

723 

724 @property 

725 def solution_size(self): 

726 if self._solution_size is None: 

727 if os.path.isfile(self.solution_path): 

728 self._solution_size = os.path.getsize(self.solution_path) 

729 else: 

730 print(f"The solution size is estimated to be 10 GB ({self.solution_path})") 

731 self._solution_size = 10 * 1024**3 

732 return self._solution_size 

733 

734 def load_solution(self): 

735 """ 

736 Loads solution, but only if enough RAM is available. 

737 

738 Raises 

739 ------ 

740 MemoryError : 

741 If not enough memory is available. 

742 """ 

743 if (get_free_ram() - FastParser._total_reserved_memory) > self.solution_size: 

744 self._block_memory() 

745 assert self.network.rc_solution.load_solution(self.solution_path), ( 

746 f"Solution file {self.solution_path} not found" 

747 ) 

748 self._free_memory() 

749 else: 

750 raise MemoryError("Not enough free memory to load solution.") 

751 

752 def load_solution_safe(self): 

753 """ 

754 Like load_solution, but it waits for up to 1 hour for enough RAM. 

755 

756 Raises 

757 ------ 

758 MemoryError : 

759 If not enough memory is available within 1 hour. 

760 """ 

761 counter = 0 

762 success = False 

763 while counter <= 3600: 

764 if (get_free_ram() - FastParser._total_reserved_memory) > self.solution_size: 

765 self.load_solution() 

766 success = True 

767 break 

768 time.sleep(1) 

769 counter += 1 

770 if not success: 

771 raise MemoryError("Not enough free memory to load solution.") 

772 

773 def free_ram(self): 

774 """ 

775 Deletes the network solution from memory without deleting any calculated/filtered/requested data. 

776 

777 #TODO: Append this function with all variables that are existing in the state of this class containing the 

778 whole solution data. The garbage collection has to be able to free the RAM from the most data! 

779 """ 

780 self.network.rc_solution.delete_solutions(True) 

781 

782 # garbage collection 

783 gc.collect() 

784 

785 def map(self, function: Callable, *args, **kwargs): 

786 """ 

787 Maps the passed function to every filter and returns the result as a tuple. 

788 

789 Parameters 

790 ---------- 

791 function : Callable 

792 The function to map on every filter. The first argument is a FilteredRCSolution. 

793 

794 Returns 

795 ------- 

796 tuple : 

797 The results for each filter. 

798 """ 

799 result = [] 

800 for filter_obj in self.filters: 

801 filtered_solution = FilteredRCSolution(self.network.rc_solution, filter_obj) 

802 result.append(function(filtered_solution, *args, **kwargs)) 

803 return tuple(result) 

804 

805 

806class MultiParser: 

807 """ 

808 Class to process multiple solutions of an RC-network (FastParser instances). 

809 

810 It behaves like FastParser, but it always executes all calls to every parser instance defined in the init. 

811 

812 This should be used with brain! 

813 Only compare networks with same hashes or with comparable layout. 

814 """ 

815 

816 def __init__(self, objects: list): 

817 """ 

818 Objects can be a list of FastParser instances or network solution-path tuples. 

819 

820 Parameters 

821 ---------- 

822 objects : list[FastParser] | list[tuple[RCNetwork, str]] 

823 These objects are compared to each other (same calculations are done for all of them). 

824 """ 

825 assert isinstance(objects, list) 

826 self.parsers: list[FastParser] = [] 

827 for obj in objects: 

828 if isinstance(obj, FastParser): 

829 self.parsers.append(obj) 

830 elif isinstance(obj, tuple): 

831 obj: tuple[RCNetwork, str] 

832 self.parsers.append(FastParser(obj)) 

833 else: 

834 raise TypeError(f"Object {obj} is not a FastParser instance or a tuple to create it.") 

835 

836 def __getattr__(self, name): 

837 def multi_method(*args, **kwargs): 

838 results = [] 

839 for parser in self.parsers: 

840 attr = getattr(parser, name) 

841 if callable(attr): 

842 results.append(attr(*args, **kwargs)) 

843 else: 

844 results.append(attr) 

845 return results 

846 

847 first_attr = getattr(self.parsers[0], name) 

848 if callable(first_attr): 

849 return multi_method 

850 else: 

851 return [getattr(parser, name) for parser in self.parsers]