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

360 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-03 16:07 +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 gc 

9import os 

10import time 

11from abc import abstractmethod 

12from collections.abc import Callable 

13from copy import copy 

14from datetime import datetime, timedelta 

15from typing import Any 

16 

17import numpy as np 

18 

19from pyrc.core.components.capacitor import Capacitor 

20from pyrc.core.components.templates import RCSolution 

21from pyrc.core.network import RCNetwork 

22from pyrc.core.nodes import Node 

23from pyrc.core.settings import Settings 

24from pyrc.dataHandler.weather import WeatherData 

25from pyrc.tools.functions import seconds_to_dates 

26from pyrc.tools.science import get_free_ram 

27 

28 

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

30 """ 

31 Makes one direction vector out of the input. 

32 

33 Parameters 

34 ---------- 

35 direction 

36 

37 Returns 

38 ------- 

39 np.ndarray : 

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

41 """ 

42 if isinstance(direction, str): 

43 sign = 1 

44 if len(direction) == 2: 

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

46 sign = -1 

47 direction = direction[1] 

48 assert len(direction) == 1 

49 match direction.lower(): 

50 case "x": 

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

52 case "y": 

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

54 case "z": 

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

56 case _: 

57 raise ValueError("Invalid direction input.") 

58 direction = sign * direction 

59 return direction 

60 

61 

62class Filter: 

63 @abstractmethod 

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

65 pass 

66 

67 

68class FilterMixin(Filter): 

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

70 self.network_settings = settings 

71 if isinstance(values, list): 

72 values = np.array(values) 

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

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

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

76 

77 @abstractmethod 

78 def __copy__(self): 

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

80 

81 def invert(self): 

82 """ 

83 Inverts the mask of the given axis. 

84 """ 

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

86 

87 def _add_mask(self, mask): 

88 """ 

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

90 

91 Parameters 

92 ---------- 

93 mask : np.ndarray 

94 The mask to add. 

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

96 """ 

97 self._apply_mask(mask, add=True) 

98 

99 def _subtract_mask(self, mask): 

100 """ 

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

102 

103 Parameters 

104 ---------- 

105 mask : np.ndarray 

106 The mask to add. 

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

108 """ 

109 self._apply_mask(mask, add=False) 

110 

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

112 """ 

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

114 

115 Parameters 

116 ---------- 

117 mask_vector : np.ndarray 

118 The mask to add. 

119 add : bool, optional 

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

121 Add: current | mask_vector 

122 Not add: current & mask_vector 

123 

124 """ 

125 mask_vector = mask_vector.reshape( 

126 -1, 

127 ) 

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

129 if add: 

130 self.mask = mask_vector | self.mask 

131 else: 

132 self.mask = mask_vector & self.mask 

133 

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

135 """ 

136 Returns the filtered matrix. 

137 

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

139 

140 Parameters 

141 ---------- 

142 matrix : np.ndarray 

143 The matrix to be filtered. 

144 axis : int, optional 

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

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

147 

148 Returns 

149 ------- 

150 np.ndarray : 

151 The filtered matrix. 

152 """ 

153 if axis is None: 

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

155 axis = 0 

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

157 axis = 1 

158 else: 

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

160 if axis == 0: 

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

162 return matrix[self.mask] 

163 else: 

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

165 return matrix[:, self.mask] 

166 

167 def __add__(self, other): 

168 import copy 

169 

170 result = copy.copy(self) 

171 result._add_mask(other.mask) 

172 return result 

173 

174 @property 

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

176 return self.values[self.mask] 

177 

178 

179class NodeFilter(FilterMixin): 

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

181 """ 

182 Initially: filter out everything. 

183 

184 Parameters 

185 ---------- 

186 nodes : list[Capacitor] | np.ndarray 

187 The nodes which solutions are represented in the columns. 

188 settings: Settings 

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

190 Is used to get the start_date and the weather_data_path 

191 """ 

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

193 

194 def __copy__(self): 

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

196 

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

198 """ 

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

200 

201 Parameters 

202 ---------- 

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

204 The list with the node objects. 

205 """ 

206 if isinstance(nodes, Node): 

207 nodes = [nodes] 

208 elif isinstance(nodes, np.ndarray): 

209 nodes = nodes.tolist() 

210 assert isinstance(nodes, list) 

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

212 self.mask[indices] = True 

213 

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

215 """ 

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

217 

218 Parameters 

219 ---------- 

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

221 The list with the node objects. 

222 """ 

223 if isinstance(nodes, Node): 

224 nodes = [nodes] 

225 elif isinstance(nodes, np.ndarray): 

226 nodes = nodes.tolist() 

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

228 self.mask[indices] = False 

229 

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

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

232 

233 

234class WeatherFilter(FilterMixin): 

235 def __init__(self, settings: Settings): 

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

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

238 self._weather = None 

239 

240 def __copy__(self): 

241 result = WeatherFilter(self.network_settings) 

242 result._weather = self._weather 

243 return result 

244 

245 @property 

246 def weather(self) -> WeatherData: 

247 if self._weather is None: 

248 self._weather = self.network_settings.weather_data 

249 return self._weather 

250 

251 

252class TimeFilter(FilterMixin): 

253 """ 

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

255 """ 

256 

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

258 """ 

259 Initially: filter out everything. 

260 

261 Parameters 

262 ---------- 

263 seconds : np.ndarray 

264 The row index as increasing seconds. 

265 settings : Settings 

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

267 Is used to get the start_date and the weather_data_path 

268 time_accuracy : str, optional 

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

270 """ 

271 self.time_accuracy = time_accuracy 

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

273 values: np.ndarray = ( 

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

275 ) 

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

277 if initial_mask_value: 

278 self.invert() 

279 

280 def __copy__(self): 

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

282 # Copy all attributes from parent class 

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

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

285 # Copy specific attributes from this class 

286 result.time_accuracy = self.time_accuracy 

287 # Copy any other attributes you have 

288 return result 

289 

290 @property 

291 def datetime(self): 

292 """ 

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

294 

295 Returns 

296 ------- 

297 np.ndarray(datetime.datetime) : 

298 Date times of filtered values. 

299 """ 

300 filtered_values = self.values[self.mask] 

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

302 

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

304 """ 

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

306 

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

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

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

310 

311 Parameters 

312 ---------- 

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

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

315 Is converted to np.datetime64. 

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

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

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

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

320 Is converted to np.datetime64. 

321 

322 Examples 

323 -------- 

324 To apply a range of three days: 

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

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

327 """ 

328 datetime1 = np.datetime64(datetime1) 

329 if datetime2 is None: 

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

331 else: 

332 datetime2 = np.datetime64(datetime2) 

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

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

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

336 

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

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

339 ) 

340 

341 self._add_mask(mask) 

342 

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

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

345 

346 

347class NetworkFilter(Filter): 

348 """ 

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

350 """ 

351 

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

353 """ 

354 Initially: filter out everything. 

355 

356 Parameters 

357 ---------- 

358 seconds : np.ndarray 

359 The row index as increasing seconds. 

360 nodes : list[Capacitor] 

361 The nodes which solutions are represented in the columns. 

362 settings: Settings 

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

364 Is used to get the start_date and the weather_data_path 

365 """ 

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

367 self.number_columns = len(nodes) 

368 self.network_settings: Settings = settings 

369 

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

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

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

373 

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

375 if axis is None: 

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

377 # memory layout by default. 

378 column_filtered = self.apply_column_filter(matrix) 

379 return self.apply_row_filter(column_filtered) 

380 elif axis == 0: 

381 return self.apply_row_filter(matrix) 

382 else: 

383 return self.apply_column_filter(matrix) 

384 

385 def apply_row_filter(self, matrix): 

386 return self.filter_row.apply_filter(matrix) 

387 

388 def apply_column_filter(self, matrix): 

389 return self.filter_column.apply_filter(matrix) 

390 

391 

392class FilteredRCSolution: 

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

394 self._rc_solution: RCSolution = rc_solution 

395 self.filter: Filter = filter_obj 

396 

397 def __getattr__(self, item): 

398 """ 

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

400 """ 

401 attr = getattr(self._rc_solution, item) 

402 

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

404 attr = self.filter.apply_filter(attr) 

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

406 if isinstance(self.filter, TimeFilter): 

407 attr = self.filter.apply_filter(attr) 

408 elif isinstance(self.filter, NetworkFilter): 

409 attr = self.filter.apply_row_filter(attr) 

410 return attr 

411 

412 

413class FastParser: 

414 """ 

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

416 

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

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

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

420 kept in memory. 

421 

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

423 """ 

424 

425 _total_reserved_memory = 0 # in bytes 

426 

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

428 self.network = network_solution_path_tuple[0] 

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

430 self._solution_size = solution_size 

431 

432 self._blocked_ram = 0 

433 

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

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

436 

437 def __copy__(self): 

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

439 result.network = self.network 

440 result.solution_path = self.solution_path 

441 result._solution_size = self._solution_size 

442 result._blocked_ram = self._blocked_ram 

443 

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

445 result._filter_names = self._filter_names 

446 return result 

447 

448 def __parse_filter_index(self, entry): 

449 if isinstance(entry, str): 

450 entry = self._filter_names.index(entry) 

451 return entry 

452 

453 @property 

454 def result_vectors(self): 

455 if not self.solution_exist: 

456 self.load_solution_safe() 

457 return self.network.rc_solution.result_vectors 

458 

459 @property 

460 def time_vector(self): 

461 if not self.solution_exist: 

462 self.load_solution_safe() 

463 return np.array( 

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

465 ) 

466 

467 @property 

468 def input_vectors(self): 

469 if not self.solution_exist: 

470 self.load_solution_safe() 

471 return self.network.rc_solution.input_vectors 

472 

473 @property 

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

475 return self._filters 

476 

477 @property 

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

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

480 

481 @property 

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

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

484 

485 @property 

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

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

488 

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

490 """ 

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

492 

493 Parameters 

494 ---------- 

495 entry : int | Any, optional 

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

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

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

499 """ 

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

501 

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

503 """ 

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

505 

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

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

508 

509 Parameters 

510 ---------- 

511 filter_class : type 

512 The Filter class to be added to self._filters 

513 name : str, optional 

514 The name of the filter to add. 

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

516 

517 Returns 

518 ------- 

519 int : 

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

521 self.filter(index) 

522 """ 

523 if not self.solution_exist: 

524 self.load_solution_safe() 

525 assert self.network.rc_solution.exist 

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

527 if filter_class is TimeFilter or filter_class is NetworkFilter: 

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

529 if filter_class is NodeFilter or filter_class is NetworkFilter: 

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

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

532 self._filter_names.append(name) 

533 return len(self.filters) - 1 

534 

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

536 """ 

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

538 

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

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

541 

542 Parameters 

543 ---------- 

544 name : str, optional 

545 The name of the filter to add. 

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

547 return_index : bool, optional 

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

549 

550 Returns 

551 ------- 

552 None | int : 

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

554 self.filter(index) 

555 """ 

556 result = self._add_filter(NetworkFilter, name) 

557 if return_index: 

558 return result 

559 

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

561 """ 

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

563 

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

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

566 

567 Parameters 

568 ---------- 

569 name : str, optional 

570 The name of the filter to add. 

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

572 return_index : bool, optional 

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

574 

575 Returns 

576 ------- 

577 None | int : 

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

579 self.filter(index) 

580 """ 

581 result = self._add_filter(TimeFilter, name) 

582 if return_index: 

583 return result 

584 

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

586 """ 

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

588 

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

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

591 

592 Parameters 

593 ---------- 

594 name : str, optional 

595 The name of the filter to add. 

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

597 return_index : bool, optional 

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

599 

600 Returns 

601 ------- 

602 None | int : 

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

604 self.filter(index) 

605 """ 

606 result = self._add_filter(NodeFilter, name) 

607 if return_index: 

608 return result 

609 

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

611 """ 

612 Removes the desired filter from the filters list. 

613 

614 Remember: Previously passed filter indexes might change. 

615 

616 Parameters 

617 ---------- 

618 entry : int | Any, optional 

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

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

621 If None, the last filter is used. 

622 """ 

623 index = self.__parse_filter_index(entry) 

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

625 l.pop(index) 

626 

627 def add_time_filters( 

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

629 ): 

630 """ 

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

632 

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

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

635 

636 If no value is passed no filter is created. 

637 

638 Parameters 

639 ---------- 

640 days : list[datetime] | datetime, optional 

641 The days that should be plotted. 

642 weeks : list[datetime] | datetime, optional 

643 The weeks that should be plotted. 

644 Each week is represented by its start date. 

645 months : list[datetime] | datetime, optional 

646 The months that should be plotted. 

647 Each month is represented by its start date. 

648 years : list[datetime] | datetime, optional 

649 The years that should be plotted. 

650 Each year is represented by its start date. 

651 filter_name_add_on : str, optional 

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

653 return_names : bool, optional 

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

655 months and years and the corresponding list. 

656 

657 Returns 

658 ------- 

659 dict | None : 

660 If return_names, a dict with the layout: 

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

662 with the names of the filters in the lists. 

663 """ 

664 import calendar 

665 

666 names = { 

667 "days": days or [], 

668 "weeks": weeks or [], 

669 "months": months or [], 

670 "years": years or [], 

671 } 

672 for i, day in enumerate(days): 

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

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

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

676 time_filter.daterange(datetime1=day) 

677 for i, week in enumerate(weeks): 

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

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

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

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

682 for i, dt in enumerate(months): 

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

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

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

686 if dt.month == 12: 

687 next_year = dt.year + 1 

688 next_month = 1 

689 else: 

690 next_year = dt.year 

691 next_month = dt.month + 1 

692 

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

694 next_day = min(dt.day, max_day) 

695 

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

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

698 for i, dt in enumerate(years): 

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

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

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

702 # Handle leap year edge case for Feb 29 

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

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

705 else: 

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

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

708 if return_names: 

709 return names 

710 

711 def _block_memory(self): 

712 self._blocked_ram = self.solution_size * 1.01 

713 FastParser._total_reserved_memory += self.solution_size 

714 

715 def _free_memory(self): 

716 FastParser._total_reserved_memory -= self._blocked_ram 

717 self._blocked_ram = 0 

718 

719 @property 

720 def solution_exist(self) -> bool: 

721 return self.network.rc_solution.exist 

722 

723 @property 

724 def solution_size(self): 

725 if self._solution_size is None: 

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

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

728 else: 

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

730 self._solution_size = 10 * 1024**3 

731 return self._solution_size 

732 

733 def load_solution(self): 

734 """ 

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

736 

737 Raises 

738 ------ 

739 MemoryError : 

740 If not enough memory is available. 

741 """ 

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

743 self._block_memory() 

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

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

746 ) 

747 self._free_memory() 

748 else: 

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

750 

751 def load_solution_safe(self): 

752 """ 

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

754 

755 Raises 

756 ------ 

757 MemoryError : 

758 If not enough memory is available within 1 hour. 

759 """ 

760 counter = 0 

761 success = False 

762 while counter <= 3600: 

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

764 self.load_solution() 

765 success = True 

766 break 

767 time.sleep(1) 

768 counter += 1 

769 if not success: 

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

771 

772 def free_ram(self): 

773 """ 

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

775 

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

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

778 """ 

779 self.network.rc_solution.delete_solutions(True) 

780 

781 # garbage collection 

782 gc.collect() 

783 

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

785 """ 

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

787 

788 Parameters 

789 ---------- 

790 function : Callable 

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

792 

793 Returns 

794 ------- 

795 tuple : 

796 The results for each filter. 

797 """ 

798 result = [] 

799 for filter_obj in self.filters: 

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

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

802 return tuple(result) 

803 

804 

805class MultiParser: 

806 """ 

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

808 

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

810 

811 This should be used with brain! 

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

813 """ 

814 

815 def __init__(self, objects: list): 

816 """ 

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

818 

819 Parameters 

820 ---------- 

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

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

823 """ 

824 assert isinstance(objects, list) 

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

826 for obj in objects: 

827 if isinstance(obj, FastParser): 

828 self.parsers.append(obj) 

829 elif isinstance(obj, tuple): 

830 obj: tuple[RCNetwork, str] 

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

832 else: 

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

834 

835 def __getattr__(self, name): 

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

837 results = [] 

838 for parser in self.parsers: 

839 attr = getattr(parser, name) 

840 if callable(attr): 

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

842 else: 

843 results.append(attr) 

844 return results 

845 

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

847 if callable(first_attr): 

848 return multi_method 

849 else: 

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