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
« 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# ------------------------------------------------------------------------------
8from __future__ import annotations
10import os
11import pickle
12import re
13from abc import ABC, abstractmethod
14from typing import TYPE_CHECKING, Any, Optional
16import numpy as np
17import pandas as pd
18from sympy import Expr, Symbol
19from vpython import box, vector
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
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
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
49 @property
50 def nodes(self) -> list:
51 return self.__nodes
53 @property
54 def capacities(self) -> list:
55 return self.__nodes
57 @property
58 def mass_flow_nodes(self):
59 from pyrc.core.nodes import MassFlowNode
61 return [n for n in self.nodes if isinstance(n, MassFlowNode)]
63 @property
64 def resistors(self) -> list[Resistor]:
65 return self.__resistors
67 @property
68 def boundaries(self) -> list:
69 return self.__boundaries
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
81 @property
82 def all(self) -> list[TemperatureNode | Resistor]:
83 """
84 Returns all `TemperatureNode`\\s and `Resistor`\\s (network objects, that are linked together).
86 `InternalHeatSource` s are not returned!
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
100 @property
101 def all_equation_objects(self) -> list[EquationItem]:
102 return [*self.all, *self.other_equation_objects]
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.
109 This is mainly used for `InternalHeatSource` s and in the future for other input items.
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
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
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
136 @property
137 def raw_data(self) -> tuple:
138 return (self.__nodes, self.__resistors, self.__boundaries, self.__input_objs)
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
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.
154 Parameters
155 ----------
156 variant : type
157 The ``type`` of the objects that will be returned.
158 Will be used for the ``isinstance()`` match.
160 Returns
161 -------
162 list :
163 The list with all objects in the network with requested variant.
164 """
165 result = []
167 for element in self.all:
168 if isinstance(element, variant):
169 result.append(element)
170 return result
172 def set_loaded_data(self, loaded_objects: RCObjects):
173 """
174 Replaces all attributes with the ones of the loaded `RCObjects`.
176 Parameters
177 ----------
178 loaded_objects : RCObjects
179 The loaded `RCObjects` that should "replace" self / should be used to overwrite self.
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]
189initial_rc_objects = RCObjects()
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.
199 Must be in the same order as self.values.
201 Returns
202 -------
203 list :
204 The list of sympy.symbols.
205 """
206 pass
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.
214 Must be in the same order as self.symbols.
216 Returns
217 -------
218 list :
219 The list of sympy.symbols.
220 """
221 pass
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.
228 The list is sorted by the name of the symbols for deterministic reasons.
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)
240class EquationItem(SymbolMixin, ABC):
241 item_counter = 0
243 def __init__(self):
244 EquationItem.item_counter += 1
245 self.id: int = EquationItem.item_counter
246 self._index = None
248 @classmethod
249 def reset_counter(cls):
250 EquationItem.item_counter = 0
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
260 self._temperature_dataframe = None
261 self._dataframe = None
263 self.last_saved_timestep_index = 0
265 @property
266 def input_exists(self) -> bool:
267 if self._input_vectors:
268 return True
269 return False
271 @property
272 def inputs(self):
273 return self.rc_objects.inputs
275 @property
276 def nodes(self):
277 return self.rc_objects.nodes
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
285 def last_value_input(self, index):
286 return self._input_vectors[-1][-1, index]
288 def append_to_input(self, new_input_vector: np.ndarray):
289 self._input_vectors.append(new_input_vector.reshape(1, -1))
291 def delete_last_input(self):
292 if len(self._input_vectors) > 0:
293 self._input_vectors.pop()
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])
304 self._temperature_dataframe = None
305 self._dataframe = None
307 self.last_saved_timestep_index = 0
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
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.
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.
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 = []
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.
342 New data is defined as all data that is saved in a time step greater than self.last_saved_timestep.
344 Parameters
345 ----------
346 path_with_name_and_ending : str
347 Where to save the solution.
349 Returns
350 -------
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)
360 if self.time_steps is not None:
361 self.last_saved_timestep_index = len(self.time_steps) - 1
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.
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.
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)
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()
418 if use_degrees_celsius:
419 result -= 273.15
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 )
430 @property
431 def raw_data(self):
432 return [self.result_vectors, self._input_vectors, self.time_steps, self._temperature_dataframe, self._dataframe]
434 def raw_data_last(self, starting_index):
435 """
436 Like raw_data but only returns the values starting from `starting_index`.
438 Parameters
439 ----------
440 starting_index : int
441 The index where to start getting the data from.
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:]
464 return [result_vectors, input_vectors, time_steps, temperature_dataframe, dataframe]
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
475 self._temperature_dataframe = None
476 self._dataframe = None
478 self.last_saved_timestep_index = 0
479 else:
480 print("You have to confirm to delete all data.")
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.
486 This method forces the load. That the hash matches the current network is the responsibility of the user.
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 (".*").
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.
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
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.
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)
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])
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)
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]])
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]])
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.
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
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.
599 The current solution is replaced if some exist!
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.
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 = []
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))
629 solutions.sort(key=lambda x: x[0])
631 all_data = [[] for _ in range(5)]
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)
640 # Concatenate once for each data type
641 if all_data[0]:
642 self.__result_vectors = np.concatenate(all_data[0], axis=0)
644 if all_data[1]:
645 self._input_vectors = []
646 for vectors in all_data[1]:
647 self._input_vectors.extend(vectors)
649 if all_data[2]:
650 self.time_steps = np.concatenate(all_data[2], axis=0)
652 if all_data[3]:
653 self._temperature_dataframe = pd.concat(all_data[3], ignore_index=True)
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)
663 if len(solutions) > 0:
664 return True
665 return False
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.
672 The hash is defined as all characters in the filename before the first underscore.
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.
677 Parameters
678 ----------
679 path : str
680 Full path to the saved pickle file including the file name (with or without ending)
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
709 @property
710 def exist(self) -> bool:
711 if self.result_vectors is not None:
712 return True
713 return False
715 @property
716 def result_vectors(self) -> np.ndarray | Any:
717 return self.__result_vectors
719 @result_vectors.setter
720 def result_vectors(self, value):
721 pass
723 @property
724 def time_steps_count(self):
725 return len(self.time_steps.flatten())
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.
732 The DataFrame is cached. To reset it, set ``self.result_vectors = None`` .
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
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
754 @property
755 def temperature_vectors(self) -> np.ndarray:
756 """
757 Like temperature_vectors_pandas, but returns a numpy array.
759 This value is not cached.
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
768 @property
769 def t(self):
770 return self.time_steps
772 @t.setter
773 def t(self, value):
774 self.time_steps = value
776 @property
777 def y(self):
778 return self.result_vectors
780 @y.setter
781 def y(self, value):
782 assert isinstance(value, np.ndarray)
783 self.__result_vectors = value
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
801 def save_last_step(self, file_path):
802 """
803 Saves a vector with the solution of the last time step.
805 The saved data can be set as initial values using `RCNetwork.load_initial_values`
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)
818solution_object = RCSolution()
821class ObjectWithPorts:
822 def __init__(self):
823 self.__neighbours = []
825 @property
826 def neighbours(self):
827 return self.__neighbours
829 @property
830 def ports(self):
831 """
832 Alias for `self.neighbours`.
833 """
834 return self.__neighbours
836 def __iter__(self):
837 """
838 Iterate over `self.neighbours`.
840 Returns
841 -------
842 ObjectWithPorts :
843 The neighbours.
844 """
845 for neighbour in self.neighbours:
846 yield neighbour
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.
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.
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
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.")
875 self.__neighbours.append(neighbour)
876 neighbour.__single_connect(self)
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
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)
894 def double_connect(self, neighbour1, neighbour2):
895 self.connect(neighbour1)
896 self.connect(neighbour2)
898 def __single_connect(self, neighbour):
899 """
900 Like `self.connect`, but it doesn't set the connection to the neighbour, too.
902 Parameters
903 ----------
904 neighbour : ObjectWithPorts
905 The neighbour to connect to.
906 """
907 self.__neighbours.append(neighbour)
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
918 @property
919 def guess_volume_flow(self):
920 return self._volume_flow
922 @property
923 def volume_flow(self):
924 return self._volume_flow
926 @abstractmethod
927 def check_balance(self) -> bool:
928 pass
930 @property
931 def sources(self) -> list:
932 return []
934 @property
935 def sinks(self) -> list:
936 return []
938 @property
939 @abstractmethod
940 def balance(self):
941 pass
944class Geometric:
945 """
946 Skeleton for a geometric object that only contains the position in 3D space.
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.
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 """
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 ):
971 self.fixed_z = False
972 self.fixed_xy = False
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
981 @property
982 def position(self) -> np.ndarray:
983 return self.__position.copy()
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()
998 @property
999 def x(self):
1000 return self.__position[0]
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])
1009 @property
1010 def y(self):
1011 return self.__position[1]
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])
1020 @property
1021 def z(self):
1022 return self.__position[2]
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])
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.
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
1055 super().__init__(position=position)
1057 self.delta = delta
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()
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
1077 @vbox.setter
1078 def vbox(self, value):
1079 assert isinstance(value, box)
1080 self.__vbox = value
1082 @property
1083 def delta(self):
1084 """
1085 Returns the delta vector.
1087 Returns
1088 -------
1089 np.ndarray :
1090 The delta vector.
1091 """
1092 return self.__delta
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}.")
1106 if self.__vbox is not None:
1107 self.update_vbox_geometry()
1109 @property
1110 def delta_x(self):
1111 return self.delta[0]
1113 @property
1114 def delta_y(self):
1115 return self.delta[1]
1117 @property
1118 def delta_z(self):
1119 return self.delta[2]
1121 @property
1122 def length(self):
1123 return self.delta_x
1125 @property
1126 def height(self):
1127 return self.delta_y
1129 @property
1130 def depth(self):
1131 return self.delta_z
1133 @property
1134 def boundaries(self) -> list:
1135 """
1136 Returns the boundaries of the cell.
1138 The format looks like:
1139 [-x, x, -y, y, -z, z]
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]
1150 def face_points(self, direction: str) -> np.ndarray:
1151 """
1152 Return the four vertices of a cell face in a given direction.
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).
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'.
1163 Returns
1164 -------
1165 np.ndarray
1166 Shape (4, 3) array of vertex coordinates.
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 )
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
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
1198 half = self.delta / 2
1199 center_face = self.position.copy()
1200 center_face[axis] += sign * half[axis]
1202 offsets = [
1203 (-half[t1], -half[t2]),
1204 (half[t1], -half[t2]),
1205 (half[t1], half[t2]),
1206 (-half[t1], half[t2]),
1207 ]
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
1216 return vertices
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)
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.
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)
1247 def _apply_alignment(self, alignment, reference_position, other_deltas):
1248 """
1249 Apply alignment string to calculate new position.
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
1267 Returns
1268 -------
1269 np.ndarray
1270 Updated position after applying alignment
1272 Raises
1273 ------
1274 ValueError
1275 If alignment string is malformed
1276 """
1277 new_position = reference_position.copy()
1279 # Remove spaces and parse character by character
1280 alignment_no_space = alignment.replace(" ", "")
1282 i = 0
1283 while i < len(alignment_no_space):
1284 self_direction = 1
1285 other_direction = -1
1286 signs = []
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}")
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
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)
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 )
1320 return new_position
1322 def place_adjacent(self, other_cell, alignment):
1323 """
1324 Place other_cell adjacent to self aligned at specified face(s).
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)
1343 return other_cell
1345 def create_adjacent(self, alignment, **kwargs):
1346 """
1347 Create and place new cell of same type adjacent to self.
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.)
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)
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.
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.
1395 Returns
1396 -------
1397 np.ndarray
1398 3D array of shape (nx, ny, nz) containing Cell instances.
1399 """
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()
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
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.
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.
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)
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.
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
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.
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)
1482 @property
1483 def name(self):
1484 return self.__name
1486 @property
1487 def density(self):
1488 return self.__density
1490 @property
1491 def heat_capacity(self):
1492 return self.__heat_capacity
1494 @property
1495 def thermal_conductivity(self):
1496 return self.__thermal_conductivity
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 """
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
1526 @property
1527 def kin_viscosity(self):
1528 return self.__kin_viscosity
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
1536 @property
1537 def Pr(self):
1538 return self.prandtl_number
1540 @property
1541 def grashof_number(self):
1542 return self.__grashof_number
1544 @property
1545 def Gr(self):
1546 return self.__grashof_number
1548 @property
1549 def rayleigh_number(self):
1550 return self.grashof_number * self.prandtl_number
1553class Solid(Material):
1554 def __init__(self, *args, **kwargs):
1555 super().__init__(*args, **kwargs)
1558class CompositeMaterialSolid(Solid):
1559 """
1560 Combine multiple materials using ratios.
1561 """
1563 def __init__(self, name, materials, ratios, **kwargs):
1564 total_ratio = sum(ratios)
1565 weights = [ratio / total_ratio for ratio in ratios]
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))
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)
1581class CompositeMaterialFluid(Fluid):
1582 """
1583 Combine multiple fluid materials using ratios.
1584 """
1586 def __init__(self, name, materials, ratios, **kwargs):
1587 total_ratio = sum(ratios)
1588 weights = [ratio / total_ratio for ratio in ratios]
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))
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)
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
1618class EquationItemInput(Input, EquationItem, ABC):
1619 pass