Coverage for pyrc\core\network.py: 67%

500 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 operator 

11import os 

12import pickle 

13import threading 

14from abc import abstractmethod 

15from copy import copy 

16from typing import Any, TYPE_CHECKING, Callable, Iterable 

17 

18import numpy as np 

19from sympy import ( 

20 lambdify, 

21 latex, 

22 SparseMatrix, 

23 Matrix, 

24 diag, 

25 Symbol, 

26 Basic, 

27 ImmutableSparseMatrix, 

28 MutableSparseMatrix, 

29 Expr, 

30) 

31import sympy as sym 

32import hashlib 

33import networkx as nx 

34from scipy.sparse import coo_matrix 

35 

36from pyrc.core.solver.stationary import solve_stationary 

37from pyrc.paths import run_folder 

38from pyrc.core.components.resistor import Resistor 

39from pyrc.core.inputs import InternalHeatSource 

40from pyrc.core.settings import initial_settings, Settings 

41from pyrc.core.solver.symbolic import SparseSymbolicEvaluator, ArraySymbolicEvaluator 

42from pyrc.core.solver.handler import InhomogeneousSystemHandler, SolveIVPHandler, HomogeneousSystemHandler 

43from pyrc.tools.errors import HighCourantNumberError 

44from pyrc.tools.functions import add_leading_underscore 

45 

46from pyrc.tools.science import build_jacobian 

47from pyrc.core.components.templates import RCObjects, RCSolution, EquationItem, initial_rc_objects, Cell 

48from pyrc.core.components.templates import solution_object 

49from pyrc.core.components.capacitor import Capacitor 

50 

51if TYPE_CHECKING: 

52 from pyrc.core.nodes import MassFlowNode 

53 from pyrc.core.components.templates import EquationItemInput 

54 from scipy.sparse import spmatrix, sparray 

55 

56 

57class RCNetwork: 

58 def __init__( 

59 self, 

60 save_to_pickle: bool = True, 

61 load_from_pickle: bool = True, 

62 load_solution: bool = True, 

63 num_cores_jacobian: int = 1, 

64 # TODO: Parallelization only works on linux currently (because it forks instead 

65 # of spawning it). Problem is: Sympy symbols are not picklable. 

66 settings: Settings = initial_settings, 

67 rc_objects: RCObjects = initial_rc_objects, 

68 rc_solution: RCSolution = solution_object, 

69 # strategy: str = "lambdify", 

70 continue_canceled: bool = True, 

71 skip_infinity_check: bool = False, 

72 ): 

73 """ 

74 

75 Parameters 

76 ---------- 

77 save_to_pickle 

78 load_from_pickle 

79 load_solution 

80 num_cores_jacobian 

81 settings 

82 rc_objects 

83 rc_solution 

84 continue_canceled : bool, default=True 

85 If True, continue canceled simulations. 

86 Overwrites load_solution 

87 skip_infinity_check : bool, default=False 

88 If True the infinity check of the created matrices is skipped. 

89 This is recommended for complicated dynamic (time-dependent) systems. 

90 """ 

91 self.rc_objects: RCObjects = rc_objects 

92 self.settings: Settings = settings 

93 self._system_matrix_symbol: MutableSparseMatrix = None 

94 self._input_matrix_symbol: MutableSparseMatrix = None 

95 self._temperature_vector_symbol = None 

96 self._input_vector_symbol = None 

97 self._input_matrix = None 

98 self._system_matrix = None 

99 self.__hash = None 

100 self.rc_solution: RCSolution = rc_solution 

101 # link rc objects to rc solution 

102 self.rc_solution.rc_objects = rc_objects 

103 self.skip_infinity_check = skip_infinity_check 

104 

105 # For each time dependent symbol the following values are not allowed because they would make an entry in the 

106 # system matrix be infinity 

107 self.forbidden_values: dict[Symbol, set] = {} 

108 

109 self.save_to_pickle: bool = save_to_pickle 

110 self.load_from_pickle: bool = load_from_pickle 

111 self.load_solution: bool = load_solution 

112 self.continue_canceled: bool = continue_canceled 

113 

114 if num_cores_jacobian is None: 

115 num_cores_jacobian = os.cpu_count() 

116 self.num_cores_jacobian = min(max(num_cores_jacobian, 1), os.cpu_count()) 

117 

118 self.groups: dict = {} 

119 

120 def __getattr__(self, item): 

121 if getattr(self.rc_objects, item, None): 

122 return self.rc_objects.__getattribute__(item) 

123 return AttributeError 

124 

125 def create_network(self, *args, **kwargs): 

126 EquationItem.reset_counter() 

127 Resistor.reset_counter() 

128 self.rc_objects.wipe_all() 

129 self._create_network(*args, **kwargs) 

130 assert self.nodes is not None 

131 

132 @abstractmethod 

133 def _create_network(self, *args, **kwargs): 

134 pass 

135 

136 def copy_dict(self): 

137 return { 

138 "save_to_pickle": self.save_to_pickle, 

139 "load_from_pickle": self.load_from_pickle, 

140 "load_solution": self.load_solution, 

141 "num_cores_jacobian": self.num_cores_jacobian, 

142 "settings": copy(self.settings), 

143 } 

144 

145 def __copy__(self): 

146 return RCNetwork(**self.copy_dict()) 

147 

148 @property 

149 def nodes(self): 

150 return self.rc_objects.nodes 

151 

152 @property 

153 def hash(self): 

154 """ 

155 Compute a deterministic hash for the :class:`RCNetwork` where node identity (class name + obj.id) and topology matter. 

156 

157 For :class:`Capacitor` instances, an optional internal_heat_source (identified by its id) is encoded as a node attribute. 

158 

159 For :class:`Cell` subclass instances, position and delta arrays are encoded. 

160 

161 Returns 

162 ------- 

163 str : 

164 SHA-256 hex digest of the canonical edge and node representation. 

165 """ 

166 if self.__hash is None: 

167 

168 def node_key(_obj) -> str: 

169 return f"{type(_obj).__name__}:{_obj.id}" 

170 

171 def array_str(arr: np.ndarray) -> str: 

172 return ",".join(f"{v:.14g}" for v in arr.ravel()) 

173 

174 graph = nx.Graph() 

175 for obj in self.rc_objects.all: 

176 attrs = {} 

177 if isinstance(obj, Capacitor): 

178 attrs["heat_source_id"] = ( 

179 str(obj.internal_heat_source.id) if obj.internal_heat_source is not None else None 

180 ) 

181 attrs["heat_source_symbol_name"] = ( 

182 obj.internal_heat_source.power.name 

183 if obj.internal_heat_source is not None and isinstance(obj.internal_heat_source.power, Basic) 

184 else None 

185 ) 

186 attrs["capacitor_symbol_name"] = obj.capacity_symbol.name 

187 attrs["capacity_variable"] = "yes" if isinstance(obj.capacity, Basic) else "no" 

188 if isinstance(obj, Cell): 

189 attrs["position"] = array_str(obj.position) 

190 attrs["delta"] = array_str(obj.delta) 

191 if isinstance(obj, Resistor): 

192 attrs["resistance_symbol"] = obj.resistance_symbol.name 

193 attrs["resistance_variable"] = "yes" if isinstance(obj.resistance, Basic) else "no" 

194 graph.add_node(node_key(obj), **attrs) 

195 

196 # loop over all linked/connected network objects 

197 for obj in self.rc_objects.all: 

198 for neighbour in obj.neighbours: 

199 graph.add_edge(node_key(obj), node_key(neighbour)) 

200 

201 node_str = "|".join( 

202 f"{nid}#{data}" 

203 for nid, data in sorted((nid, str(sorted(data.items()))) for nid, data in graph.nodes(data=True)) 

204 ) 

205 edge_str = "|".join(sorted(f"{min(u, v)}-{max(u, v)}" for u, v in graph.edges())) 

206 canonical = f"nodes:{node_str};edges:{edge_str}" 

207 self.__hash = hashlib.sha256(canonical.encode()).hexdigest() 

208 return self.__hash 

209 

210 @property 

211 def time_steps(self) -> list: 

212 """ 

213 Returns a list with all time steps. 

214 

215 Returns 

216 ------- 

217 list : 

218 The time steps. 

219 """ 

220 return self.rc_solution.time_steps.tolist() 

221 

222 def reset_properties(self): 

223 self._temperature_vector_symbol = None 

224 

225 @property 

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

227 return self.rc_objects.inputs 

228 

229 def change_static_dynamic(self, calculate_static: bool | str): 

230 if isinstance(calculate_static, str): 

231 match calculate_static.lower(): 

232 case "static" | "statisch" | "s": 

233 calculate_static = True 

234 case _: 

235 calculate_static = False 

236 self.settings.calculate_static = calculate_static 

237 

238 def get_node_by_id(self, _id: str | int): 

239 """ 

240 Returns the node with the given id. 

241 

242 Parameters 

243 ---------- 

244 _id : str | int 

245 The id of the node. 

246 

247 Returns 

248 ------- 

249 TemperatureNode | InternalHeatSource : 

250 """ 

251 if isinstance(_id, str): 

252 _id = int(_id) 

253 return next((x for x in self.rc_objects.all_equation_objects if x.id == _id), None) 

254 

255 def no_infinity(self, sp_matrix: SparseMatrix | spmatrix | sparray) -> bool: 

256 """ 

257 Checks the given sparse matrix for infinity entries and symbolic singularities. 

258 

259 Notes 

260 ----- 

261 Saves a dict, which maps each free sympy symbol to a set of values at which at least one symbolic entry 

262 diverges. Empty if no symbolic entries exist. 

263 

264 Parameters 

265 ---------- 

266 sp_matrix : SparseMatrix | spmatrix | sparray 

267 A sparse matrix/vector to check, may contain numeric values or sympy 

268 expressions. 

269 

270 Returns 

271 ------- 

272 tuple[bool, dict] : 

273 bool : True if no numeric infinity was found. 

274 """ 

275 sp_matrix: SparseMatrix | spmatrix | sparray 

276 if isinstance(sp_matrix, SparseMatrix) or isinstance(sp_matrix, ImmutableSparseMatrix): 

277 entries = sp_matrix.todok().values() 

278 else: 

279 sp_matrix: spmatrix | sparray 

280 entries = sp_matrix.data 

281 

282 def _substitute_thetas(expression: Expr) -> Expr: 

283 theta_symbols = sorted( 

284 {s for s in expression.free_symbols if s.name.startswith("theta_")}, key=lambda s: s.name 

285 ) 

286 num_thetas = len(theta_symbols) 

287 substitution = {symbol: 270 + (index / num_thetas) * 60 for index, symbol in enumerate(theta_symbols)} 

288 return expression.subs(substitution) 

289 

290 def _solve_with_timeout(expression: Expr, symbol: sym.Symbol, timeout: float = 0.5) -> list: 

291 result = [] 

292 timed_out = threading.Event() 

293 

294 def target(): 

295 result.extend(sym.solve(expression, symbol)) 

296 

297 thread = threading.Thread(target=target) 

298 thread.start() 

299 thread.join(timeout) 

300 if thread.is_alive(): 

301 timed_out.set() 

302 

303 if timed_out.is_set(): 

304 raise TimeoutError(f"sym.solve timed out after {timeout}s for symbol {symbol}") 

305 

306 return result 

307 

308 for entry in entries: 

309 if isinstance(entry, Expr): 

310 if not entry.free_symbols: 

311 if entry in (sym.oo, -sym.oo, sym.zoo, sym.nan): 

312 return False 

313 else: 

314 _, denominator = sym.fraction(sym.cancel(entry)) 

315 if denominator == sym.Integer(0): 

316 return False 

317 if denominator != sym.Integer(1): 

318 substituted_denominator = _substitute_thetas(denominator) 

319 float_atoms = {a for a in substituted_denominator.atoms(sym.Float)} 

320 dummy_map = {f: sym.Symbol(f"staticVALUE_{i}") for i, f in enumerate(float_atoms)} 

321 inverse_dummy_map = {v: k for k, v in dummy_map.items()} 

322 cleaned_denominator = substituted_denominator.subs(dummy_map) 

323 try: 

324 for s in cleaned_denominator.free_symbols - set(dummy_map.values()): 

325 solution = _solve_with_timeout(cleaned_denominator, s, timeout=1) 

326 solution = [sol.subs(inverse_dummy_map) for sol in solution] 

327 self.forbidden_values.setdefault(s, set()).update(solution) 

328 except (NotImplementedError, TimeoutError): 

329 print(f"No infinity check for eq: {cleaned_denominator}") 

330 else: 

331 if np.isinf(entry): 

332 return False 

333 

334 return True 

335 

336 def _get_matrix_symbol(self, key: str) -> SparseMatrix | ImmutableSparseMatrix: 

337 """ 

338 Returns the desired matrix in symbolic notation. 

339 

340 Parameters 

341 ---------- 

342 key : str 

343 Either "symbol" or "input" to determine the matrix type. 

344 

345 Returns 

346 ------- 

347 SparseMatrix | ImmutableSparseMatrix : 

348 The (sympy) symbolic notation of the matrix. 

349 """ 

350 attr = f"_{key}_matrix_symbol" 

351 if getattr(self, attr) is None: 

352 self.make_system_matrices() 

353 return getattr(self, attr) 

354 

355 def _build_matrix_function(self, key: str) -> Callable: 

356 """ 

357 Builds the desired matrix lambda function using the symbols matrix, respectively. 

358 

359 Parameters 

360 ---------- 

361 key : str 

362 Either "symbol" or "input" to determine the matrix type. 

363 

364 Returns 

365 ------- 

366 Callable : 

367 The function of the matrix. 

368 """ 

369 matrix_symbol = self._get_matrix_symbol(key) 

370 all_symbols = self.system_symbols 

371 if self.time_dependent_system_symbols: 

372 free_symbols_in_matrix = matrix_symbol.free_symbols 

373 ordered_symbols = [ 

374 next((s for s in free_symbols_in_matrix if s.name == symbol.name), symbol) for symbol in all_symbols 

375 ] 

376 return lambdify(ordered_symbols, matrix_symbol, "sympy") 

377 return lambdify(all_symbols, matrix_symbol, "scipy") 

378 

379 def _build_matrix_direct(self, key: str) -> spmatrix: 

380 """ 

381 Bypasses lambdify by substituting all symbol values via name-based xreplace. 

382 

383 Parameters 

384 ---------- 

385 key : str 

386 Either "symbol" or "input" to determine the matrix type. 

387 

388 Notes 

389 ----- 

390 The replace must be done using the name of the symbols and not the objects itself because they might be loaded 

391 from disk and might not match, but the names do. 

392 

393 Returns 

394 ------- 

395 scipy.spmatrix : 

396 A scipy sparse matrix with all values substituted. 

397 """ 

398 matrix_symbol = self._get_matrix_symbol(key) 

399 name_to_value = {s.name: v for s, v in zip(self.system_symbols, self.system_values)} 

400 free = matrix_symbol.free_symbols 

401 substitution_map = {s: name_to_value[s.name] for s in free if s.name in name_to_value} 

402 

403 dok = matrix_symbol.todok() 

404 rows, cols, data = [], [], [] 

405 for (i, j), expr in dok.items(): 

406 rows.append(i) 

407 cols.append(j) 

408 data.append(float(expr.xreplace(substitution_map))) 

409 

410 return coo_matrix((data, (rows, cols)), shape=matrix_symbol.shape).tocsr() 

411 

412 # import warnings 

413 # import logging 

414 # import pickle 

415 # from concurrent.futures import ProcessPoolExecutor 

416 # from pathlib import Path 

417 # from typing import Callable 

418 # 

419 # import numba 

420 # import numpy as np 

421 # from scipy.sparse import coo_matrix, csr_matrix 

422 # from sympy import lambdify 

423 # from sympy.printing import ccode 

424 # 

425 # def _xreplace_element(args: tuple) -> object: 

426 # """Apply xreplace to a single expression.""" 

427 # expr, static_map = args 

428 # return expr.xreplace(static_map) 

429 # 

430 # def _build_c_source(reduced_exprs: list, td_ordered: list, func_name: str) -> str: 

431 # """ 

432 # Generates a C source file with a single function iterating over all nonzero 

433 # expressions. 

434 # 

435 # Parameters 

436 # ---------- 

437 # reduced_exprs : list 

438 # Sympy expressions with static symbols already substituted. 

439 # td_ordered : list 

440 # Ordered time-dependent sympy symbols. 

441 # func_name : str 

442 # Name of the generated C function. 

443 # 

444 # Returns 

445 # ------- 

446 # str : 

447 # C source code as a string. 

448 # """ 

449 # args = ", ".join(f"double {s.name}" for s in td_ordered) 

450 # lines = [ 

451 # "#include <stddef.h>", 

452 # f"void {func_name}({args}, double *out, size_t n) {{", 

453 # ] 

454 # for i, expr in enumerate(reduced_exprs): 

455 # lines.append(f" out[{i}] = {ccode(expr)};") 

456 # lines.append("}") 

457 # return "\n".join(lines) 

458 # 

459 # def _compile_c_function( 

460 # source: str, 

461 # func_name: str, 

462 # n_out: int, 

463 # cache_path: Path, 

464 # ) -> Callable: 

465 # """ 

466 # Compiles C source to a shared library and returns a callable via ctypes. 

467 # 

468 # Parameters 

469 # ---------- 

470 # source : str 

471 # C source code string. 

472 # func_name : str 

473 # Name of the C function to load. 

474 # n_out : int 

475 # Number of output values (nnz). 

476 # cache_path : Path 

477 # Path to write the compiled .so / .dll. 

478 # 

479 # Returns 

480 # ------- 

481 # Callable : 

482 # Python callable wrapping the compiled C function. 

483 # """ 

484 # import ctypes 

485 # import subprocess 

486 # import tempfile 

487 # 

488 # src_path = cache_path.with_suffix(".c") 

489 # src_path.write_text(source) 

490 # 

491 # result = subprocess.run( 

492 # ["gcc", "-O3", "-shared", "-fPIC", "-o", str(cache_path), str(src_path)], 

493 # capture_output=True, 

494 # text=True, 

495 # ) 

496 # if result.returncode != 0: 

497 # raise RuntimeError(result.stderr) 

498 # 

499 # lib = ctypes.CDLL(str(cache_path)) 

500 # fn = getattr(lib, func_name) 

501 # fn.restype = None 

502 # 

503 # out_type = ctypes.c_double * n_out 

504 # 

505 # def c_callable(*td_vals: float) -> np.ndarray: 

506 # out = out_type() 

507 # fn(*[ctypes.c_double(v) for v in td_vals], out, ctypes.c_size_t(n_out)) 

508 # return np.frombuffer(out, dtype=np.float64).copy() 

509 # 

510 # return c_callable 

511 

512 # def _prepare_matrix_ingredients( 

513 # self, key: str, parallel: bool 

514 # ) -> tuple[list, list, np.ndarray, np.ndarray, tuple, list]: 

515 # """ 

516 # Extracts and reduces nonzero expressions, substituting all static symbols. 

517 # 

518 # Parameters 

519 # ---------- 

520 # key : str 

521 # Either "symbol" or "input" to determine the matrix type. 

522 # parallel : bool 

523 # If True, parallelizes static substitution via ProcessPoolExecutor. 

524 # 

525 # Returns 

526 # ------- 

527 # tuple : 

528 # td_ordered, reduced_exprs, rows, cols, shape, raw exprs (unreduced) 

529 # """ 

530 # matrix_symbol = self._get_matrix_symbol(key) 

531 # all_symbols = self.get_symbols() 

532 # all_values = self.get_values() 

533 # td_symbols = self.get_time_dependent_symbols() 

534 # td_names = {s.name for s in td_symbols} 

535 # 

536 # static_map = { 

537 # s_mat: val 

538 # for sym, val in zip(all_symbols, all_values) 

539 # if sym.name not in td_names 

540 # for s_mat in matrix_symbol.free_symbols 

541 # if s_mat.name == sym.name 

542 # } 

543 # 

544 # td_ordered = [ 

545 # next(s for s in matrix_symbol.free_symbols if s.name == td.name) 

546 # for td in td_symbols 

547 # if any(s.name == td.name for s in matrix_symbol.free_symbols) 

548 # ] 

549 # 

550 # dok = matrix_symbol.todok() 

551 # shape = matrix_symbol.shape 

552 # keys, exprs = zip(*dok.items()) if dok else ([], []) 

553 # rows = np.array([k[0] for k in keys], dtype=np.int32) 

554 # cols = np.array([k[1] for k in keys], dtype=np.int32) 

555 # 

556 # TODO: do not do it like that, it is worse than the solution that already exists. 

557 # if parallel: 

558 # tasks = [(expr, static_map) for expr in exprs] 

559 # with ProcessPoolExecutor() as executor: 

560 # reduced_exprs = list(executor.map(_xreplace_element, tasks)) 

561 # else: 

562 # reduced_exprs = [expr.xreplace(static_map) for expr in exprs] 

563 # 

564 # return td_ordered, reduced_exprs, rows, cols, shape 

565 # 

566 # TODO: This is worse than the current solution, but the other version (numby/c...) should be implemented to speed 

567 # things up. But attention: Currently, it is just vibe coded... 

568 # def _build_matrix_function_lambdify( 

569 # self, key: str, parallel: bool = False 

570 # ) -> Callable: 

571 # """ 

572 # Builds the matrix function using lambdify with numpy backend. 

573 # 

574 # Parameters 

575 # ---------- 

576 # key : str 

577 # Either "symbol" or "input" to determine the matrix type. 

578 # parallel : bool 

579 # If True, parallelizes static symbol substitution. 

580 # 

581 # Returns 

582 # ------- 

583 # Callable : 

584 # Function accepting td symbol values, returning a csr_matrix. 

585 # """ 

586 # td_ordered, reduced_exprs, rows, cols, shape = ( 

587 # self._prepare_matrix_ingredients(key, parallel) 

588 # ) 

589 # 

590 # if not len(rows): 

591 # empty = csr_matrix(shape, dtype=float) 

592 # 

593 # def assembled(*_) -> csr_matrix: 

594 # return empty 

595 # 

596 # return assembled 

597 # 

598 # data_func = lambdify(td_ordered, reduced_exprs, "numpy") 

599 # template = coo_matrix( 

600 # (np.ones(len(rows), dtype=float), (rows, cols)), shape=shape 

601 # ).tocsr() 

602 # 

603 # def assembled(*td_vals: float) -> csr_matrix: 

604 # template.data[:] = np.asarray(data_func(*td_vals), dtype=float) 

605 # return template 

606 # 

607 # return assembled 

608 # 

609 # def _build_matrix_function_numba(self, key: str, parallel: bool = False) -> Callable: 

610 # """ 

611 # Builds the matrix function using a numba-JIT compiled callable, with 

612 # caching to self.save_folder_path keyed by self.hash. 

613 # 

614 # Parameters 

615 # ---------- 

616 # key : str 

617 # Either "symbol" or "input" to determine the matrix type. 

618 # parallel : bool 

619 # If True, parallelizes static symbol substitution. 

620 # 

621 # Returns 

622 # ------- 

623 # Callable : 

624 # Function accepting td symbol values, returning a csr_matrix. 

625 # """ 

626 # td_ordered, reduced_exprs, rows, cols, shape = ( 

627 # self._prepare_matrix_ingredients(key, parallel) 

628 # ) 

629 # 

630 # if not len(rows): 

631 # empty = csr_matrix(shape, dtype=float) 

632 # 

633 # def assembled(*_) -> csr_matrix: 

634 # return empty 

635 # 

636 # return assembled 

637 # 

638 # cache_path = Path(self.save_folder_path) / f"{self.hash}_{key}_numba.pkl" 

639 # 

640 # if cache_path.exists(): 

641 # with open(cache_path, "rb") as f: 

642 # data_func = pickle.load(f) 

643 # else: 

644 # data_func = lambdify(td_ordered, reduced_exprs, "numpy") 

645 # data_func_nb = numba.njit(data_func, cache=True) 

646 # with open(cache_path, "wb") as f: 

647 # pickle.dump(data_func_nb, f) 

648 # data_func = data_func_nb 

649 # 

650 # template = coo_matrix( 

651 # (np.ones(len(rows), dtype=float), (rows, cols)), shape=shape 

652 # ).tocsr() 

653 # 

654 # def assembled(*td_vals: float) -> csr_matrix: 

655 # template.data[:] = np.asarray(data_func(*td_vals), dtype=float) 

656 # return template 

657 # 

658 # return assembled 

659 # 

660 # def _build_matrix_function_c(self, key: str, parallel: bool = False) -> Callable: 

661 # """ 

662 # Builds the matrix function using a compiled C shared library via ctypes, 

663 # falling back to numba if compilation fails. 

664 # 

665 # Parameters 

666 # ---------- 

667 # key : str 

668 # Either "symbol" or "input" to determine the matrix type. 

669 # parallel : bool 

670 # If True, parallelizes static symbol substitution. 

671 # 

672 # Returns 

673 # ------- 

674 # Callable : 

675 # Function accepting td symbol values, returning a csr_matrix. 

676 # """ 

677 # td_ordered, reduced_exprs, rows, cols, shape = ( 

678 # self._prepare_matrix_ingredients(key, parallel) 

679 # ) 

680 # 

681 # if not len(rows): 

682 # empty = csr_matrix(shape, dtype=float) 

683 # 

684 # def assembled(*_) -> csr_matrix: 

685 # return empty 

686 # 

687 # return assembled 

688 # 

689 # func_name = f"matrix_func_{self.hash}_{key}" 

690 # so_path = Path(self.save_folder_path) / f"{func_name}.so" 

691 # 

692 # try: 

693 # if so_path.exists(): 

694 # import ctypes 

695 # lib = ctypes.CDLL(str(so_path)) 

696 # fn = getattr(lib, func_name) 

697 # n_out = len(rows) 

698 # out_type = ctypes.c_double * n_out 

699 # 

700 # def data_func(*td_vals: float) -> np.ndarray: 

701 # out = out_type() 

702 # fn( 

703 # *[ctypes.c_double(v) for v in td_vals], 

704 # out, 

705 # ctypes.c_size_t(n_out), 

706 # ) 

707 # return np.frombuffer(out, dtype=np.float64).copy() 

708 # else: 

709 # source = _build_c_source(reduced_exprs, td_ordered, func_name) 

710 # data_func = _compile_c_function( 

711 # source, func_name, len(rows), so_path 

712 # ) 

713 # except Exception as e: 

714 # print( 

715 # f"C compilation failed for '{key}' matrix ({e}), falling back to numba." 

716 # ) 

717 # return self._build_matrix_function_numba(key, parallel=parallel) 

718 # 

719 # template = coo_matrix( 

720 # (np.ones(len(rows), dtype=float), (rows, cols)), shape=shape 

721 # ).tocsr() 

722 # 

723 # def assembled(*td_vals: float) -> csr_matrix: 

724 # template.data[:] = np.asarray(data_func(*td_vals), dtype=float) 

725 # return template 

726 # 

727 # return assembled 

728 # 

729 # def _build_matrix_function( 

730 # self, key: str, strategy: str | None = None 

731 # ) -> Callable: 

732 # """ 

733 # Dispatcher selecting the matrix function build strategy. 

734 # 

735 # Parameters 

736 # ---------- 

737 # key : str 

738 # Either "symbol" or "input" to determine the matrix type. 

739 # strategy : str | None 

740 # One of "lambdify", "parallel", "numba", "c". If None, falls back 

741 # to self.strategy set at __init__. 

742 # 

743 # Returns 

744 # ------- 

745 # Callable : 

746 # Function accepting td symbol values, returning a csr_matrix. 

747 # """ 

748 # s = strategy if strategy is not None else self.strategy 

749 # if s == "lambdify": 

750 # return self._build_matrix_function_lambdify(key, parallel=False) 

751 # if s == "parallel": 

752 # return self._build_matrix_function_lambdify(key, parallel=True) 

753 # if s == "numba": 

754 # return self._build_matrix_function_numba(key, parallel=False) 

755 # if s == "c": 

756 # return self._build_matrix_function_c(key, parallel=False) 

757 # raise ValueError(f"Unknown strategy '{s}'. Choose from: lambdify, parallel, numba, c.") 

758 # 

759 # def _build_matrix( 

760 # self, key: str, strategy: str | None = None 

761 # ) -> csr_matrix: 

762 # """ 

763 # Inputs all values into the desired matrix function. 

764 # 

765 # Parameters 

766 # ---------- 

767 # key : str 

768 # Either "symbol" or "input" to determine the matrix type. 

769 # strategy : str | None 

770 # Overrides self.strategy if provided. 

771 # 

772 # Returns 

773 # ------- 

774 # csr_matrix : 

775 # The desired matrix. 

776 # """ 

777 # attr = f"_{key}_matrix" 

778 # if getattr(self, attr) is None: 

779 # if not self.get_time_dependent_symbols(): 

780 # result = self._build_matrix_direct(key) 

781 # else: 

782 # result = self._build_matrix_function(key, strategy=strategy)( 

783 # *self.get_values() 

784 # ) 

785 # assert self.no_infinity(result), ( 

786 # f"Infinity detected in {key} matrix. System cannot be solved." 

787 # ) 

788 # setattr(self, attr, result) 

789 # return getattr(self, attr) 

790 

791 def _build_matrix(self, key: str) -> SparseMatrix | ImmutableSparseMatrix | spmatrix | sparray: 

792 """ 

793 Inputs all values (can be symbolic values) into the desired matrix function. 

794 

795 Parameters 

796 ---------- 

797 key : str 

798 Either "symbol" or "input" to determine the matrix type. 

799 

800 Returns 

801 ------- 

802 SparseMatrix | ImmutableSparseMatrix | spmatrix | sparray : 

803 The desired matrix. If no time dependent variables are used, it is a scipy sparse matrix, otherwise it's a 

804 sympy SparseMatrix. 

805 """ 

806 attr = f"_{key}_matrix" 

807 if getattr(self, attr) is None: 

808 if self.time_dependent_system_symbols: 

809 result = self._build_matrix_function(key)(*self.system_values) 

810 else: 

811 result = self._build_matrix_direct(key) 

812 if not self.skip_infinity_check: 

813 assert self.no_infinity(result), f"Infinity detected in {key} matrix. System cannot be solved." 

814 setattr(self, attr, result) 

815 return getattr(self, attr) 

816 

817 @property 

818 def system_matrix_symbol(self) -> SparseMatrix | ImmutableSparseMatrix: 

819 return self._get_matrix_symbol("system") 

820 

821 @property 

822 def input_matrix_symbol(self) -> SparseMatrix | ImmutableSparseMatrix: 

823 return self._get_matrix_symbol("input") 

824 

825 @property 

826 def system_matrix_function(self) -> Callable: 

827 return self._build_matrix_function("system") 

828 

829 @property 

830 def input_matrix_function(self) -> Callable: 

831 return self._build_matrix_function("input") 

832 

833 @property 

834 def system_matrix(self) -> SparseMatrix | ImmutableSparseMatrix | spmatrix | sparray: 

835 return self._build_matrix("system") 

836 

837 @property 

838 def input_matrix(self) -> SparseMatrix | ImmutableSparseMatrix | spmatrix | sparray: 

839 return self._build_matrix("input") 

840 

841 @property 

842 def temperature_vector_symbol(self) -> list: 

843 if self._temperature_vector_symbol is None: 

844 result = [node.temperature_symbol for node in self.nodes] 

845 self._temperature_vector_symbol = result 

846 return self._temperature_vector_symbol 

847 

848 @property 

849 def temperature_vector(self) -> np.ndarray: 

850 result = [node.temperature for node in self.nodes] 

851 return np.array(result).flatten() 

852 

853 @property 

854 def input_vector(self) -> np.ndarray: 

855 result = [n for input_item in self.inputs for n in input_item.values] 

856 return np.array(result).reshape(-1,1) 

857 

858 @property 

859 def input_vector_symbol(self) -> np.ndarray: 

860 if self._input_vector_symbol is None: 

861 result = [] 

862 if self.inputs is not None and self.inputs != []: 

863 result = [n for input_item in self.inputs for n in input_item.symbols] 

864 self._input_vector_symbol = np.array(result).flatten() 

865 return self._input_vector_symbol 

866 

867 # @property 

868 # def seconds_of_day(self): 

869 # """ 

870 # Returns the seconds that have already passed on the day of ``self.start_time``. 

871 # 

872 # Returns 

873 # ------- 

874 # float | int : 

875 # The passed seconds of the day. 

876 # """ 

877 # return (self.settings.start_date - self.settings.start_date.astype("datetime64[D]")) / np.timedelta64(1, "s") 

878 

879 @property 

880 def save_folder_path(self): 

881 if self.settings.save_folder_path is None: 

882 return run_folder 

883 return self.settings.save_folder_path 

884 

885 @property 

886 def pickle_path(self) -> str: 

887 filename = f"{self.hash}.pickle" 

888 return os.path.normpath(os.path.join(self.save_folder_path, filename)) 

889 

890 def pickle_path_result(self, t_span, name_add_on=None): 

891 name_add_on = add_leading_underscore(name_add_on) 

892 filename = f"{self.hash}{name_add_on}_{int(t_span[1])}_result.pickle" 

893 return os.path.normpath(os.path.join(self.save_folder_path, filename)) 

894 

895 @property 

896 def pickle_path_single_solution(self) -> str: 

897 filename = f"{self.hash}_single_solution.pickle" 

898 return os.path.normpath(os.path.join(self.save_folder_path, filename)) 

899 

900 def pickle_path_solution(self, t_span, name_add_on=None) -> str: 

901 name_add_on = add_leading_underscore(name_add_on) 

902 filename = f"{self.hash}{name_add_on}_{int(t_span[0])}_{int(t_span[1])}_solution.pickle" 

903 return os.path.normpath(os.path.join(self.save_folder_path, filename)) 

904 

905 def save_matrices(self): 

906 if self.save_to_pickle: 

907 file_path = self.pickle_path 

908 matrices = [ 

909 self._system_matrix_symbol, 

910 self._input_matrix_symbol, 

911 self._input_vector_symbol, 

912 self._temperature_vector_symbol, 

913 self.forbidden_values, 

914 ] 

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

916 pickle.dump(matrices, f) 

917 

918 def load_matrices(self): 

919 file_path = self.pickle_path 

920 if os.path.exists(file_path): 

921 with open(file_path, "rb") as f: 

922 matrices = pickle.load(f) 

923 self._system_matrix_symbol = matrices[0] 

924 self._input_matrix_symbol = matrices[1] 

925 self._input_vector_symbol = matrices[2] 

926 self._temperature_vector_symbol = matrices[3] 

927 self.forbidden_values = matrices[4] 

928 print("matrices loaded") 

929 return True 

930 else: 

931 print("No network found nor loaded.") 

932 return False 

933 

934 def save_last_step_solution(self, pickle_path_single_solution: str = None): 

935 """ 

936 Saves the last time step solution. 

937 """ 

938 if pickle_path_single_solution is None: 

939 pickle_path_single_solution = self.pickle_path_single_solution 

940 file_path = pickle_path_single_solution 

941 self.rc_solution.save_last_step(file_path) 

942 

943 def load_initial_values(self, return_bool: bool = False, pickle_path_single_solution: str = None) -> None | bool: 

944 """ 

945 Loads the last time step solution (temperature vector) and sets it as initial values. 

946 """ 

947 if pickle_path_single_solution is None: 

948 pickle_path_single_solution = self.pickle_path_single_solution 

949 file_path = pickle_path_single_solution 

950 if os.path.exists(file_path): 

951 with open(file_path, "rb") as f: 

952 solution_tuple = pickle.load(f) 

953 temp_vector = solution_tuple[0] 

954 input_vector = solution_tuple[1] 

955 for node, temp in zip(self.rc_objects.nodes, temp_vector): 

956 node.initial_temperature = temp 

957 for item, value in zip(self.inputs, input_vector): 

958 item.initial_value = value 

959 if return_bool: 

960 return True 

961 else: 

962 print("No temperature vector found nor loaded.") 

963 if return_bool: 

964 return False 

965 return None 

966 

967 @property 

968 def inhomogeneous_system(self) -> bool: 

969 """ 

970 Returns True if inputs are existing and the network is inhomogeneous. False otherwise. 

971 

972 Returns 

973 ------- 

974 bool : 

975 True if inputs are existing and the network is inhomogeneous. False otherwise. 

976 """ 

977 if self.rc_objects.inputs is None or self.rc_objects.inputs == []: 

978 return False 

979 return True 

980 

981 def determine_max_step(self) -> float | None: 

982 max_step = 1 

983 loop_counter = 0 

984 if self.rc_objects.mass_flow_nodes is not None or self.rc_objects.mass_flow_nodes != []: 

985 while loop_counter < 1000: 

986 try: 

987 self.check_courant(max_step) # approximated maximum time step used during iterations. 

988 # if no error 

989 break 

990 except HighCourantNumberError: 

991 loop_counter += 1 

992 max_step *= 0.99 

993 if loop_counter >= 1000: 

994 raise HighCourantNumberError 

995 if loop_counter == 0: 

996 return None 

997 return max_step 

998 

999 def solve_stationary(self, time_step: float | int = 0): 

1000 """ 

1001 Determines the analytic solution of the network (if it exists). It works for symbolic and numeric matrices. 

1002 

1003 Parameters 

1004 ---------- 

1005 time_step : float | int, default=0 

1006 The time step as which the solution is saved in the rc_solution object. 

1007 This parameter has no effect if the network is symbolic. 

1008 

1009 Returns 

1010 ------- 

1011 sp.Matrix | MutableDenseMatrix or None: 

1012 Stationary symbolic temperature vector of shape (n, 1) (only if result is symbolic, otherwise nothing is 

1013 returned and the result is written to the rc_solution object using time_step). 

1014 """ 

1015 if self.inhomogeneous_system: 

1016 result = solve_stationary( 

1017 system_matrix=self.system_matrix, 

1018 input_matrix=self.input_matrix, 

1019 input_vector=self.input_vector, 

1020 ) 

1021 else: 

1022 result = solve_stationary( 

1023 system_matrix=self.system_matrix, 

1024 ) 

1025 

1026 if isinstance(result, Basic) and result.free_symbols is not None: 

1027 # return symbolic result 

1028 return result 

1029 else: 

1030 # save result in rc_solution 

1031 self.rc_solution.add_to_solution( 

1032 [ 

1033 np.array(time_step).reshape( 

1034 -1, 

1035 ) 

1036 ], 

1037 [np.array(result).reshape(-1, 1)], 

1038 ) 

1039 return None 

1040 

1041 def solve_network( 

1042 self, 

1043 t_span: tuple, 

1044 print_progress=False, 

1045 name_add_on: str = "", 

1046 check_courant: bool = True, 

1047 time_dependent_tuple: tuple[Iterable, Callable] | None = None, 

1048 time_dependent_tuple_input: tuple[Iterable, Callable] | None = None, 

1049 hook_function: Callable = None, 

1050 expected_solution_size_mb=5000, 

1051 **kwargs: dict[str, int | Any], 

1052 ): 

1053 """ 

1054 Solves the network at the given times. 

1055 

1056 If time_vector is None, one second is calculated. 

1057 

1058 Parameters 

1059 ---------- 

1060 t_span : tuple | Any 

1061 Interval of integration (t0, tf). The solver starts with t=t0 and integrates until it reaches t=tf. 

1062 Both t0 and tf must be floats or values interpretable by the float conversion function. 

1063 print_progress : bool, default=False 

1064 If True, some print-outs are made during solving to get the time step that is currently simulated. 

1065 name_add_on : str, default="" 

1066 Optional add-on to the name of in-between saves that is placed after the hash value (separated by "_"). 

1067 Example save name: 

1068 42395d3d9f07f06ce9c9cb609b406aa54fc2dc5e8c4d9cc75987d9a02541ad2f_nameAddOn_zw_000001080_h.pickle 

1069 check_courant : bool, default=True 

1070 If True, self.check_courant(0.4) is run before simulating. 

1071 time_dependent_tuple : tuple[Iterable, Callable] | None, optional 

1072 A ordered list with the time dependent symbols and the function that calculates their values. 

1073 The list represents the order of the output of the function. 

1074 The function calculates the value of the time dependent symbols in the order of the list. It gets passed 

1075 the time step, temperature vector and input vector (last one only if existing): 

1076 value1, value2, ... = my_function(time_step, temperature_vector, input_vector) 

1077 time_dependent_tuple_input : tuple[Iterable, Callable] | None, optional 

1078 A ordered list with the time dependent input vector symbols and the function that calculates their values. 

1079 The list represents the order of the output of the function. 

1080 A function that calculates all time dependent variables within the time step and returns them in the same order as 

1081 RCNetwork.variable_input_vector_symbols. 

1082 It gets parameters like this:\n 

1083 ``time_dependent_function(time, temperature_vector)`` \n 

1084 This function is required if time dependent symbols exist in the input vector. 

1085 It must return an iterable (e.g. list). 

1086 hook_function : Callable, optional 

1087 A function that is run before the network is solved using solve_ivp. 

1088 Can be used to catch the time before the solving actually starts (e.g. used in `PerformanceTest`\\). 

1089 expected_solution_size_mb : int | float, default=5000 

1090 The expected solution size in Megabytes. Is used for RAM-Management in ``SystemHandler.solve()``\\. 

1091 The solution size depends on the size of the RC network and number of saved time steps. 

1092 kwargs : dict[str: int | Any], optional 

1093 Passed to SystemHandler. 

1094 """ 

1095 time_dependent_function = None 

1096 if self.time_dependent_system_symbols: 

1097 assert time_dependent_tuple is not None 

1098 assert time_dependent_tuple[0] is not None 

1099 assert time_dependent_tuple[1] is not None 

1100 time_dependent_function: Callable = self.remap_symbol_function(*time_dependent_tuple) 

1101 time_dependent_function_input = None 

1102 if self.variable_input_vector_symbols: 

1103 assert time_dependent_tuple_input is not None 

1104 assert time_dependent_tuple_input[0] is not None 

1105 assert time_dependent_tuple_input[1] is not None 

1106 time_dependent_function_input = self.remap_symbol_function( 

1107 *time_dependent_tuple_input, wanted_order=self.variable_input_vector_symbols 

1108 ) 

1109 

1110 continued_simulation = False 

1111 if self.load_solution or self.continue_canceled: 

1112 success = self.rc_solution.load_solution( 

1113 self.pickle_path_solution(t_span, name_add_on), last_time_step=t_span[-1] 

1114 ) 

1115 if not success: 

1116 success = self.rc_solution.load_solution( 

1117 self.pickle_path_result(t_span, name_add_on), last_time_step=t_span[-1] 

1118 ) 

1119 if success or (not isinstance(success, bool) and success == 0): # ==0 because 0 could be the last time 

1120 # step (will never be the case.... -.-) 

1121 if not isinstance(success, bool): 

1122 if not self.continue_canceled: 

1123 self.rc_solution.delete_solution_except_first() 

1124 print(f"Partial solution detected but not used because continue_canceled is False.") 

1125 else: 

1126 print( 

1127 f"Simulation is started at loaded time step {success} on {success / t_span[-1] * 100:.2f} %." 

1128 ) 

1129 t_span = (success, t_span[-1]) 

1130 continued_simulation = True 

1131 else: 

1132 print("Solution was loaded from file.") 

1133 return None 

1134 

1135 if check_courant: 

1136 new_max_step = self.determine_max_step() 

1137 if new_max_step is not None: 

1138 if self.settings.solve_settings.max_step > new_max_step: 

1139 print( 

1140 f"Because of Courant Number: New maximum step is set to {new_max_step} (previous:" 

1141 f" {self.settings.solve_settings.max_step})" 

1142 ) 

1143 self.settings.solve_settings.max_step = new_max_step 

1144 

1145 temperature_vector = np.array(self.temperature_vector).flatten() 

1146 system_matrix = self.system_matrix 

1147 time_symbols = self.time_dependent_system_symbols 

1148 if time_symbols: 

1149 system_matrix = SparseSymbolicEvaluator(system_matrix, time_symbols) 

1150 

1151 system_handler_kwargs = { 

1152 "system_matrix": system_matrix, 

1153 "rc_solution": self.rc_solution, 

1154 "settings": self.settings, 

1155 "print_progress": print_progress, 

1156 "print_points": np.linspace(t_span[0], t_span[-1], 20 + 1), 

1157 "batch_end": t_span[-1], 

1158 "time_dependent_function": time_dependent_function, 

1159 } 

1160 

1161 # create the time_steps to solve the results (every X seconds a value) 

1162 t_eval = np.linspace( 

1163 t_span[0], t_span[1], max(1, int((t_span[1] - t_span[0]) / self.settings.save_all_x_seconds) + 1) 

1164 ) 

1165 

1166 if self.inhomogeneous_system: 

1167 input_matrix = self.input_matrix 

1168 if time_symbols: 

1169 input_matrix = SparseSymbolicEvaluator(input_matrix, time_symbols) 

1170 input_vector_free_symbols = self.variable_input_vector_symbols 

1171 if input_vector_free_symbols: 

1172 input_vector = ArraySymbolicEvaluator(self.input_vector, input_vector_free_symbols) 

1173 else: 

1174 input_vector = self.input_vector 

1175 

1176 system_handler_kwargs.update( 

1177 { 

1178 "input_matrix": input_matrix, 

1179 "input_vector": input_vector, 

1180 "t_eval": t_eval, 

1181 "first_time": t_span[0], 

1182 "time_dependent_function_input": time_dependent_function_input, 

1183 } 

1184 ) 

1185 

1186 # only update the keys that are in the predefined dictionary (it would also work if everything is passed anyway) 

1187 system_handler_kwargs.update({k: kwargs[k] for k in system_handler_kwargs if k in kwargs}) 

1188 

1189 system_handler = self.system_handler_type(**system_handler_kwargs) 

1190 save_prefix = f"{self.hash}" 

1191 if name_add_on: 

1192 save_prefix += f"_{name_add_on.removeprefix('_').removesuffix('_')}" 

1193 solve_handler = SolveIVPHandler( 

1194 system_handler=system_handler, 

1195 save_prefix=save_prefix, 

1196 ) 

1197 if hook_function is not None: 

1198 hook_function() 

1199 solve_handler.solve( 

1200 t_span=t_span, 

1201 y0=temperature_vector, 

1202 t_eval=t_eval, 

1203 continued_simulation=continued_simulation, 

1204 expected_solution_size_mb=expected_solution_size_mb, 

1205 ) 

1206 

1207 def remap_symbol_function( 

1208 self, symbols: Iterable[Symbol] | Symbol, function: Callable, wanted_order: Iterable[Symbol] | Symbol = None 

1209 ) -> Callable: 

1210 """ 

1211 Remap the functions return so that it maps self.get_time_dependent_symbols(). 

1212 The symbols iterable shows the order of the current return. 

1213 

1214 Parameters 

1215 ---------- 

1216 symbols : Iterable[Symbol] | Symbol 

1217 The order of the current return of the function. 

1218 function : Callable 

1219 The function that calculates the values for the symbols in the symbols iterable. 

1220 wanted_order : Iterable[Symbol] | Symbol, optional 

1221 A list with the expected order of the function output. 

1222 If None, self.get_time_dependent_symbols() is used. 

1223 

1224 Returns 

1225 ------- 

1226 Callable : 

1227 The function which return was reordered. 

1228 """ 

1229 if wanted_order is None: 

1230 wanted_order = self.time_dependent_system_symbols 

1231 if isinstance(wanted_order, Symbol): 

1232 wanted_order = [wanted_order] 

1233 if isinstance(symbols, Symbol): 

1234 symbols = [symbols] 

1235 pos = {s.name: i for i, s in enumerate(symbols)} 

1236 mapping = [pos[s.name] for s in wanted_order] 

1237 

1238 if len(mapping) == 1: 

1239 

1240 def reordered_function(*args, **kwargs): 

1241 return (function(*args, **kwargs)[mapping[0]],) 

1242 else: 

1243 getter = operator.itemgetter(*mapping) 

1244 

1245 def reordered_function(*args, **kwargs): 

1246 return getter(function(*args, **kwargs)) 

1247 

1248 return reordered_function 

1249 

1250 @property 

1251 def system_handler_type(self): 

1252 """ 

1253 Returns the SystemHandler type depending on which system (in/homogeneous) is needed. 

1254 

1255 Returns 

1256 ------- 

1257 type : 

1258 The system handler type. 

1259 """ 

1260 if self.inhomogeneous_system: 

1261 return InhomogeneousSystemHandler 

1262 return HomogeneousSystemHandler 

1263 

1264 @property 

1265 def all_objects(self) -> list: 

1266 return [ 

1267 *(self.rc_objects.resistors or []), 

1268 *(self.rc_objects.capacities or []), 

1269 *(self.rc_objects.inputs or []), 

1270 ] 

1271 

1272 @property 

1273 def resistors_filtered_equivalent(self) -> list[Resistor]: 

1274 """ 

1275 Get all resistor objects which equivalent resistor symbols are unique in defined order. 

1276 

1277 In other words: If two Capacitors are connected over multiple Resistors (parallel or serial) only one of 

1278 these is returned. 

1279 

1280 The order is kept like in self.rc_objects.resistors, except all objects that are filtered out. 

1281 

1282 Returns 

1283 ------- 

1284 list[Resistor] : 

1285 All resistor objects without doubled equivalent symbols. 

1286 """ 

1287 resistor_list: list = self.rc_objects.resistors or [] 

1288 all_resistors = set(resistor_list) 

1289 to_exclude = set() 

1290 

1291 # Collect all resistors to exclude in order of appearance 

1292 for obj in resistor_list: 

1293 if obj not in to_exclude: 

1294 inbetween = set(obj.all_resistors_inbetween) - {obj} 

1295 to_exclude.update(inbetween & all_resistors) 

1296 

1297 # Filter maintaining original order 

1298 return [obj for obj in resistor_list if obj not in to_exclude] 

1299 

1300 @property 

1301 def system_objects_unique(self) -> list[Resistor | Capacitor]: 

1302 """ 

1303 Returns all Resistor and Capacitor objects that are needed for system and B-matrix (using 

1304 resistors_filtered_equivalent instead of all resistors). 

1305 

1306 Returns 

1307 ------- 

1308 list[Resistor|Capacitor] : 

1309 All (equivalent representative) Resistors and Capacitor objects in the network. 

1310 """ 

1311 return [*self.resistors_filtered_equivalent, *(self.rc_objects.capacities or [])] 

1312 

1313 @property 

1314 def system_symbols(self) -> list: 

1315 """ 

1316 Returns a list with all R and C symbols for A- and B-matrix. 

1317 

1318 Returns 

1319 ------- 

1320 list : 

1321 All R and C symbols for A- and B-matrix. 

1322 """ 

1323 return [symb for rc_object in self.system_objects_unique for symb in rc_object.symbols] 

1324 

1325 @property 

1326 def system_values(self) -> list: 

1327 """ 

1328 Returns a list with all R and C values for A- and B-matrix. 

1329 

1330 Returns 

1331 ------- 

1332 list : 

1333 All R and C values for A- and B-matrix (might be sympy Expressions). 

1334 """ 

1335 return [ 

1336 val if isinstance(val, Expr) else np.float64(val) 

1337 for rc_object in self.system_objects_unique 

1338 for val in rc_object.values 

1339 ] 

1340 

1341 @property 

1342 def time_dependent_system_symbols(self) -> list: 

1343 """ 

1344 A list with all time dependent symbols of the system (A-matrix). 

1345 

1346 Returns 

1347 ------- 

1348 list : 

1349 The time dependent symbols of the system (appearing in system (A) matrix). 

1350 """ 

1351 symbols = [] 

1352 seen = set() 

1353 for rc_object in self.system_objects_unique: 

1354 for symb in rc_object.time_dependent_symbols: 

1355 if symb not in seen: 

1356 seen.add(symb) 

1357 symbols.append(symb) 

1358 return symbols 

1359 

1360 @property 

1361 def time_dependent_input_symbols(self): 

1362 """ 

1363 A list with all time dependent input symbols of the system (u vector). 

1364 

1365 Returns 

1366 ------- 

1367 list : 

1368 """ 

1369 return self.variable_input_vector_symbols 

1370 

1371 @property 

1372 def variable_input_vector_symbols(self) -> list: 

1373 """ 

1374 A list of all symbols in the input vector. 

1375 

1376 Returns 

1377 ------- 

1378 list : 

1379 The symbols in the input vector. 

1380 """ 

1381 symbols = [] 

1382 seen = set() 

1383 for value in self.input_vector.flatten(): 

1384 if isinstance(value, Expr): 

1385 for symbol in value.free_symbols: 

1386 if symbol not in seen: 

1387 seen.add(symbol) 

1388 symbols.append(symbol) 

1389 return symbols 

1390 

1391 def get_symbols_and_values(self) -> tuple[list, list]: 

1392 """ 

1393 Return two lists of first: all symbols and second: all associated values of all rc objects. 

1394 

1395 Returns 

1396 ------- 

1397 tuple[list, list] : 

1398 

1399 """ 

1400 return self.system_symbols, self.system_values 

1401 

1402 def make_system_matrices(self): 

1403 """ 

1404 Creates the Jacobian matrices of the RC Network. 

1405 

1406 If self.load_from_pickle the whole network will be loaded from pickle file if it exists to prevent heavy 

1407 calculations. 

1408 

1409 Returns 

1410 ------- 

1411 sympy expression : 

1412 The system matrix as sympy expression. 

1413 """ 

1414 success = False 

1415 if self.load_from_pickle: 

1416 try: 

1417 success = self.load_matrices() 

1418 except Exception: 

1419 pass 

1420 if not success: 

1421 # fill system matrix 

1422 terms = [] 

1423 involved_symbols: list[set] = [] 

1424 temperature_symbols: list[set] = [] 

1425 variables = [] 

1426 for node in self.nodes: 

1427 # works only for one term 

1428 term, t_symbols, all_symbols = node.temperature_derivative_term() 

1429 terms.append(term) 

1430 involved_symbols.append(all_symbols) 

1431 temperature_symbols.append(t_symbols) 

1432 variables.append(node.temperature_symbol) 

1433 

1434 self._system_matrix_symbol = build_jacobian( 

1435 terms, variables, temperature_symbols, num_cores=self.num_cores_jacobian 

1436 ) 

1437 print("system matrix created") 

1438 

1439 self.save_matrices() 

1440 

1441 input_variables = list(self.input_vector_symbol) 

1442 self._input_matrix_symbol = build_jacobian( 

1443 terms, input_variables, involved_symbols, num_cores=self.num_cores_jacobian 

1444 ) 

1445 print("input matrix created") 

1446 

1447 self.save_matrices() 

1448 

1449 def check_courant(self, time_step: float): 

1450 """ 

1451 Calculates the Courant number for the whole network and raises an error if its larger than 1. 

1452 

1453 Parameters 

1454 ---------- 

1455 time_step : float 

1456 The maximum time step in seconds. 

1457 

1458 Raises 

1459 ------ 

1460 HighCourantNumberError : 

1461 If the courant number is greater than 1 the network will calculate shit. 

1462 

1463 """ 

1464 # TODO: Do not use the courant number but return a critical maximum time_step to easy raise an error during 

1465 # iterations. So: no iteration, but calculate the maximum time_step and return this so that it can be set. 

1466 mass_flow_nodes = self.rc_objects.mass_flow_nodes 

1467 mass_flow_node: MassFlowNode 

1468 for mass_flow_node in mass_flow_nodes: 

1469 if mass_flow_node.courant_number(time_step) > 1: 

1470 raise HighCourantNumberError 

1471 

1472 def matrix_to_latex_diag(self): 

1473 matrix = self.system_matrix_symbol 

1474 diag_elements = matrix.diagonal() 

1475 matrix_no_diag = matrix - diag(*diag_elements) 

1476 

1477 matrix_latex = latex(matrix_no_diag) 

1478 diag_latex = latex(Matrix(diag_elements).reshape(len(diag_elements), 1)) 

1479 

1480 return f"{matrix_latex} + \\mathrm{{diag}}\\left({diag_latex}\\right)"