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

360 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-13 16:59 +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 

71 def __init__(self, 

72 values: list | np.ndarray, 

73 settings: Settings): 

74 self.network_settings = settings 

75 if isinstance(values, list): 

76 values = np.array(values) 

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

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

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

80 

81 @abstractmethod 

82 def __copy__(self): 

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

84 

85 def invert(self): 

86 """ 

87 Inverts the mask of the given axis. 

88 """ 

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

90 

91 def _add_mask(self, mask): 

92 """ 

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

94 

95 Parameters 

96 ---------- 

97 mask : np.ndarray 

98 The mask to add. 

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

100 """ 

101 self._apply_mask(mask, add=True) 

102 

103 def _subtract_mask(self, mask): 

104 """ 

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

106 

107 Parameters 

108 ---------- 

109 mask : np.ndarray 

110 The mask to add. 

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

112 """ 

113 self._apply_mask(mask, add=False) 

114 

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

116 """ 

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

118 

119 Parameters 

120 ---------- 

121 mask_vector : np.ndarray 

122 The mask to add. 

123 add : bool, optional 

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

125 Add: current | mask_vector 

126 Not add: current & mask_vector 

127 

128 """ 

129 mask_vector = mask_vector.reshape(-1, ) 

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

131 if add: 

132 self.mask = mask_vector | self.mask 

133 else: 

134 self.mask = mask_vector & self.mask 

135 

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

137 """ 

138 Returns the filtered matrix. 

139 

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

141 

142 Parameters 

143 ---------- 

144 matrix : np.ndarray 

145 The matrix to be filtered. 

146 axis : int, optional 

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

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

149 

150 Returns 

151 ------- 

152 np.ndarray : 

153 The filtered matrix. 

154 """ 

155 if axis is None: 

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

157 axis = 0 

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

159 axis = 1 

160 else: 

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

162 if axis == 0: 

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

164 return matrix[self.mask] 

165 else: 

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

167 return matrix[:, self.mask] 

168 

169 def __add__(self, other): 

170 import copy 

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 

182 def __init__(self, 

183 nodes: list[Capacitor] | np.ndarray, 

184 settings: Settings): 

185 """ 

186 Initially: filter out everything. 

187 

188 Parameters 

189 ---------- 

190 nodes : list[Capacitor] | np.ndarray 

191 The nodes which solutions are represented in the columns. 

192 settings: Settings 

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

194 Is used to get the start_date and the weather_data_path 

195 """ 

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

197 

198 def __copy__(self): 

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

200 

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

202 """ 

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

204 

205 Parameters 

206 ---------- 

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

208 The list with the node objects. 

209 """ 

210 if isinstance(nodes, Node): 

211 nodes = [nodes] 

212 elif isinstance(nodes, np.ndarray): 

213 nodes = nodes.tolist() 

214 assert isinstance(nodes, list) 

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

216 self.mask[indices] = True 

217 

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

219 """ 

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

221 

222 Parameters 

223 ---------- 

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

225 The list with the node objects. 

226 """ 

227 if isinstance(nodes, Node): 

228 nodes = [nodes] 

229 elif isinstance(nodes, np.ndarray): 

230 nodes = nodes.tolist() 

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

232 self.mask[indices] = False 

233 

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

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

236 

237 

238class WeatherFilter(FilterMixin): 

239 

240 def __init__(self, 

241 settings: Settings): 

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

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

244 self._weather = None 

245 

246 def __copy__(self): 

247 result = WeatherFilter(self.network_settings) 

248 result._weather = self._weather 

249 return result 

250 

251 @property 

252 def weather(self) -> WeatherData: 

253 if self._weather is None: 

254 self._weather = self.network_settings.weather_data 

255 return self._weather 

256 

257 

258class TimeFilter(FilterMixin): 

259 """ 

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

261 """ 

262 

263 def __init__(self, 

264 seconds: np.ndarray, 

265 settings: Settings, 

266 time_accuracy="ms", 

267 initial_mask_value=False): 

268 """ 

269 Initially: filter out everything. 

270 

271 Parameters 

272 ---------- 

273 seconds : np.ndarray 

274 The row index as increasing seconds. 

275 settings : Settings 

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

277 Is used to get the start_date and the weather_data_path 

278 time_accuracy : str, optional 

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

280 """ 

281 self.time_accuracy = time_accuracy 

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

283 values: np.ndarray = (np.datetime64(settings.start_date) 

284 + np.timedelta64(1, time_accuracy) * np.array(seconds) * time_mult) 

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

286 if initial_mask_value: 

287 self.invert() 

288 

289 def __copy__(self): 

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

291 # Copy all attributes from parent class 

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

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

294 # Copy specific attributes from this class 

295 result.time_accuracy = self.time_accuracy 

296 # Copy any other attributes you have 

297 return result 

298 

299 @property 

300 def datetime(self): 

301 """ 

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

303 

304 Returns 

305 ------- 

306 np.ndarray(datetime.datetime) : 

307 Date times of filtered values. 

308 """ 

309 filtered_values = self.values[self.mask] 

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

311 

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

313 """ 

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

315 

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

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

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

319 

320 Parameters 

321 ---------- 

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

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

324 Is converted to np.datetime64. 

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

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

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

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

329 Is converted to np.datetime64. 

330 

331 Examples 

332 -------- 

333 To apply a range of three days: 

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

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

336 """ 

337 datetime1 = np.datetime64(datetime1) 

338 if datetime2 is None: 

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

340 else: 

341 datetime2 = np.datetime64(datetime2) 

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

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

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

345 

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

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

348 

349 self._add_mask(mask) 

350 

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

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

353 

354 

355class NetworkFilter(Filter): 

356 """ 

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

358 """ 

359 

360 def __init__(self, 

361 seconds: np.ndarray, 

362 nodes: list[Capacitor], 

363 settings: Settings, 

364 time_accuracy="ms"): 

365 """ 

366 Initially: filter out everything. 

367 

368 Parameters 

369 ---------- 

370 seconds : np.ndarray 

371 The row index as increasing seconds. 

372 nodes : list[Capacitor] 

373 The nodes which solutions are represented in the columns. 

374 settings: Settings 

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

376 Is used to get the start_date and the weather_data_path 

377 """ 

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

379 self.number_columns = len(nodes) 

380 self.network_settings: Settings = settings 

381 

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

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

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

385 

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

387 if axis is None: 

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

389 # memory layout by default. 

390 column_filtered = self.apply_column_filter(matrix) 

391 return self.apply_row_filter(column_filtered) 

392 elif axis == 0: 

393 return self.apply_row_filter(matrix) 

394 else: 

395 return self.apply_column_filter(matrix) 

396 

397 def apply_row_filter(self, matrix): 

398 return self.filter_row.apply_filter(matrix) 

399 

400 def apply_column_filter(self, matrix): 

401 return self.filter_column.apply_filter(matrix) 

402 

403 

404class FilteredRCSolution: 

405 

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

407 self._rc_solution: RCSolution = rc_solution 

408 self.filter: Filter = filter_obj 

409 

410 def __getattr__(self, item): 

411 """ 

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

413 """ 

414 attr = getattr(self._rc_solution, item) 

415 

416 if item in [ 

417 "result_vectors", 

418 "temperature_vectors", 

419 "y" 

420 ]: 

421 attr = self.filter.apply_filter(attr) 

422 elif item in [ 

423 "t", 

424 "time_steps", 

425 "input_vectors" 

426 ]: 

427 if isinstance(self.filter, TimeFilter): 

428 attr = self.filter.apply_filter(attr) 

429 elif isinstance(self.filter, NetworkFilter): 

430 attr = self.filter.apply_row_filter(attr) 

431 return attr 

432 

433 

434class FastParser: 

435 """ 

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

437 

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

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

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

441 kept in memory. 

442 

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

444 """ 

445 

446 _total_reserved_memory = 0 # in bytes 

447 

448 def __init__(self, 

449 network_solution_path_tuple: tuple[RCNetwork, str], 

450 solution_size=None): 

451 self.network = network_solution_path_tuple[0] 

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

453 self._solution_size = solution_size 

454 

455 self._blocked_ram = 0 

456 

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

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

459 

460 def __copy__(self): 

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

462 result.network = self.network 

463 result.solution_path = self.solution_path 

464 result._solution_size = self._solution_size 

465 result._blocked_ram = self._blocked_ram 

466 

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

468 result._filter_names = self._filter_names 

469 return result 

470 

471 def __parse_filter_index(self, entry): 

472 if isinstance(entry, str): 

473 entry = self._filter_names.index(entry) 

474 return entry 

475 

476 @property 

477 def result_vectors(self): 

478 if not self.solution_exist: 

479 self.load_solution_safe() 

480 return self.network.rc_solution.result_vectors 

481 

482 @property 

483 def time_vector(self): 

484 if not self.solution_exist: 

485 self.load_solution_safe() 

486 return np.array(seconds_to_dates(self.network.rc_solution.time_steps, 

487 self.network.settings.weather_data.start_time)) 

488 

489 @property 

490 def input_vectors(self): 

491 if not self.solution_exist: 

492 self.load_solution_safe() 

493 return self.network.rc_solution.input_vectors 

494 

495 @property 

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

497 return self._filters 

498 

499 @property 

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

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

502 

503 @property 

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

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

506 

507 @property 

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

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

510 

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

512 """ 

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

514 

515 Parameters 

516 ---------- 

517 entry : int | Any, optional 

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

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

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

521 """ 

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

523 

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

525 """ 

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

527 

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

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

530 

531 Parameters 

532 ---------- 

533 filter_class : type 

534 The Filter class to be added to self._filters 

535 name : str, optional 

536 The name of the filter to add. 

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

538 

539 Returns 

540 ------- 

541 int : 

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

543 self.filter(index) 

544 """ 

545 if not self.solution_exist: 

546 self.load_solution_safe() 

547 assert self.network.rc_solution.exist 

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

549 if filter_class is TimeFilter or filter_class is NetworkFilter: 

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

551 if filter_class is NodeFilter or filter_class is NetworkFilter: 

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

553 self._filters.append(filter_class( 

554 **kwargs 

555 )) 

556 self._filter_names.append(name) 

557 return len(self.filters) - 1 

558 

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

560 """ 

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

562 

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

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

565 

566 Parameters 

567 ---------- 

568 name : str, optional 

569 The name of the filter to add. 

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

571 return_index : bool, optional 

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

573 

574 Returns 

575 ------- 

576 None | int : 

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

578 self.filter(index) 

579 """ 

580 result = self._add_filter(NetworkFilter, name) 

581 if return_index: 

582 return result 

583 

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

585 """ 

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

587 

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

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

590 

591 Parameters 

592 ---------- 

593 name : str, optional 

594 The name of the filter to add. 

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

596 return_index : bool, optional 

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

598 

599 Returns 

600 ------- 

601 None | int : 

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

603 self.filter(index) 

604 """ 

605 result = self._add_filter(TimeFilter, name) 

606 if return_index: 

607 return result 

608 

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

610 """ 

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

612 

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

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

615 

616 Parameters 

617 ---------- 

618 name : str, optional 

619 The name of the filter to add. 

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

621 return_index : bool, optional 

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

623 

624 Returns 

625 ------- 

626 None | int : 

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

628 self.filter(index) 

629 """ 

630 result = self._add_filter(NodeFilter, name) 

631 if return_index: 

632 return result 

633 

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

635 """ 

636 Removes the desired filter from the filters list. 

637 

638 Remember: Previously passed filter indexes might change. 

639 

640 Parameters 

641 ---------- 

642 entry : int | Any, optional 

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

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

645 If None, the last filter is used. 

646 """ 

647 index = self.__parse_filter_index(entry) 

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

649 l.pop(index) 

650 

651 def add_time_filters(self, 

652 days=None, 

653 weeks=None, 

654 months=None, 

655 years=None, 

656 filter_name_add_on="", 

657 return_names=False): 

658 """ 

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

660 

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

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

663 

664 If no value is passed no filter is created. 

665 

666 Parameters 

667 ---------- 

668 days : list[datetime] | datetime, optional 

669 The days that should be plotted. 

670 weeks : list[datetime] | datetime, optional 

671 The weeks that should be plotted. 

672 Each week is represented by its start date. 

673 months : list[datetime] | datetime, optional 

674 The months that should be plotted. 

675 Each month is represented by its start date. 

676 years : list[datetime] | datetime, optional 

677 The years that should be plotted. 

678 Each year is represented by its start date. 

679 filter_name_add_on : str, optional 

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

681 return_names : bool, optional 

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

683 months and years and the corresponding list. 

684 

685 Returns 

686 ------- 

687 dict | None : 

688 If return_names, a dict with the layout: 

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

690 with the names of the filters in the lists. 

691 """ 

692 import calendar 

693 names = { 

694 "days": days or [], 

695 "weeks": weeks or [], 

696 "months": months or [], 

697 "years": years or [], 

698 } 

699 for i, day in enumerate(days): 

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

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

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

703 time_filter.daterange(datetime1=day) 

704 for i, week in enumerate(weeks): 

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

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

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

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

709 for i, dt in enumerate(months): 

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

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

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

713 if dt.month == 12: 

714 next_year = dt.year + 1 

715 next_month = 1 

716 else: 

717 next_year = dt.year 

718 next_month = dt.month + 1 

719 

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

721 next_day = min(dt.day, max_day) 

722 

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

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

725 for i, dt in enumerate(years): 

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

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

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

729 # Handle leap year edge case for Feb 29 

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

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

732 else: 

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

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

735 if return_names: 

736 return names 

737 

738 def _block_memory(self): 

739 self._blocked_ram = self.solution_size * 1.01 

740 FastParser._total_reserved_memory += self.solution_size 

741 

742 def _free_memory(self): 

743 FastParser._total_reserved_memory -= self._blocked_ram 

744 self._blocked_ram = 0 

745 

746 @property 

747 def solution_exist(self) -> bool: 

748 return self.network.rc_solution.exist 

749 

750 @property 

751 def solution_size(self): 

752 if self._solution_size is None: 

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

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

755 else: 

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

757 self._solution_size = 10 * 1024 ** 3 

758 return self._solution_size 

759 

760 def load_solution(self): 

761 """ 

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

763 

764 Raises 

765 ------ 

766 MemoryError : 

767 If not enough memory is available. 

768 """ 

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

770 self._block_memory() 

771 assert self.network.rc_solution.load_solution( 

772 self.solution_path), f"Solution file {self.solution_path} not found" 

773 self._free_memory() 

774 else: 

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

776 

777 def load_solution_safe(self): 

778 """ 

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

780 

781 Raises 

782 ------ 

783 MemoryError : 

784 If not enough memory is available within 1 hour. 

785 """ 

786 counter = 0 

787 success = False 

788 while counter <= 3600: 

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

790 self.load_solution() 

791 success = True 

792 break 

793 time.sleep(1) 

794 counter += 1 

795 if not success: 

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

797 

798 def free_ram(self): 

799 """ 

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

801 

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

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

804 """ 

805 self.network.rc_solution.delete_solutions(True) 

806 

807 # garbage collection 

808 gc.collect() 

809 

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

811 """ 

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

813 

814 Parameters 

815 ---------- 

816 function : Callable 

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

818 

819 Returns 

820 ------- 

821 tuple : 

822 The results for each filter. 

823 """ 

824 result = [] 

825 for filter_obj in self.filters: 

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

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

828 return tuple(result) 

829 

830 

831class MultiParser: 

832 """ 

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

834 

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

836 

837 This should be used with brain! 

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

839 """ 

840 

841 def __init__(self, objects: list): 

842 """ 

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

844 

845 Parameters 

846 ---------- 

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

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

849 """ 

850 assert isinstance(objects, list) 

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

852 for obj in objects: 

853 if isinstance(obj, FastParser): 

854 self.parsers.append(obj) 

855 elif isinstance(obj, tuple): 

856 obj: tuple[RCNetwork, str] 

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

858 else: 

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

860 

861 def __getattr__(self, name): 

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

863 results = [] 

864 for parser in self.parsers: 

865 attr = getattr(parser, name) 

866 if callable(attr): 

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

868 else: 

869 results.append(attr) 

870 return results 

871 

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

873 if callable(first_attr): 

874 return multi_method 

875 else: 

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