Coverage for pyrc\core\components\templates.py: 73%

724 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 

8from __future__ import annotations 

9 

10import os 

11import pickle 

12import re 

13from abc import ABC, abstractmethod 

14from typing import TYPE_CHECKING, Any, Optional 

15 

16import numpy as np 

17import pandas as pd 

18from sympy import Expr, Symbol 

19from vpython import box, vector 

20 

21from pyrc.core.components.input import Input 

22from pyrc.core.visualization.color.color import value_to_rgb 

23from pyrc.tools.errors import FixedPositionError, FixedXYError, FixedZError 

24from pyrc.tools.functions import is_set 

25from pyrc.tools.science import is_numeric 

26from pyrc.visualization.vtk_parser import write_static_cells_vtu, write_temperature_vtu 

27 

28if TYPE_CHECKING: 

29 from pyrc.core.components.node import TemperatureNode 

30 from pyrc.core.components.resistor import Resistor 

31 from pyrc.core.inputs import InternalHeatSource 

32 from pyrc.core.resistors import MassTransport 

33 

34 

35class RCObjects: 

36 def __init__(self, nodes: list = None, resistors: list = None, boundaries: list = None): 

37 if nodes is None: 

38 nodes = [] 

39 self.__nodes = nodes 

40 if resistors is None: 

41 resistors = [] 

42 self.__resistors: list[Resistor] = resistors 

43 if boundaries is None: 

44 boundaries = [] 

45 self.__boundaries = boundaries 

46 self.__input_objs = [] 

47 self.__internal_heat_sources = None 

48 

49 @property 

50 def nodes(self) -> list: 

51 return self.__nodes 

52 

53 @property 

54 def capacities(self) -> list: 

55 return self.__nodes 

56 

57 @property 

58 def mass_flow_nodes(self): 

59 from pyrc.core.nodes import MassFlowNode 

60 

61 return [n for n in self.nodes if isinstance(n, MassFlowNode)] 

62 

63 @property 

64 def resistors(self) -> list[Resistor]: 

65 return self.__resistors 

66 

67 @property 

68 def boundaries(self) -> list: 

69 return self.__boundaries 

70 

71 @property 

72 def inputs(self) -> list[EquationItemInput]: 

73 if self.__input_objs == [] and self.nodes is not None: 

74 result = [] 

75 result.extend(self.boundaries) 

76 if self.internal_heat_sources is not None: 

77 result.extend(self.internal_heat_sources) 

78 self.__input_objs: list = result 

79 return self.__input_objs 

80 

81 @property 

82 def all(self) -> list[TemperatureNode | Resistor]: 

83 """ 

84 Returns all `TemperatureNode`\\s and `Resistor`\\s (network objects, that are linked together). 

85 

86 `InternalHeatSource` s are not returned! 

87 

88 Returns 

89 ------- 

90 list[TemperatureNode | Resistor] : 

91 Capacitors, BoundaryConditions and Resistors 

92 """ 

93 result = [] 

94 item: list | None 

95 for item in [self.__nodes, self.__resistors, self.__boundaries]: 

96 if item is not None and item != []: 

97 result.extend(item) 

98 return result 

99 

100 @property 

101 def all_equation_objects(self) -> list[EquationItem]: 

102 return [*self.all, *self.other_equation_objects] 

103 

104 @property 

105 def other_equation_objects(self) -> list[EquationItem]: 

106 """ 

107 Like `RCObjects.all` but not the connected RC objects were returned but all other `EquationItem` s. 

108 

109 This is mainly used for `InternalHeatSource` s and in the future for other input items. 

110 

111 Returns 

112 ------- 

113 list[InternalHeatSource] : 

114 All other `EquationItem` s. 

115 Until now, it's just all InternalHeatSource items. 

116 """ 

117 return self.internal_heat_sources 

118 

119 @property 

120 def internal_heat_sources(self) -> list[InternalHeatSource]: 

121 if self.__internal_heat_sources is None: 

122 self.__internal_heat_sources = [n.internal_heat_source for n in self.nodes if n.internal_heat_source] 

123 return self.__internal_heat_sources 

124 

125 def set_lists(self, capacitors: list = None, resistors: list = None, boundaries: list = None): 

126 if capacitors is not None: 

127 assert isinstance(capacitors, list) 

128 self.__nodes = capacitors 

129 if resistors is not None: 

130 assert isinstance(resistors, list) 

131 self.__resistors = resistors 

132 if boundaries is not None: 

133 assert isinstance(boundaries, list) 

134 self.__boundaries = boundaries 

135 

136 @property 

137 def raw_data(self) -> tuple: 

138 return (self.__nodes, self.__resistors, self.__boundaries, self.__input_objs) 

139 

140 def wipe_all(self): 

141 """ 

142 Deletes every raw data. 

143 """ 

144 self.__nodes = [] 

145 self.__resistors = [] 

146 self.__boundaries = [] 

147 self.__input_objs = [] 

148 self.__internal_heat_sources = None 

149 

150 def get_all_objects(self, variant: type) -> list: 

151 """ 

152 Returns a list with tuples with all objects in the network with requested variant/type. 

153 

154 Parameters 

155 ---------- 

156 variant : type 

157 The ``type`` of the objects that will be returned. 

158 Will be used for the ``isinstance()`` match. 

159 

160 Returns 

161 ------- 

162 list : 

163 The list with all objects in the network with requested variant. 

164 """ 

165 result = [] 

166 

167 for element in self.all: 

168 if isinstance(element, variant): 

169 result.append(element) 

170 return result 

171 

172 def set_loaded_data(self, loaded_objects: RCObjects): 

173 """ 

174 Replaces all attributes with the ones of the loaded `RCObjects`. 

175 

176 Parameters 

177 ---------- 

178 loaded_objects : RCObjects 

179 The loaded `RCObjects` that should "replace" self / should be used to overwrite self. 

180 

181 """ 

182 raw_data = loaded_objects.raw_data 

183 self.__nodes = raw_data[0] 

184 self.__resistors = raw_data[1] 

185 self.__boundaries = raw_data[2] 

186 self.__input_objs = raw_data[3] 

187 

188 

189initial_rc_objects = RCObjects() 

190 

191 

192class SymbolMixin(ABC): 

193 @property 

194 @abstractmethod 

195 def symbols(self) -> list: 

196 """ 

197 Returns a list of all sympy.symbols of the object, except time dependent symbols. 

198 

199 Must be in the same order as self.values. 

200 

201 Returns 

202 ------- 

203 list : 

204 The list of sympy.symbols. 

205 """ 

206 pass 

207 

208 @property 

209 @abstractmethod 

210 def values(self) -> list: 

211 """ 

212 Returns a list of all values of all object symbols, except of time dependent symbols. 

213 

214 Must be in the same order as self.symbols. 

215 

216 Returns 

217 ------- 

218 list : 

219 The list of sympy.symbols. 

220 """ 

221 pass 

222 

223 @property 

224 def time_dependent_symbols(self) -> list[Symbol]: 

225 """ 

226 Returns a list of all symbols that are needed to calculate self.value. 

227 

228 The list is sorted by the name of the symbols for deterministic reasons. 

229 

230 Returns 

231 ------- 

232 list[Symbol] : 

233 All the symbols needed to calculate self.value (symbols that are time dependent and will be calculated 

234 between time steps). 

235 """ 

236 symbols = {sym for value in self.values if isinstance(value, Expr) for sym in value.free_symbols} 

237 return sorted(symbols, key=lambda s: s.name) 

238 

239 

240class EquationItem(SymbolMixin, ABC): 

241 item_counter = 0 

242 

243 def __init__(self): 

244 EquationItem.item_counter += 1 

245 self.id: int = EquationItem.item_counter 

246 self._index = None 

247 

248 @classmethod 

249 def reset_counter(cls): 

250 EquationItem.item_counter = 0 

251 

252 

253class RCSolution: 

254 def __init__(self, rc_objects: RCObjects = initial_rc_objects): 

255 self.rc_objects = rc_objects 

256 self.__result_vectors: np.ndarray | Any = None 

257 self._input_vectors: list = [] 

258 self.time_steps: np.ndarray | Any = None 

259 

260 self._temperature_dataframe = None 

261 self._dataframe = None 

262 

263 self.last_saved_timestep_index = 0 

264 

265 @property 

266 def input_exists(self) -> bool: 

267 if self._input_vectors: 

268 return True 

269 return False 

270 

271 @property 

272 def inputs(self): 

273 return self.rc_objects.inputs 

274 

275 @property 

276 def nodes(self): 

277 return self.rc_objects.nodes 

278 

279 @property 

280 def input_vectors(self) -> np.ndarray | Any: 

281 if self._input_vectors: 

282 return np.concatenate(self._input_vectors, axis=0) 

283 return None 

284 

285 def last_value_input(self, index): 

286 return self._input_vectors[-1][-1, index] 

287 

288 def append_to_input(self, new_input_vector: np.ndarray): 

289 self._input_vectors.append(new_input_vector.reshape(1, -1)) 

290 

291 def delete_last_input(self): 

292 if len(self._input_vectors) > 0: 

293 self._input_vectors.pop() 

294 

295 def delete_solution_except_first(self): 

296 """ 

297 Deletes every solution except for the time_step == 0. 

298 """ 

299 self.__result_vectors = self.__result_vectors[0, :] 

300 first_input_vector = self._input_vectors[0] 

301 self._input_vectors = [first_input_vector] 

302 self.time_steps = np.array(self.time_steps[0]) 

303 

304 self._temperature_dataframe = None 

305 self._dataframe = None 

306 

307 self.last_saved_timestep_index = 0 

308 

309 def save_solution(self, path_with_name_and_ending: str): 

310 with open(path_with_name_and_ending, "wb") as f: 

311 pickle.dump(self.raw_data, f) 

312 if self.time_steps is not None: 

313 self.last_saved_timestep_index = len(self.time_steps) - 1 

314 

315 def save_to_file_only(self, t: np.ndarray, y: np.ndarray, path_with_name_and_ending: str): 

316 """ 

317 Only save the passed solution to file and delete the input vector except the last value. 

318 

319 The t and y values are not saved into the solution object to prevent high RAM usage. The input vector is 

320 deleted for the same reason. Only the last value of the input vector is kept to use it for further solving. 

321 

322 Parameters 

323 ---------- 

324 t : np.ndarray 

325 The time step array of the solution. 

326 y : np.ndarray 

327 The result array/matrix of the solution. 

328 path_with_name_and_ending : str 

329 Where to save the solution. 

330 """ 

331 if t is not None: 

332 data = [y, self._input_vectors[-len(t):], t, None, None] 

333 with open(path_with_name_and_ending, "wb") as f: 

334 pickle.dump(data, f) 

335 # delete the input vector to make space for new one 

336 self._input_vectors = [] 

337 

338 def save_new_solution(self, path_with_name_and_ending: str): 

339 """ 

340 Like save_solution, but it only saves the new solution data instead of everything. 

341 

342 New data is defined as all data that is saved in a time step greater than self.last_saved_timestep. 

343 

344 Parameters 

345 ---------- 

346 path_with_name_and_ending : str 

347 Where to save the solution. 

348 

349 Returns 

350 ------- 

351 

352 """ 

353 if self.time_steps is None: 

354 self.save_solution(path_with_name_and_ending) 

355 else: 

356 # save everything from self.last_saved_timestep_index up to the last time step 

357 with open(path_with_name_and_ending, "wb") as f: 

358 pickle.dump(self.raw_data_last(self.last_saved_timestep_index + 1), f) 

359 

360 if self.time_steps is not None: 

361 self.last_saved_timestep_index = len(self.time_steps) - 1 

362 

363 def write_paraview_data( 

364 self, 

365 folder: str, 

366 increment: int = None, 

367 number_of_saved_steps: int = None, 

368 time_increment: int | float | Any = None, 

369 use_degrees_celsius: bool = True, 

370 ): 

371 """ 

372 Parsing the result data to vtu files that can be used to visualize the result in paraview. 

373 

374 It is recommended to not generate more than several thousand resolution steps. 

375 If no increment is given, about 2000 result steps are created. 

376 

377 Parameters 

378 ---------- 

379 folder : str 

380 The folder to save the Paraview data in. 

381 increment : int, optional 

382 If specified, only the incremental part of the result is parsed (to shorten the write time). 

383 If None, the increment is calculated so that a maximum of 2000 results are created. 

384 number_of_saved_steps : int, optional 

385 Works like increment but instead of walking a fixed step width it calculates the increment using the 

386 given number to get the number of steps. 

387 Or: Say, how many steps should be saved (+-1). 

388 Overwrites the increment parameter. 

389 time_increment : int | float | Any, optional 

390 The x'th time that should be saved in seconds. 

391 If given, the increment parameter is not used. 

392 Example usage: time_increment = 120 

393 The result files are created for results every 120 seconds. 

394 use_degrees_celsius : bool, optional 

395 If True, the temperatures are saved as degree Celsius. In Kelvin otherwise. 

396 """ 

397 static_points, static_cells = write_static_cells_vtu(self.nodes, folder) 

398 

399 if increment is None: 

400 increment = self.time_steps_count // 2000 

401 if number_of_saved_steps is not None: 

402 increment = self.time_steps_count // number_of_saved_steps 

403 increment = max(1, int(increment)) 

404 if time_increment is not None: 

405 assert isinstance(time_increment, float) or isinstance(time_increment, int) 

406 mask = None 

407 if time_increment is not None: 

408 mask = np.diff(np.floor((self.time_steps - self.time_steps[0]) / time_increment), prepend=-1).astype(bool) 

409 if mask is not None: 

410 print("Time increment is used.") 

411 result = self.result_vectors[mask, :] 

412 time_steps = self.time_steps[mask].tolist() 

413 else: 

414 print("Manual increment is used.") 

415 result = self.result_vectors[::increment, :] 

416 time_steps = self.time_steps[::increment].tolist() 

417 

418 if use_degrees_celsius: 

419 result -= 273.15 

420 

421 write_temperature_vtu( 

422 result, 

423 time_steps, 

424 static_points, 

425 static_cells, 

426 folder, 

427 step_interval=1, # reducing of the result was already done, so parse every step 

428 ) 

429 

430 @property 

431 def raw_data(self): 

432 return [self.result_vectors, self._input_vectors, self.time_steps, self._temperature_dataframe, self._dataframe] 

433 

434 def raw_data_last(self, starting_index): 

435 """ 

436 Like raw_data but only returns the values starting from `starting_index`. 

437 

438 Parameters 

439 ---------- 

440 starting_index : int 

441 The index where to start getting the data from. 

442 

443 Returns 

444 ------- 

445 list : 

446 A list with all raw data starting from `starting_index`. 

447 """ 

448 result_vectors = None 

449 if self.result_vectors is not None: 

450 result_vectors = self.result_vectors[starting_index:, :] 

451 input_vectors = [] 

452 if self._input_vectors: 

453 input_vectors = self._input_vectors[starting_index:] 

454 time_steps = None 

455 if self.time_steps is not None: 

456 time_steps = self.time_steps[starting_index:] 

457 temperature_dataframe = None 

458 if self._temperature_dataframe is not None: 

459 temperature_dataframe = self._temperature_dataframe.iloc[starting_index:] 

460 dataframe = None 

461 if self._dataframe is not None: 

462 dataframe = self._dataframe.iloc[starting_index:] 

463 

464 return [result_vectors, input_vectors, time_steps, temperature_dataframe, dataframe] 

465 

466 def delete_solutions(self, confirm=False): 

467 """ 

468 Use with care! Deletes all data if it's confirm=True. 

469 """ 

470 if confirm: 

471 self.__result_vectors = None 

472 self._input_vectors = [] 

473 self.time_steps = None 

474 

475 self._temperature_dataframe = None 

476 self._dataframe = None 

477 

478 self.last_saved_timestep_index = 0 

479 else: 

480 print("You have to confirm to delete all data.") 

481 

482 def load_solution(self, path_with_name_and_ending: str, save_combined_solution: bool = True, last_time_step=None): 

483 """ 

484 Loads a pickled solution. If the file is not found it searches for incremental solutions and loads them. 

485 

486 This method forces the load. That the hash matches the current network is the responsibility of the user. 

487 

488 The search for an incremental solution is done by using the hash as the start followed by a "_" and 

489 everything after the hash is used as add-on to the name, except "_result". So if the requested file is: 

490 fcd7d8e0f79c611c05db6e80457b8c3f0f2a696acb5e213cc0516bed468e9497_normal_static_result.pickle 

491 it searches for: 

492 fcd7d8e0f79c611c05db6e80457b8c3f0f2a696acb5e213cc0516bed468e9497_normal_static_0000100_*.pickle 

493 The number is the time step and after the time step everything can follow (".*"). 

494 

495 Parameters 

496 ---------- 

497 path_with_name_and_ending : str 

498 The path where the solution is stored with name and ending. 

499 save_combined_solution : bool, optional 

500 If True, the solution is saved in a pickle file if it was concatenated from incremental solutions. 

501 last_time_step : int | float, optional 

502 The last time step which defines the complete solution. 

503 If given, it is checked if the whole solution was loaded or just a part out of it. 

504 Used, to continue canceled simulations. 

505 

506 """ 

507 path_with_name_and_ending = os.path.normpath(path_with_name_and_ending) 

508 if os.path.exists(path_with_name_and_ending): 

509 with open(path_with_name_and_ending, "rb") as f: 

510 loaded_solution = pickle.load(f) 

511 raw_data = loaded_solution 

512 self._append_or_initialize(raw_data) 

513 return True 

514 else: 

515 # try to load incremental data 

516 folder, rc_hash, name_add_on = self._get_hash_and_folder_from_file_name(path_with_name_and_ending) 

517 if rc_hash is not None: 

518 success = self._load_all_solutions(folder, rc_hash, name_add_on) 

519 solution_is_complete = True 

520 if last_time_step is not None and success: 

521 last_loaded_step = self.time_steps[-1] 

522 if last_loaded_step < last_time_step: 

523 print(f"Solution loaded {last_loaded_step}/{last_time_step}") 

524 solution_is_complete = False 

525 success = last_loaded_step 

526 if success and save_combined_solution and solution_is_complete: 

527 self.save_solution(path_with_name_and_ending) 

528 return success 

529 return False 

530 

531 def _append_or_initialize(self, raw_data): 

532 """ 

533 Append new data from raw_data to existing attributes if they are not None, 

534 otherwise initialize them. 

535 

536 Parameters 

537 ---------- 

538 raw_data : tuple 

539 A tuple containing (result_vectors, input_vectors, time_steps, 

540 temperature_dataframe, dataframe). 

541 """ 

542 if raw_data[0] is not None: 

543 if self.__result_vectors is None: 

544 self.__result_vectors: np.ndarray = raw_data[0] 

545 else: 

546 self.__result_vectors = np.concatenate((self.__result_vectors, raw_data[0]), axis=0) 

547 

548 if raw_data[1] is not None: 

549 if self._input_vectors is None: 

550 self._input_vectors: list = raw_data[1] 

551 else: 

552 self._input_vectors.extend(raw_data[1]) 

553 

554 if raw_data[2] is not None: 

555 if self.time_steps is None: 

556 self.time_steps: np.ndarray = raw_data[2] 

557 else: 

558 self.time_steps = np.concatenate((self.time_steps, raw_data[2]), axis=0) 

559 

560 if raw_data[3] is not None: 

561 if self._temperature_dataframe is None: 

562 self._temperature_dataframe = raw_data[3] 

563 else: 

564 self._temperature_dataframe = pd.concat([self._temperature_dataframe, raw_data[3]]) 

565 

566 if raw_data[4] is not None: 

567 if self._dataframe is None: 

568 self._dataframe = raw_data[4] 

569 else: 

570 self._dataframe = pd.concat([self._dataframe, raw_data[4]]) 

571 

572 def add_to_solution(self, new_t: list, new_y: list[np.ndarray]) -> None: 

573 """ 

574 Add new solution from solver. Initialize or append/concatenate. 

575 

576 Parameters 

577 ---------- 

578 new_t : list 

579 All new time steps in a list. 

580 new_y : list[np.ndarray] 

581 All new result vectors (temperature vectors) in a list. 

582 """ 

583 if self.t is None: 

584 self.t = np.concatenate(new_t) 

585 else: 

586 if new_t is not None: 

587 self.t = np.concatenate([self.t, *new_t]) 

588 if self.y is None: 

589 self.y = np.concatenate(new_y, axis=1).T 

590 else: 

591 if new_y is not None: 

592 self.y = np.concatenate([self.y.T, *new_y], axis=1).T 

593 

594 def _load_all_solutions(self, save_dir: str, save_prefix: str, hash_add_on: str | Any = None): 

595 """ 

596 Load all incrementally saved pickled solution files matching the pattern 

597 '{save_prefix}.*{float(batch_end):09.0f}.*.(pickle|pkl)' in ascending order of batch_end. 

598 

599 The current solution is replaced if some exist! 

600 

601 Parameters 

602 ---------- 

603 save_dir : str 

604 Directory where the pickled files are stored. 

605 save_prefix : str 

606 Prefix of the saved files to identify relevant pickles. 

607 hash_add_on : str | Any, optional 

608 A string that is added to the hash with a "_" to serve as identifier. 

609 

610 Returns 

611 ------- 

612 list 

613 A list of loaded solutions sorted by batch_end. 

614 """ 

615 if hash_add_on is not None: 

616 save_prefix += "_" + hash_add_on 

617 # pattern = re.compile(rf"{re.escape(save_prefix)}.*?(\d+(?:\.\d+)?)(?!.*\d).*\.(?:pickle|pkl)$") 

618 pattern = re.compile( 

619 rf"{re.escape(save_prefix)}_?(\d+(?:\.\d+)?)(?=_(?:s)?\.pickle$|(?:s)?\.pickle$|_(?:s)?\.pkl$|(?:s)?\.pkl$)" 

620 ) 

621 solutions = [] 

622 

623 for file_name in os.listdir(save_dir): 

624 match = pattern.match(file_name) 

625 if match: 

626 batch_end = float(match.group(1)) 

627 solutions.append((batch_end, file_name)) 

628 

629 solutions.sort(key=lambda x: x[0]) 

630 

631 all_data = [[] for _ in range(5)] 

632 

633 for _, file_name in solutions: 

634 with open(os.path.join(save_dir, file_name), "rb") as f: 

635 raw_data = pickle.load(f) 

636 for i, data in enumerate(raw_data): 

637 if data is not None: 

638 all_data[i].append(data) 

639 

640 # Concatenate once for each data type 

641 if all_data[0]: 

642 self.__result_vectors = np.concatenate(all_data[0], axis=0) 

643 

644 if all_data[1]: 

645 self._input_vectors = [] 

646 for vectors in all_data[1]: 

647 self._input_vectors.extend(vectors) 

648 

649 if all_data[2]: 

650 self.time_steps = np.concatenate(all_data[2], axis=0) 

651 

652 if all_data[3]: 

653 self._temperature_dataframe = pd.concat(all_data[3], ignore_index=True) 

654 

655 if all_data[4]: 

656 self._dataframe = pd.concat(all_data[4], ignore_index=True) 

657 # 

658 # for _, file_name in solutions: 

659 # with open(os.path.join(save_dir, file_name), "rb") as f: 

660 # raw_data = pickle.load(f) 

661 # self._append_or_initialize(raw_data) 

662 

663 if len(solutions) > 0: 

664 return True 

665 return False 

666 

667 @staticmethod 

668 def _get_hash_and_folder_from_file_name(path: str): 

669 """ 

670 Extract the hash and folder from the full path of a saved pickle file. 

671 

672 The hash is defined as all characters in the filename before the first underscore. 

673 

674 The name add-on is everything followed by the hash (without the underscore) but without the .pickle extension 

675 and without "_result". If no characters match this, None is returned. 

676 

677 Parameters 

678 ---------- 

679 path : str 

680 Full path to the saved pickle file including the file name (with or without ending) 

681 

682 Returns 

683 ------- 

684 tuple[str, str] 

685 A tuple (save_dir, save_prefix). 

686 """ 

687 folder = os.path.dirname(path) 

688 filename = os.path.basename(path) 

689 # split at _ 

690 split = filename.split("_", 1) 

691 rc_hash = None 

692 name_add_on = None 

693 if len(split) > 1: 

694 rc_hash = split[0] 

695 name_add_on = split[1] 

696 else: 

697 # split at . 

698 split = filename.split(".", 1) 

699 if len(split) > 1: 

700 if len(split[0]) == 64: 

701 rc_hash = split[0] 

702 name_add_on = ".".join(split[1:]) if len(split) > 1 else None 

703 if name_add_on is not None: 

704 name_add_on = name_add_on.removesuffix(".pickle") 

705 name_add_on = name_add_on.removesuffix(".pkl") 

706 name_add_on = name_add_on.removesuffix("_result") 

707 return folder, rc_hash, name_add_on 

708 

709 @property 

710 def exist(self) -> bool: 

711 if self.result_vectors is not None: 

712 return True 

713 return False 

714 

715 @property 

716 def result_vectors(self) -> np.ndarray | Any: 

717 return self.__result_vectors 

718 

719 @result_vectors.setter 

720 def result_vectors(self, value): 

721 pass 

722 

723 @property 

724 def time_steps_count(self): 

725 return len(self.time_steps.flatten()) 

726 

727 @property 

728 def temperature_vectors_pandas(self) -> pd.DataFrame: 

729 """ 

730 Returns the solution with all node results in one column within a pd.DataFrame. 

731 

732 The DataFrame is cached. To reset it, set ``self.result_vectors = None`` . 

733 

734 Returns 

735 ------- 

736 pd.DataFrame : 

737 The solution. Each column represents the solution of one node. Each row the time step. 

738 The index of the DataFrame are the time steps. 

739 """ 

740 if self._temperature_dataframe is None: 

741 self._temperature_dataframe = pd.DataFrame(self.result_vectors) 

742 self._temperature_dataframe.index = self.time_steps 

743 return self._temperature_dataframe 

744 

745 @property 

746 def dataframe(self): 

747 if self._dataframe is None: 

748 merge = np.concatenate((self.result_vectors, self.input_vectors), axis=1) 

749 self._dataframe = pd.DataFrame( 

750 merge, columns=[*[n.id for n in self.nodes], *[i.id for i in self.inputs]], index=self.time_steps 

751 ) 

752 return self._dataframe 

753 

754 @property 

755 def temperature_vectors(self) -> np.ndarray: 

756 """ 

757 Like temperature_vectors_pandas, but returns a numpy array. 

758 

759 This value is not cached. 

760 

761 Returns 

762 ------- 

763 np.ndarray : 

764 The solution. Each column represents the solution of one node. Each row the time step. 

765 """ 

766 return self.result_vectors 

767 

768 @property 

769 def t(self): 

770 return self.time_steps 

771 

772 @t.setter 

773 def t(self, value): 

774 self.time_steps = value 

775 

776 @property 

777 def y(self): 

778 return self.result_vectors 

779 

780 @y.setter 

781 def y(self, value): 

782 assert isinstance(value, np.ndarray) 

783 self.__result_vectors = value 

784 

785 # def set_loaded_data(self, loaded_solutions: RCSolution): 

786 # """ 

787 # Replaces all attributes with the ones of the loaded `RCSolution`. 

788 # 

789 # This is used when the object can hardly be replaced by a loaded one, e.g. when used in composition. 

790 # 

791 # Parameters 

792 # ---------- 

793 # loaded_solutions : RCSolution 

794 # The loaded solution object that should "replace" self / should be used to overwrite self. 

795 # 

796 # """ 

797 # self.time_steps = loaded_solutions.time_steps 

798 # self.result_vectors = loaded_solutions.result_vectors 

799 # self._input_vectors: np.ndarray | Any = loaded_solutions._input_vectors 

800 

801 def save_last_step(self, file_path): 

802 """ 

803 Saves a vector with the solution of the last time step. 

804 

805 The saved data can be set as initial values using `RCNetwork.load_initial_values` 

806 

807 Parameters 

808 ---------- 

809 file_path : str | Any 

810 The file path with name and ending. 

811 """ 

812 last_solution = self.temperature_vectors[-1, :] 

813 last_input = self.input_vectors[-1, :] 

814 with open(file_path, "wb") as f: 

815 pickle.dump((last_solution, last_input), f) 

816 

817 

818solution_object = RCSolution() 

819 

820 

821class ObjectWithPorts: 

822 def __init__(self): 

823 self.__neighbours = [] 

824 

825 @property 

826 def neighbours(self): 

827 return self.__neighbours 

828 

829 @property 

830 def ports(self): 

831 """ 

832 Alias for `self.neighbours`. 

833 """ 

834 return self.__neighbours 

835 

836 def __iter__(self): 

837 """ 

838 Iterate over `self.neighbours`. 

839 

840 Returns 

841 ------- 

842 ObjectWithPorts : 

843 The neighbours. 

844 """ 

845 for neighbour in self.neighbours: 

846 yield neighbour 

847 

848 def connect(self, neighbour, direction: tuple | list | np.ndarray | Any = None, node_direction_points_to=None): 

849 """ 

850 Add the given object/neighbour to the `self.neighbours` list. 

851 

852 The neighbour itself will connect ``self`` to its neighbours list. 

853 E.g.: If node2 should be connected to node1, node2's neighbours list appends self. 

854 

855 The direction is a possibility to set the direction between two connected nodes manually. It is used for 

856 connected `BoundaryCondition` s and `Node` s. 

857 The direction is set for the neighbour. The 

858 

859 Parameters 

860 ---------- 

861 neighbour : ObjectWithPorts 

862 The neighbour to connect to. It will connect ``self`` to itself. 

863 This is the Node the manual direction is set on! 

864 direction : tuple | list | np.ndarray, optional 

865 If not None, a direction is set manually to node_direction_points_at. 

866 Either none or both node_direction_points_at and direction must be passed. 

867 node_direction_points_to : TemperatureNode, optional 

868 If not None, this is the node to which the direction points at (looking from neighbour). 

869 Either none or both node_direction_points_at and direction must be passed. 

870 Must be a TemperatureNode. 

871 """ 

872 if (direction is not None) ^ (node_direction_points_to is not None): 

873 raise ValueError("Either none or both node_direction_points_at and direction must be passed.") 

874 

875 self.__neighbours.append(neighbour) 

876 neighbour.__single_connect(self) 

877 

878 # set direction of neighbour using the node the direction points at 

879 if direction is not None: 

880 direction: tuple | np.ndarray | list 

881 from pyrc.core.nodes import Node 

882 from pyrc.core.components.node import TemperatureNode 

883 

884 assert isinstance(node_direction_points_to, TemperatureNode) 

885 direction: np.ndarray = np.array(direction) / np.linalg.norm(np.array(direction)) 

886 if not isinstance(neighbour, Node): 

887 assert isinstance(self, Node), "The direction can only set on Nodes, not TemperatureNodes/Resistors." 

888 # set direction at self to node_direction_points_to 

889 self.set_direction(node_direction_points_to, direction) 

890 else: 

891 node: TemperatureNode = node_direction_points_to 

892 neighbour.set_direction(node, direction) 

893 

894 def double_connect(self, neighbour1, neighbour2): 

895 self.connect(neighbour1) 

896 self.connect(neighbour2) 

897 

898 def __single_connect(self, neighbour): 

899 """ 

900 Like `self.connect`, but it doesn't set the connection to the neighbour, too. 

901 

902 Parameters 

903 ---------- 

904 neighbour : ObjectWithPorts 

905 The neighbour to connect to. 

906 """ 

907 self.__neighbours.append(neighbour) 

908 

909 

910class ConnectedFlowObject: 

911 def __init__(self): 

912 self._volume_flow = None 

913 # manual switch to determine if the volume flows are balanced: sum(inflows)-sum(outflows) = 0 

914 # This switch should be actuated from the algorithm that distributes the flows or a method that checks the 

915 # balance. 

916 self.volume_flow_is_balanced = False 

917 

918 @property 

919 def guess_volume_flow(self): 

920 return self._volume_flow 

921 

922 @property 

923 def volume_flow(self): 

924 return self._volume_flow 

925 

926 @abstractmethod 

927 def check_balance(self) -> bool: 

928 pass 

929 

930 @property 

931 def sources(self) -> list: 

932 return [] 

933 

934 @property 

935 def sinks(self) -> list: 

936 return [] 

937 

938 @property 

939 @abstractmethod 

940 def balance(self): 

941 pass 

942 

943 

944class Geometric: 

945 """ 

946 Skeleton for a geometric object that only contains the position in 3D space. 

947 

948 Defines getter and setter for X, Y and Z coordinates. If a 2D vector is given, 

949 the Z coordinate is set to 0. 

950 

951 Parameters 

952 ---------- 

953 position : np.ndarray 

954 Either 2D or 3D position of the object as array. 

955 fixed_position : bool 

956 If ``True``, the position cannot be changed. Overwrites both `fixed_z` and `fixed_xy` parameters. 

957 fixed_z : bool 

958 If ``True``, the z coordinate cannot be changed. 

959 fixed_xy : bool 

960 If ``True``, the x and y coordinates cannot be changed. 

961 """ 

962 

963 def __init__( 

964 self, 

965 position: np.ndarray | tuple | list, 

966 fixed_position: bool = False, 

967 fixed_z: bool = False, 

968 fixed_xy: bool = False, 

969 ): 

970 

971 self.fixed_z = False 

972 self.fixed_xy = False 

973 

974 self.position = np.array(position, dtype=np.float64) 

975 if fixed_position: 

976 self.fixed_z = self.fixed_xy = True 

977 else: 

978 self.fixed_xy = fixed_xy 

979 self.fixed_z = fixed_z 

980 

981 @property 

982 def position(self) -> np.ndarray: 

983 return self.__position.copy() 

984 

985 @position.setter 

986 def position(self, value: np.ndarray): 

987 if self.fixed_z or self.fixed_xy: 

988 raise FixedPositionError() 

989 if not isinstance(value, np.ndarray): 

990 value = np.array(value, dtype=np.float64) 

991 assert 2 <= len(value) <= 3 

992 if len(value) == 2: 

993 value = np.array([*value, 0], dtype=np.float64) 

994 if np.isnan(value).any(): 

995 raise ValueError 

996 self.__position = value.copy() 

997 

998 @property 

999 def x(self): 

1000 return self.__position[0] 

1001 

1002 @x.setter 

1003 def x(self, value): 

1004 if self.fixed_xy: 

1005 raise FixedXYError() 

1006 assert is_numeric(value) 

1007 self.__position = np.array([value, self.y, self.z]) 

1008 

1009 @property 

1010 def y(self): 

1011 return self.__position[1] 

1012 

1013 @y.setter 

1014 def y(self, value): 

1015 if self.fixed_xy: 

1016 raise FixedXYError() 

1017 assert is_numeric(value) 

1018 self.__position = np.array([self.x, value, self.z]) 

1019 

1020 @property 

1021 def z(self): 

1022 return self.__position[2] 

1023 

1024 @z.setter 

1025 def z(self, value): 

1026 if self.fixed_z: 

1027 raise FixedZError() 

1028 assert is_numeric(value) 

1029 self.__position = np.array([self.x, self.y, value]) 

1030 

1031 

1032class Cell(Geometric): 

1033 def __init__( 

1034 self, 

1035 position: np.ndarray | tuple | list, 

1036 delta: np.ndarray | tuple = None, 

1037 ): 

1038 """ 

1039 Extends the `Geometric` class to a cell with length, height and depth. 

1040 

1041 Parameters 

1042 ---------- 

1043 position : np.ndarray 

1044 The position of the node in 2D/3D space. 

1045 If 2D, a zero is added for the z coordinate. 

1046 delta : np.ndarray | tuple, optional 

1047 Delta vector [delta_x, delta_y, delta_z]. 

1048 """ 

1049 # visualize the Cell using vpython 

1050 self.__vbox: Optional[box] = ( 

1051 None # must be initialized before Geometric init is called because of position setter 

1052 ) 

1053 self.opacity = 1 

1054 

1055 super().__init__(position=position) 

1056 

1057 self.delta = delta 

1058 

1059 @Geometric.position.setter 

1060 def position(self, value): 

1061 Geometric.position.fset(self, value) 

1062 if self.__vbox is not None: 

1063 self.update_vbox_geometry() 

1064 

1065 @property 

1066 def vbox(self): 

1067 if self.__vbox is None: 

1068 self.vbox = box( 

1069 pos=vector(*self.position), 

1070 size=vector(*self.delta), 

1071 color=vector(0.6, 0.6, 0.6), 

1072 opacity=self.opacity, 

1073 shininess=0.0, 

1074 ) 

1075 return self.__vbox 

1076 

1077 @vbox.setter 

1078 def vbox(self, value): 

1079 assert isinstance(value, box) 

1080 self.__vbox = value 

1081 

1082 @property 

1083 def delta(self): 

1084 """ 

1085 Returns the delta vector. 

1086 

1087 Returns 

1088 ------- 

1089 np.ndarray : 

1090 The delta vector. 

1091 """ 

1092 return self.__delta 

1093 

1094 @delta.setter 

1095 def delta(self, value): 

1096 value = np.asarray(value).ravel() 

1097 if value.size == 1: 

1098 self.__delta = np.append(value, [1.0, 1.0]) 

1099 elif value.size == 2: 

1100 self.__delta = np.append(value, 1.0) 

1101 elif value.size == 3: 

1102 self.__delta = value 

1103 else: 

1104 raise ValueError(f"Expected 2 or 3 elements, got {value.size}.") 

1105 

1106 if self.__vbox is not None: 

1107 self.update_vbox_geometry() 

1108 

1109 @property 

1110 def delta_x(self): 

1111 return self.delta[0] 

1112 

1113 @property 

1114 def delta_y(self): 

1115 return self.delta[1] 

1116 

1117 @property 

1118 def delta_z(self): 

1119 return self.delta[2] 

1120 

1121 @property 

1122 def length(self): 

1123 return self.delta_x 

1124 

1125 @property 

1126 def height(self): 

1127 return self.delta_y 

1128 

1129 @property 

1130 def depth(self): 

1131 return self.delta_z 

1132 

1133 @property 

1134 def boundaries(self) -> list: 

1135 """ 

1136 Returns the boundaries of the cell. 

1137 

1138 The format looks like: 

1139 [-x, x, -y, y, -z, z] 

1140 

1141 Returns 

1142 ------- 

1143 list : 

1144 The boundaries. 

1145 """ 

1146 negative = (self.position - self.delta / 2).tolist() 

1147 positive = (self.position + self.delta / 2).tolist() 

1148 return [item for pair in zip(negative, positive) for item in pair] 

1149 

1150 def face_points(self, direction: str) -> np.ndarray: 

1151 """ 

1152 Return the four vertices of a cell face in a given direction. 

1153 

1154 Vertices are ordered counter-clockwise when viewed from outside the cell 

1155 (i.e., the cross product of two consecutive edge vectors points away from center). 

1156 

1157 Parameters 

1158 ---------- 

1159 direction : str 

1160 Face direction: 'x' or '+x' (positive x-face), '-x' (negative x-face), 

1161 and equivalently for 'y', 'z'. 

1162 

1163 Returns 

1164 ------- 

1165 np.ndarray 

1166 Shape (4, 3) array of vertex coordinates. 

1167 

1168 Raises 

1169 ------ 

1170 ValueError 

1171 If direction string is not recognized. 

1172 """ 

1173 direction = direction.strip().lower() 

1174 if direction in ("x", "+x"): 

1175 sign, axis = 1, 0 

1176 elif direction == "-x": 

1177 sign, axis = -1, 0 

1178 elif direction in ("y", "+y"): 

1179 sign, axis = 1, 1 

1180 elif direction == "-y": 

1181 sign, axis = -1, 1 

1182 elif direction in ("z", "+z"): 

1183 sign, axis = 1, 2 

1184 elif direction == "-z": 

1185 sign, axis = -1, 2 

1186 else: 

1187 raise ValueError( 

1188 f"Unrecognized direction '{direction}'. Use 'x', '+x', '-x', 'y', '+y', '-y', 'z', '+z', '-z'." 

1189 ) 

1190 

1191 # Two tangent axes (right-hand rule: normal = t1 × t2 points outward for sign=+1) 

1192 t1, t2 = [(axis + 1) % 3, (axis + 2) % 3] # e.g. axis=0 -> t1=1, t2=2 

1193 

1194 # If sign is -1, swap tangents to keep outward-pointing normal via right-hand rule 

1195 if sign == -1: 

1196 t1, t2 = t2, t1 

1197 

1198 half = self.delta / 2 

1199 center_face = self.position.copy() 

1200 center_face[axis] += sign * half[axis] 

1201 

1202 offsets = [ 

1203 (-half[t1], -half[t2]), 

1204 (half[t1], -half[t2]), 

1205 (half[t1], half[t2]), 

1206 (-half[t1], half[t2]), 

1207 ] 

1208 

1209 vertices = np.empty((4, 3)) 

1210 for i, (o1, o2) in enumerate(offsets): 

1211 v = center_face.copy() 

1212 v[t1] += o1 

1213 v[t2] += o2 

1214 vertices[i] = v 

1215 

1216 return vertices 

1217 

1218 def update_vbox_geometry(self) -> None: 

1219 """ 

1220 Update position/size (geometry) of the vbox (visualization). Call only if geometry changes. 

1221 """ 

1222 self.vbox.pos = vector(*self.position) 

1223 self.vbox.size = vector(*self.delta) 

1224 

1225 def update_color( 

1226 self, temperature: float, t_min: float = 263.15, t_max: float = 413.15, colormap="managua" 

1227 ) -> None: 

1228 """ 

1229 Update the color of the vbox for visualization. 

1230 

1231 Parameters 

1232 ---------- 

1233 temperature : float 

1234 The temperature in Kelvin to set. 

1235 t_min : float | int, optional 

1236 The minimal temperature for the color code. 

1237 t_max : float | int, optional 

1238 The maximal temperature for the color code. 

1239 colormap : str, optional 

1240 The colormap to use. See pyrc.core.visualization.color.color.py or the txt files in there respectively. 

1241 """ 

1242 assert t_max > t_min 

1243 t_norm = (temperature - t_min) / (t_max - t_min) 

1244 r, g, b = value_to_rgb(t_norm, colormap) 

1245 self.vbox.color = vector(r, g, b) 

1246 

1247 def _apply_alignment(self, alignment, reference_position, other_deltas): 

1248 """ 

1249 Apply alignment string to calculate new position. 

1250 

1251 Parameters 

1252 ---------- 

1253 alignment : str 

1254 Face alignment specification with optional pairing override. 

1255 Format: Space-separated or consecutive axis specifications. 

1256 Each specification: [self_dir][other_dir]axis 

1257 where self_dir and other_dir are '+' or '-'. 

1258 Default pairing: opposite faces ('+x' pairs with '-x' of other) 

1259 Examples: 'x' (default opposite), 'xy' (both default opposite), 

1260 '+-x' (explicit opposite), '++x' (same face), 

1261 '-y' (self -y with other +y), '+-x -+y' (multiple axes with space) 

1262 reference_position : np.ndarray 

1263 Starting position to update 

1264 other_deltas : np.ndarray 

1265 Delta values of object being placed 

1266 

1267 Returns 

1268 ------- 

1269 np.ndarray 

1270 Updated position after applying alignment 

1271 

1272 Raises 

1273 ------ 

1274 ValueError 

1275 If alignment string is malformed 

1276 """ 

1277 new_position = reference_position.copy() 

1278 

1279 # Remove spaces and parse character by character 

1280 alignment_no_space = alignment.replace(" ", "") 

1281 

1282 i = 0 

1283 while i < len(alignment_no_space): 

1284 self_direction = 1 

1285 other_direction = -1 

1286 signs = [] 

1287 

1288 # Parse signs (0, 1, or 2) 

1289 while i < len(alignment_no_space) and alignment_no_space[i] in ["-", "+"]: 

1290 signs.append(-1 if alignment_no_space[i] == "-" else 1) 

1291 i += 1 

1292 if len(signs) > 2: 

1293 raise ValueError(f"More than 2 signs found before axis at position {i}") 

1294 

1295 # Parse axis 

1296 if i < len(alignment_no_space) and alignment_no_space[i] in ["x", "y", "z"]: 

1297 axis = alignment_no_space[i] 

1298 i += 1 

1299 else: 

1300 if signs: 

1301 raise ValueError(f"Found direction signs without following axis at position {i}") 

1302 break 

1303 

1304 # Apply signs 

1305 if len(signs) == 1: 

1306 self_direction = signs[0] 

1307 other_direction = -signs[0] # Opposite of self 

1308 elif len(signs) == 2: 

1309 self_direction = signs[0] 

1310 other_direction = signs[1] 

1311 # else: len(signs) == 0, use defaults (1, -1) 

1312 

1313 axis_index = {"x": 0, "y": 1, "z": 2}[axis] 

1314 new_position[axis_index] = ( 

1315 self.position[axis_index] 

1316 + self_direction * self.delta[axis_index] / 2 

1317 - other_direction * other_deltas[axis_index] / 2 

1318 ) 

1319 

1320 return new_position 

1321 

1322 def place_adjacent(self, other_cell, alignment): 

1323 """ 

1324 Place other_cell adjacent to self aligned at specified face(s). 

1325 

1326 Parameters 

1327 ---------- 

1328 other_cell : Cell 

1329 Cell to be placed adjacent to self 

1330 alignment : str 

1331 Face alignment specification with optional pairing override. 

1332 Format: Space-separated or consecutive axis specifications. 

1333 Each specification: [self_dir][other_dir]axis 

1334 where self_dir and other_dir are '+' or '-'. 

1335 Default pairing: opposite faces ('+x' pairs with '-x' of other) 

1336 Examples: 

1337 'x' (default opposite), 'xy' (both default opposite), 

1338 '+-x' (explicit opposite), '++x' (same face), 

1339 '-y' (self -y with other +y), '+-x -+y' (multiple axes with space) 

1340 """ 

1341 other_cell.position = self._apply_alignment(alignment, other_cell.position, other_cell.delta) 

1342 

1343 return other_cell 

1344 

1345 def create_adjacent(self, alignment, **kwargs): 

1346 """ 

1347 Create and place new cell of same type adjacent to self. 

1348 

1349 Parameters 

1350 ---------- 

1351 alignment : str 

1352 Face alignment specification with optional pairing override. 

1353 Format: Space-separated or consecutive axis specifications. 

1354 Each specification: [self_dir][other_dir]axis 

1355 where self_dir and other_dir are '+' or '-'. 

1356 Default pairing: opposite faces ('+x' pairs with '-x' of other) 

1357 Examples:\n 

1358 ``x`` (default opposite), ``xy`` (both default opposite),\n 

1359 ``+-x`` (explicit opposite), ``++x`` (same face),\n 

1360 ``-y`` (self -y with other +y), ``+-x -+y`` (multiple axes with space) 

1361 **kwargs 

1362 Arguments passed to constructor (``delta`` etc.) 

1363 

1364 Returns 

1365 ------- 

1366 Cell or subclass : 

1367 New `Cell` of same type as ``self`` placed adjacent to ``self`` 

1368 """ 

1369 if "position" not in kwargs: 

1370 kwargs["position"] = self.position.copy() 

1371 new_cell = type(self)(**kwargs) 

1372 return self.place_adjacent(new_cell, alignment) 

1373 

1374 @classmethod 

1375 def create_grid( 

1376 cls, grid_size, delta: np.ndarray | tuple = None, center_position=None, **kwargs 

1377 ) -> np.ndarray[Cell]: 

1378 """ 

1379 Create a 3D grid of cells. 

1380 

1381 Parameters 

1382 ---------- 

1383 grid_size : tuple[int] | np.ndarray | list 

1384 Number of cells (nx, ny, nz). 

1385 delta : tuple[int] | np.ndarray | list 

1386 Total dimensions in one vector. 

1387 If single delta-values are given, too, the delta vector is used. 

1388 delta : float 

1389 Total length in x,y,z direction. 

1390 center_position : np.ndarray, optional 

1391 Center position of the grid. Defaults to origin. 

1392 **kwargs 

1393 Additional arguments passed to constructor. 

1394 

1395 Returns 

1396 ------- 

1397 np.ndarray 

1398 3D array of shape (nx, ny, nz) containing Cell instances. 

1399 """ 

1400 

1401 nx, ny, nz = grid_size 

1402 cell_deltas = np.array(delta) / np.array([nx, ny, nz]) 

1403 center = np.zeros(3) if center_position is None else center_position.copy() 

1404 

1405 cells = np.empty(shape=(nx, ny, nz), dtype=object) 

1406 for ix in range(nx): 

1407 for iy in range(ny): 

1408 for iz in range(nz): 

1409 offset = (np.array([ix, iy, iz]) - (np.array([nx, ny, nz]) - 1) / 2) * cell_deltas 

1410 cells[ix, iy, iz] = cls(position=center + offset, delta=cell_deltas, **kwargs) 

1411 return cells 

1412 

1413 def create_grid_aligned(self, alignment, grid_size, total_delta, position=None, **kwargs) -> np.ndarray[Cell]: 

1414 """ 

1415 Create a 3D grid of cells aligned to self. 

1416 

1417 Parameters 

1418 ---------- 

1419 alignment : str 

1420 Face alignment specification. See _apply_alignment for format. 

1421 grid_size : tuple[int] | np.ndarray | list 

1422 Number of cells (nx, ny, nz). 

1423 total_delta : tuple[int] | np.ndarray | list 

1424 Total length in x,y,z direction. 

1425 position : np.ndarray, optional 

1426 Base position, updated by alignment. Defaults to origin. 

1427 **kwargs 

1428 Additional arguments passed to constructor. 

1429 

1430 Returns 

1431 ------- 

1432 np.ndarray 

1433 3D array of shape (nx, ny, nz) containing Cell instances. 

1434 """ 

1435 base = np.zeros(3) if position is None else position.copy() 

1436 total_deltas = np.array(total_delta) 

1437 center = self._apply_alignment(alignment, base, total_deltas) 

1438 return type(self).create_grid(grid_size, total_delta, center_position=center, **kwargs) 

1439 

1440 

1441class Material: 

1442 def __init__( 

1443 self, 

1444 name, 

1445 density: float | int | np.number = np.nan, 

1446 heat_capacity: float | int | np.number = np.nan, 

1447 thermal_conductivity: float | int | np.number = np.nan, 

1448 ): 

1449 """ 

1450 Container to hold all material properties. 

1451 

1452 Parameters 

1453 ---------- 

1454 name : str 

1455 The name/identifier of the material. 

1456 density : float | int | np.number 

1457 The density of the material in kg/m^3. 

1458 heat_capacity : float | int | np.number 

1459 The heat capacity of the material in J/kg/K. 

1460 thermal_conductivity : float | int | np.number 

1461 The thermal conductivity of the material in W/m/K. 

1462 """ 

1463 self.__name = name 

1464 self.__density = density 

1465 self.__heat_capacity = heat_capacity 

1466 self.__thermal_conductivity = thermal_conductivity 

1467 

1468 def __new__(cls, *args, **kwargs): 

1469 """ 

1470 This blocks the creation of instances from this class because it should only be used the children of self. 

1471 

1472 Parameters 

1473 ---------- 

1474 args 

1475 kwargs 

1476 """ 

1477 if cls is Material: 

1478 children = [sub_cls.__name__ for sub_cls in Material.__subclasses__()] 

1479 raise TypeError(f"Cannot instantiate {cls.__name__} directly. Use the children: {', '.join(children)}") 

1480 return super().__new__(cls) 

1481 

1482 @property 

1483 def name(self): 

1484 return self.__name 

1485 

1486 @property 

1487 def density(self): 

1488 return self.__density 

1489 

1490 @property 

1491 def heat_capacity(self): 

1492 return self.__heat_capacity 

1493 

1494 @property 

1495 def thermal_conductivity(self): 

1496 return self.__thermal_conductivity 

1497 

1498 

1499class Fluid(Material): 

1500 def __init__( 

1501 self, 

1502 *args, 

1503 kin_viscosity: float | int | np.number = np.nan, 

1504 prandtl_number: float | int | np.number = np.nan, 

1505 grashof_number: float | int | np.number = np.nan, 

1506 **kwargs, 

1507 ): 

1508 """ 

1509 

1510 Parameters 

1511 ---------- 

1512 args 

1513 kin_viscosity : float | int | np.number 

1514 The kinematic viscosity of the material in m^2/s. 

1515 prandtl_number : float | int | np.number 

1516 The Prandtl number of the material without unit. 

1517 grashof_number : float | int | np.number 

1518 The Grashof number of the material without unit. 

1519 kwargs 

1520 """ 

1521 super().__init__(*args, **kwargs) 

1522 self.__kin_viscosity = kin_viscosity 

1523 self.__prandtl_number = prandtl_number 

1524 self.__grashof_number = grashof_number 

1525 

1526 @property 

1527 def kin_viscosity(self): 

1528 return self.__kin_viscosity 

1529 

1530 @property 

1531 def prandtl_number(self): 

1532 if self.__prandtl_number is None or not is_set(self.__prandtl_number): 

1533 self.__prandtl_number = self.kin_viscosity * self.density * self.heat_capacity / self.thermal_conductivity 

1534 return self.__prandtl_number 

1535 

1536 @property 

1537 def Pr(self): 

1538 return self.prandtl_number 

1539 

1540 @property 

1541 def grashof_number(self): 

1542 return self.__grashof_number 

1543 

1544 @property 

1545 def Gr(self): 

1546 return self.__grashof_number 

1547 

1548 @property 

1549 def rayleigh_number(self): 

1550 return self.grashof_number * self.prandtl_number 

1551 

1552 

1553class Solid(Material): 

1554 def __init__(self, *args, **kwargs): 

1555 super().__init__(*args, **kwargs) 

1556 

1557 

1558class CompositeMaterialSolid(Solid): 

1559 """ 

1560 Combine multiple materials using ratios. 

1561 """ 

1562 

1563 def __init__(self, name, materials, ratios, **kwargs): 

1564 total_ratio = sum(ratios) 

1565 weights = [ratio / total_ratio for ratio in ratios] 

1566 

1567 density = sum(mat.density * weight for mat, weight in zip(materials, weights)) 

1568 heat_capacity = sum(mat.heat_capacity * weight for mat, weight in zip(materials, weights)) 

1569 thermal_conductivity = sum(mat.thermal_conductivity * weight for mat, weight in zip(materials, weights)) 

1570 

1571 solid_kwargs = { 

1572 "name": name, 

1573 "density": density, 

1574 "heat_capacity": heat_capacity, 

1575 "thermal_conductivity": thermal_conductivity, 

1576 } 

1577 solid_kwargs.update(kwargs) 

1578 super().__init__(**solid_kwargs) 

1579 

1580 

1581class CompositeMaterialFluid(Fluid): 

1582 """ 

1583 Combine multiple fluid materials using ratios. 

1584 """ 

1585 

1586 def __init__(self, name, materials, ratios, **kwargs): 

1587 total_ratio = sum(ratios) 

1588 weights = [ratio / total_ratio for ratio in ratios] 

1589 

1590 density = sum(mat.density * weight for mat, weight in zip(materials, weights)) 

1591 heat_capacity = sum(mat.heat_capacity * weight for mat, weight in zip(materials, weights)) 

1592 thermal_conductivity = sum(mat.thermal_conductivity * weight for mat, weight in zip(materials, weights)) 

1593 kin_viscosity = sum(mat.kin_viscosity * weight for mat, weight in zip(materials, weights)) 

1594 

1595 fluid_kwargs = { 

1596 "name": name, 

1597 "density": density, 

1598 "heat_capacity": heat_capacity, 

1599 "thermal_conductivity": thermal_conductivity, 

1600 "kin_viscosity": kin_viscosity, 

1601 } 

1602 fluid_kwargs.update(kwargs) 

1603 super().__init__(**fluid_kwargs) 

1604 

1605 

1606def calculate_balance_for_resistors(node, resistors: list[MassTransport]): 

1607 balance = 0 

1608 for resistor in resistors: 

1609 if resistor.sink == node: 

1610 # resistor is source for node 

1611 balance += resistor.volume_flow 

1612 else: 

1613 # resistor is sink for node 

1614 balance -= resistor.volume_flow 

1615 return balance 

1616 

1617 

1618class EquationItemInput(Input, EquationItem, ABC): 

1619 pass