Coverage for pyrc\core\inputs.py: 42%
250 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 sys
11from typing import TYPE_CHECKING
13import numpy as np
14from scipy.constants import Stefan_Boltzmann
15from sympy import symbols
17from pyrc.core.components.input import Input
18from pyrc.core.components.node import TemperatureNode
19from pyrc.core.components.templates import (
20 Cell,
21 ConnectedFlowObject,
22 EquationItem,
23 Geometric,
24 RCObjects,
25 RCSolution,
26 calculate_balance_for_resistors,
27 initial_rc_objects,
28 solution_object,
29)
31if TYPE_CHECKING:
32 from sympy import Expr
34 from pyrc import Capacitor
35 from pyrc.core.components.resistor import Resistor
36 from pyrc.core.nodes import MassFlowNode, Node
38_bc_missing_counterparts: list[str] = []
41# TODO: create a new class implementing the settings (and same for rc_objects) as class variable which can be set
42# initially for all subclasses to eliminate the need of passing the instances to each new class instances.
45class BoundaryCondition(TemperatureNode, Input):
46 def __init__(
47 self,
48 temperature,
49 rc_objects: RCObjects = initial_rc_objects,
50 rc_solution: RCSolution = solution_object,
51 is_fluid: bool = False,
52 heat_transfer_coefficient: float = np.nan,
53 **kwargs,
54 ):
55 """
56 Boundary condition of the RC network. Only sets a temperature without having a capacity.
58 Parameters
59 ----------
60 temperature : float | int | np.number
61 The temperature of the node.
62 It is recommended to use the SI unit Kelvin instead of degrees Celsius.
63 position : np.ndarray
64 The position of the node in 2D/3D space.
65 If 2D, a zero is added for the z coordinate.
66 heat_transfer_coefficient : float
67 The heat transfer coefficient used to calculate free convection to the surrounding solid nodes.
68 is_fluid : bool, default=False
69 If True, the BC is considered as fluid, otherwise as solid Material.
70 kwargs : dict
71 Optional arguments passed to `TemperatureNode`\\.
72 """
73 TemperatureNode.__init__(
74 self, temperature=temperature, rc_objects=rc_objects, rc_solution=rc_solution, **kwargs
75 )
76 self._is_fluid = is_fluid
77 self.htc: float | np.float64 = heat_transfer_coefficient # in W/m/m/K
79 @property
80 def heat_transfer_coefficient(self):
81 return self.htc
83 def __init_subclass__(cls, **kwargs):
84 """
85 Warns if no subclass of this class was found in this module (py-file) that inherits from `Cell`\\.
87 Every BoundaryCondition class should have a counterpart that also is a `Cell` and one that's a `Geometric`\\.
88 This way, the boundary condition can be used in algorithms like Capacitors that also are cells (in meshes).
89 The classes should be named like the non-Cell/Geometric-classes extended with "Cell"/"Geometric".
91 This class is only useful during development, when new `BoundaryConditions` are added.
93 Parameters
94 ----------
95 kwargs
97 Returns
98 -------
100 """
101 super().__init_subclass__(**kwargs)
102 if cls.__name__.endswith("Geometric") or cls.__name__.endswith("Cell"):
103 # The method is run in the Cell/Geometric subclass, so no check is needed :D
104 return
105 for geometric_version in ["Cell", "Geometric"]:
106 _bc_missing_counterparts.append(cls.__name__ + geometric_version)
108 @property
109 def is_solid(self):
110 return not self._is_fluid
112 @property
113 def is_fluid(self):
114 return self._is_fluid
116 def __str__(self):
117 return self.__repr__()
119 def __repr__(self):
120 return f"{self.__class__.__name__}: ϑ={self.temperature}"
122 @property
123 def index(self) -> int:
124 """
125 The index of `self` within the input vector (row in input matrix).
127 The value is cached to improve performance.
129 Returns
130 -------
131 int :
132 The index of self within the input vector.
133 """
134 if not self._index:
135 self._index = self.rc_objects.inputs.index(self)
136 return self._index
138 @property
139 def initial_value(self):
140 return self.initial_temperature
142 @initial_value.setter
143 def initial_value(self, value):
144 self.initial_temperature = value
146 @property
147 def temperature(self) -> float | int | np.number:
148 """
149 The temperature of `self`\\.
151 If no solution is saved yet, the initial temperature is returned.
153 Returns
154 -------
155 float | int | np.number
156 """
157 if not self.solutions.input_exists:
158 return self.initial_temperature
159 return self.solutions.last_value_input(self.index)
161 @property
162 def temperature_vector(self):
163 """
164 The vector with all temperature values of `self` of all (currently existing) time steps.
166 If no solution is saved (yet), the initial temperature is returned as vector with `time_step` length.
168 Returns
169 -------
170 np.ndarray | np.number
171 """
172 if not self.solutions.exist:
173 result = np.array([self.temperature] * self.solutions.time_steps_count)
174 return result
175 return self.solutions.input_vectors[:, self.index]
178class BoundaryConditionGeometric(BoundaryCondition, Geometric):
179 def __init__(self, *args, position, **kwargs):
180 Geometric.__init__(self, position=position)
181 BoundaryCondition.__init__(self, *args, **kwargs)
184class BoundaryConditionCell(BoundaryCondition, Cell):
185 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
186 Cell.__init__(self, position=position, delta=delta)
187 BoundaryCondition.__init__(self, *args, **kwargs)
190class FluidBoundaryCondition(BoundaryCondition):
191 def __init__(
192 self,
193 *args,
194 rc_objects: RCObjects = initial_rc_objects,
195 rc_solution: RCSolution = solution_object,
196 **kwargs: float | int | np.ndarray,
197 ):
198 """
200 Parameters
201 ----------
202 args
203 heat_transfer_coefficient : float
204 The heat transfer coefficient used to calculate free convection to the surrounding solid nodes.
205 rc_objects
206 rc_solution
207 kwargs
208 """
209 super().__init__(*args, rc_objects=rc_objects, rc_solution=rc_solution, is_fluid=True, **kwargs)
212class FluidBoundaryConditionGeometric(FluidBoundaryCondition, Geometric):
213 def __init__(self, *args, position, **kwargs):
214 Geometric.__init__(self, position=position)
215 FluidBoundaryCondition.__init__(self, *args, **kwargs)
218class FluidBoundaryConditionCell(FluidBoundaryCondition, Cell):
219 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
220 Cell.__init__(self, position=position, delta=delta)
221 FluidBoundaryCondition.__init__(self, *args, **kwargs)
224class SolidBoundaryCondition(BoundaryCondition):
225 def __init__(
226 self, *args, rc_objects: RCObjects = initial_rc_objects, rc_solution: RCSolution = solution_object, **kwargs
227 ):
228 super().__init__(*args, rc_objects=rc_objects, rc_solution=rc_solution, is_fluid=False, **kwargs)
231class SolidBoundaryConditionGeometric(SolidBoundaryCondition, Geometric):
232 def __init__(self, *args, position, **kwargs):
233 Geometric.__init__(self, position=position)
234 SolidBoundaryCondition.__init__(self, *args, **kwargs)
237class SolidBoundaryConditionCell(SolidBoundaryCondition, Cell):
238 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
239 Cell.__init__(self, position=position, delta=delta)
240 SolidBoundaryCondition.__init__(self, *args, **kwargs)
243class FlowBoundaryCondition(FluidBoundaryCondition, ConnectedFlowObject):
244 def __init__(
245 self,
246 *args,
247 is_mass_flow_start: bool = False,
248 volume_flow=None,
249 rc_objects: RCObjects = initial_rc_objects,
250 rc_solution: RCSolution = solution_object,
251 **kwargs,
252 ):
253 """
255 Parameters
256 ----------
257 args
258 is_mass_flow_start : bool, default=False
259 If True, the `BoundaryCondition` is a start of a mass flow.
260 If so, it must be connected to a `MassTransport` resistor.
261 volume_flow : float, optional
262 The volume flow in m^3/s
263 rc_objects
264 rc_solution
265 kwargs
266 """
267 FluidBoundaryCondition.__init__(self, *args, rc_objects=rc_objects, rc_solution=rc_solution, **kwargs)
268 ConnectedFlowObject.__init__(self)
270 self.volume_flow = volume_flow
271 self.is_mass_flow_start: bool = is_mass_flow_start
273 if self.is_mass_flow_start:
274 self.volume_flow_is_balanced = True
276 @property
277 def balance(self):
278 from pyrc.core.resistors import MassTransport
280 return calculate_balance_for_resistors(self, [res for res in self.neighbours if isinstance(res, MassTransport)])
282 @property
283 def volume_flow(self):
284 return super().volume_flow
286 @volume_flow.setter
287 def volume_flow(self, value):
288 self._volume_flow = value
290 @property
291 def sinks(self) -> list[MassFlowNode]:
292 """
293 A list with all `MassFlowNode`\\s that are sinks of mass flow for `self`\\.
295 Returns
296 -------
297 list[MassFlowNode]
298 """
299 from pyrc.core.resistors import MassTransport
301 result = []
302 if self.is_mass_flow_start:
303 for neighbour in self.neighbours:
304 if isinstance(neighbour, MassTransport):
305 result.append(neighbour.get_connected_node(self))
306 return result
308 @property
309 def sources(self) -> list[MassFlowNode]:
310 """
311 A list with all `MassFlowNode`\\s that are sources of mass flow for `self`\\.
313 Returns
314 -------
315 list[MassFlowNode]
316 """
317 from pyrc.core.resistors import MassTransport
319 result = []
320 if not self.is_mass_flow_start:
321 for neighbour in self.neighbours:
322 if isinstance(neighbour, MassTransport):
323 result.append(neighbour.get_connected_node(self))
324 return result
326 def check_balance(self) -> bool:
327 """
328 If the sum of all sinks and sources of `self` is 0 this method returns True, False otherwise.
330 Returns
331 -------
332 bool
333 """
334 if self.volume_flow_is_balanced:
335 return True
336 # check if start or end
337 if self.is_mass_flow_start:
338 self.volume_flow_is_balanced = True
339 return True
340 # self is end. So check all connected ConnectedFlowObjects for check_balance
341 flow_objects = []
342 resistor: Resistor
343 for node in [resistor.get_connected_node(self) for resistor in self.neighbours]:
344 if isinstance(node, ConnectedFlowObject):
345 if node in flow_objects:
346 continue
347 flow_objects.append(node)
348 for flow_object in flow_objects:
349 if flow_object.check_balance():
350 continue
351 else:
352 return False
353 return True
355 def connect(self, *args, **kwargs):
356 from pyrc.core.resistors import MassTransport
358 super().connect(*args, **kwargs)
359 # Prevent the connection to every other Resistor than MassTransport.
360 assert isinstance(self.neighbours[-1], MassTransport)
363class FlowBoundaryConditionGeometric(FlowBoundaryCondition, Geometric):
364 def __init__(self, *args, position, **kwargs):
365 Geometric.__init__(self, position=position)
366 FlowBoundaryCondition.__init__(self, *args, **kwargs)
369class FlowBoundaryConditionCell(FlowBoundaryCondition, Cell):
370 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
371 Cell.__init__(self, position=position, delta=delta)
372 FlowBoundaryCondition.__init__(self, *args, **kwargs)
375class InternalHeatSource(EquationItem, Input):
376 def __init__(
377 self,
378 node: Capacitor,
379 power: float | int | Expr = None,
380 specific_power_in_w_per_cubic_meter: float | int | Expr = None,
381 specific_power_in_w_per_meter_squared: float | int | Expr = None,
382 area_direction: np.ndarray = None,
383 ):
384 """
385 Internal heat source (energy source or sink).
387 Parameters
388 ----------
389 node : Capacitor
390 The Capacitor it belongs to.
391 power : float | int | Expr, optional
392 The power of the heat source. Negative values act as sink.
393 specific_power_in_w_per_cubic_meter : float | int | Expr, optional
394 The volume specific power of the heat source. Negative values act as sink.
395 specific_power_in_w_per_meter_squared : float | int | Expr, optional
396 The area specific power of the heat source. Negative values act as sink.
397 The area used to calculate the actual value is determined by area_direction vector. It points to the
398 surface that should be used.
399 Works only for Nodes, not for Capacitors that are no Cells.
400 area_direction : np.ndarray, optional
401 The direction to the area that should be used to calculate the area specific power.
402 """
403 EquationItem.__init__(self)
404 self.node: Capacitor = node
405 self.__power: float | int | Expr | None = power
406 self.volume_specific_power: float | int | Expr | None = specific_power_in_w_per_cubic_meter # in W/(m^3)
407 self.__area_specific_power: float | int | Expr | None = specific_power_in_w_per_meter_squared # in W/(m^3)
408 self.symbol = symbols(f"Q_dot_{self.node.id}")
409 self.area_direction: np.ndarray = area_direction # should be like (1,0,0) in any order and sign
411 # must be placed after initialization!
412 power_params = [specific_power_in_w_per_cubic_meter, specific_power_in_w_per_meter_squared, power]
413 if sum(p is not None for p in power_params) > 1:
414 raise ValueError(
415 "Set only one of: specific_power_in_w_per_cubic_meter, specific_power_in_w_per_meter_squared, power."
416 )
417 if specific_power_in_w_per_meter_squared is not None and area_direction is None:
418 raise ValueError("If specific_power_in_w_per_meter_squared is set, area_direction must be set too.")
419 if (
420 area_direction is not None
421 and specific_power_in_w_per_meter_squared is None
422 and any(p is not None for p in power_params)
423 ):
424 warnings.warn("area_direction is set but specific_power_in_w_per_meter_squared is not used.")
425 if specific_power_in_w_per_meter_squared is not None:
426 assert isinstance(self.node, Cell), "When using area specific power self.node must be a Cell object."
428 # The calculation of the volume_specific_power is done later because during initialization the volume isn't
429 # known (because it depends on the connected Nodes that are still created during initialization).
431 # check, if self.volume_specific_power is None and replace it with 0 if so
432 if self.volume_specific_power is None and self.__area_specific_power is None:
433 self.volume_specific_power = np.float64(0)
434 self._area = None
436 @property
437 def initial_value(self) -> float | int | Expr:
438 return self.power
440 @initial_value.setter
441 def initial_value(self, value):
442 """
443 Set the initial power value.
445 Parameters
446 ----------
447 value : float | int | Expr
448 The initial power value in Watts.
449 """
450 self.__power = value
452 def no_node(self):
453 """
454 Checks, if self.node is not of class Node and warns if so.
456 Returns
457 -------
458 bool :
459 False if self.node is of class Node.
460 """
461 if isinstance(self.node, Cell):
462 return False
463 print("This only works for Cell nodes, not Capacitors.")
464 return True
466 @property
467 def area(self):
468 if self._area is None:
469 if self.no_node():
470 return None
471 self.node: Node
472 if self._area is None:
473 self._area = self.node.area(self.area_direction)
474 return self._area
476 @property
477 def index(self) -> int:
478 if not self._index:
479 self._index = self.node.rc_objects.inputs.index(self)
480 return self._index
482 @property
483 def power(self) -> float | int | Expr:
484 if self.__power is None:
485 if self.no_node():
486 ValueError(
487 "If InternalHeatSource.node is a Capacitor the power must be set direct, "
488 "not with area/volume specific parameters."
489 )
490 self.node: Node
491 if self.volume_specific_power is None:
492 assert self.__area_specific_power is not None
493 self.set_area_specific_power(self.__area_specific_power)
494 self.__power: float | int | Expr = self.volume_specific_power * self.node.volume
495 return self.__power
497 @property
498 def area_specific_power(self):
499 if self.area_direction is None:
500 return None
501 return self.power / self.area
503 def set_area_specific_power(
504 self, area_specific_power_in_w_per_square_meter: float | int | Expr, direction: np.ndarray = None
505 ):
506 """
507 Sets the volume specific power by calculating it with the area specific power and a direction/normal of the
508 effective surface.
510 Parameters
511 ----------
512 area_specific_power_in_w_per_square_meter : float | int | Expr
513 The area specific power in W/m^2.
514 direction : np.ndarray, optional
515 The normal of the surface where the power is applied to. Should be (1,0,0) with any order and sign.
516 Is used to get the area of the node using ``self.node.area(direction)``\\.
518 """
519 assert not self.no_node(), "All area/volume specific values can only be used with Nodes, not with Capacitors."
520 if direction is None:
521 assert self.area_direction is not None
522 if self.area_direction is None:
523 assert isinstance(direction, np.ndarray) and len(direction) == 3, "Direction to face must be passed!"
524 self.area_direction = direction
525 else:
526 if direction is not None:
527 assert isinstance(direction, np.ndarray) and len(direction) == 3
528 print("Warning: direction is overwritten.")
529 self.area_direction = direction
530 self.volume_specific_power = area_specific_power_in_w_per_square_meter * self.area / self.node.volume
532 @property
533 def symbols(self) -> list:
534 return [self.symbol]
536 @property
537 def values(self) -> list:
538 return [self.power]
541class Radiation(InternalHeatSource):
542 def __init__(self, *args, epsilon_short=0.7, epsilon_long=0.93, **kwargs):
543 super().__init__(*args, **kwargs)
544 self.epsilon_short = epsilon_short
545 self.epsilon_long = epsilon_long
546 self.epsilon_long_boltzmann = self.epsilon_long * Stefan_Boltzmann # pre-calculation of calculate_dynamic
547 self._bc_temp_input_index = None
549 @property
550 def bc_temp_input_index(self):
551 if self._bc_temp_input_index is None:
552 for fbc in self.node.get_connected_nodes(variant=FluidBoundaryCondition):
553 # TODO: If multiple BoundaryConditions are connected, only the last one is used for temp. calculation
554 # If one capacitor has multiple BCs connected the calculation won't work as it is implemented currently
555 # with calculate_dynamic_functions if no weather data is used.
556 fbc: FluidBoundaryCondition
557 self._bc_temp_input_index = fbc.index
558 assert self._bc_temp_input_index is not None
559 return self._bc_temp_input_index
562# must be at the end of this module
563# This checks if for every BoundaryCondition also a similar class exists that inherits from Geometry / Cell (both separately)
564_module = sys.modules[__name__]
565for _counterpart in _bc_missing_counterparts:
566 if not hasattr(_module, _counterpart):
567 import warnings
569 warnings.warn(f"Missing counterpart {_counterpart}", stacklevel=2)