Coverage for pyrc \ core \ inputs.py: 65%
379 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-29 14:14 +0200
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-29 14:14 +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 datetime import datetime
13import numpy as np
14from sympy import symbols
15from typing import TYPE_CHECKING, Callable
16from scipy.constants import Stefan_Boltzmann
18from pyrc.core.components.node import TemperatureNode
19from pyrc.core.settings import initial_settings, Settings
20from pyrc.core.components.templates import (
21 Geometric,
22 ConnectedFlowObject,
23 calculate_balance_for_resistors,
24 EquationItem,
25 RCObjects,
26 initial_rc_objects,
27 RCSolution,
28 solution_object,
29 Cell,
30)
31from pyrc.core.components.input import Input
33if TYPE_CHECKING:
34 from pyrc.core.nodes import MassFlowNode
35 from pyrc.core.components.resistor import Resistor
36 from sympy import Expr
37 from pyrc import Capacitor
39_bc_missing_counterparts: list[str] = []
42# TODO: create a new class implementing the settings (and same for rcobjects) as class variable which can be set
43# initially for all subclasses to eliminate the need of passing the instances to each new class instances.
46class BoundaryCondition(TemperatureNode, Input):
47 def __init__(
48 self,
49 temperature,
50 rc_objects: RCObjects = initial_rc_objects,
51 rc_solution: RCSolution = solution_object,
52 is_mass_flow_start: bool = False,
53 heat_transfer_coefficient: float = np.nan,
54 is_fluid: bool = False,
55 settings: Settings = initial_settings,
56 **kwargs,
57 ):
58 """
59 Boundary condition of the RC network. Only sets a temperature without having a capacity.
61 Parameters
62 ----------
63 temperature : float | int | np.number
64 The temperature of the node.
65 It is recommended to use the SI unit Kelvin instead of degrees Celsius.
66 position : np.ndarray
67 The position of the node in 2D/3D space.
68 If 2D, a zero is added for the z coordinate.
69 is_mass_flow_start : bool
70 If True, the `BoundaryCondition` is a start of a mass flow.
71 If so, it must be connected to a `MassTransport` resistor.
72 heat_transfer_coefficient : float
73 The heat transfer coefficient used to calculate free convection to the surrounding solid nodes.
74 is_fluid : bool, optional
75 If True, the BC is considered as fluid, otherwise as solid Material.
76 settings : Settings, optional
77 A `Settings` object with general settings, including `SolveSettings`
78 kwargs : dict
79 Optional arguments passed to `TemperatureNode`\\.
80 """
81 TemperatureNode.__init__(
82 self, temperature=temperature, rc_objects=rc_objects, rc_solution=rc_solution, **kwargs
83 )
84 Input.__init__(self, settings=settings)
85 self._is_fluid = is_fluid
86 # TODO: The following attributes should be moved to the correct subclasses
87 self.is_mass_flow_start: bool = is_mass_flow_start
88 self.htc: float | np.float64 = heat_transfer_coefficient # in W/m/m/K
90 def __init_subclass__(cls, **kwargs):
91 """
92 Warns if no subclass of this class was found in this module (py-file) that inherits from `Cell`\\.
94 Every BoundaryCondition class should have a counterpart that also is a `Cell` and one that's a `Geometric`\\.
95 This way, the boundary condition can be used in algorithms like Capacitors that also are cells (in meshes).
96 The classes should be named like the non-Cell/Geometric-classes extended with "Cell"/"Geometric".
98 This class is only useful during development, when new `BoundaryConditions` are added.
100 Parameters
101 ----------
102 kwargs
104 Returns
105 -------
107 """
108 super().__init_subclass__(**kwargs)
109 if cls.__name__.endswith("Geometric") or cls.__name__.endswith("Cell"):
110 # The method is run in the Cell/Geometric subclass, so no check is needed :D
111 return
112 for geometric_version in ["Cell", "Geometric"]:
113 _bc_missing_counterparts.append(cls.__name__ + geometric_version)
115 @property
116 def is_solid(self):
117 return not self._is_fluid
119 @property
120 def is_fluid(self):
121 return self._is_fluid
123 def __str__(self):
124 return self.__repr__()
126 def __repr__(self):
127 return f"{self.__class__.__name__}: ϑ={self.temperature}"
129 @property
130 def heat_transfer_coefficient(self):
131 return self.htc
133 @property
134 def index(self) -> int:
135 """
136 The index of `self` within the input vector (row in input matrix).
138 The value is cached to improve performance.
140 Returns
141 -------
142 int
143 """
144 if not self._index:
145 self._index = self.rc_objects.inputs.index(self)
146 return self._index
148 @property
149 def initial_value(self):
150 return self.initial_temperature
152 @initial_value.setter
153 def initial_value(self, value):
154 self.initial_temperature = value
156 @property
157 def temperature(self) -> float | int | np.number:
158 """
159 The temperature of `self`\\.
161 If no solution is saved yet, the initial temperature is returned.
163 Returns
164 -------
165 float | int | np.number
166 """
167 if not self.solutions.input_exists:
168 return self.initial_temperature
169 return self.solutions.last_value_input(self.index)
171 @property
172 def temperature_vector(self):
173 """
174 The vector with all temperature values of `self` of all (currently existing) time steps.
176 If no solution is saved (yet), the initial temperature is returned as vector with `time_step` length.
178 Returns
179 -------
180 np.ndarray | np.number
181 """
182 if not self.solutions.exist:
183 result = np.array([self.temperature] * self.solutions.time_steps_count)
184 return result
185 return self.solutions.input_vectors[:, self.index]
187 def calculate_static(self, tau, temp_vector, _input_vector, *args, **kwargs):
188 """
190 Parameters
191 ----------
192 tau
193 temp_vector
194 _input_vector
195 args
196 kwargs
198 Returns
199 -------
201 """
202 return self.temperature
205class BoundaryConditionGeometric(BoundaryCondition, Geometric):
206 def __init__(self, *args, position, **kwargs):
207 Geometric.__init__(self, position=position)
208 BoundaryCondition.__init__(self, *args, **kwargs)
211class BoundaryConditionCell(BoundaryCondition, Cell):
212 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
213 Cell.__init__(self, position=position, delta=delta)
214 BoundaryCondition.__init__(self, *args, **kwargs)
217class FluidBoundaryCondition(BoundaryCondition):
218 def __init__(
219 self,
220 *args,
221 rc_objects: RCObjects = initial_rc_objects,
222 rc_solution: RCSolution = solution_object,
223 **kwargs: Settings | float | int | np.ndarray,
224 ):
225 super().__init__(*args, rc_objects=rc_objects, rc_solution=rc_solution, is_fluid=True, **kwargs)
228class FluidBoundaryConditionGeometric(FluidBoundaryCondition, Geometric):
229 def __init__(self, *args, position, **kwargs):
230 Geometric.__init__(self, position=position)
231 FluidBoundaryCondition.__init__(self, *args, **kwargs)
234class FluidBoundaryConditionCell(FluidBoundaryCondition, Cell):
235 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
236 Cell.__init__(self, position=position, delta=delta)
237 FluidBoundaryCondition.__init__(self, *args, **kwargs)
240class InteriorBoundaryCondition(FluidBoundaryCondition):
241 pass
244class InteriorBoundaryConditionGeometric(InteriorBoundaryCondition, Geometric):
245 def __init__(self, *args, position, **kwargs):
246 Geometric.__init__(self, position=position)
247 InteriorBoundaryCondition.__init__(self, *args, **kwargs)
250class InteriorBoundaryConditionCell(InteriorBoundaryCondition, Cell):
251 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
252 Cell.__init__(self, position=position, delta=delta)
253 InteriorBoundaryCondition.__init__(self, *args, **kwargs)
256class ExteriorTemperatureInputMixin:
257 def __init__(
258 self,
259 temperature_delta=None,
260 middle_temperature=None,
261 settings: Settings = initial_settings,
262 ):
263 """
264 Class to calculate the exterior temperature.
266 The temperature can be returned:
267 1. static
268 2. dynamic: sinus curve using ``temperature_delta`` and ``middle_temperature``
269 3. dynamic: weather data. The information is in the ``settings`` object.
271 Parameters
272 ----------
273 temperature_delta : float | int, optional
274 The amplitude of the sinus curve.
275 Not used if ``self.settings.use_weather_data is True``
276 middle_temperature : float | int, optional
277 The middle temperature of the sinus curve.
278 Not used if ``self.settings.use_weather_data is True``
279 settings : Settings, optional
280 A settings object with all important settings.
281 If not given the initial Settings object is used.
282 """
283 self.settings = settings
284 if not self.settings.use_weather_data:
285 assert temperature_delta is not None and middle_temperature is not None
286 self.temperature_delta = temperature_delta
287 self.middle_temperature = middle_temperature
289 def get_kwargs_functions(self) -> dict:
290 """
291 See Input.get_kwargs()
293 Returns
294 -------
295 dict :
296 The names of the values that must be passed to calculate_static/dynamic and the function to calculate
297 this values.
298 If for example the exterior temperature is needed for calculate_dynamic, the dict looks like this:
299 {"exterior_temperature": lambda tau: my_fancy_algorithm_to_calculate/get_the_exterior_temperature(tau)}
300 To use it, this dict must be passed to calculate_dynamic like this:
301 my_obj.calculate_dynamic(tau, temp_vector, _input_vector, **the_get_kwargs_dict)
302 """
303 if self.settings.calculate_static:
304 return {"exterior_temperature": self.calculate_static_fun()}
305 return {"exterior_temperature": self.calculate_dynamic_fun()}
307 def calculate_static_fun(self) -> Callable:
308 dynamic_fun = self.calculate_dynamic_fun()
309 value = dynamic_fun(0)
310 return lambda *args, **_kwargs: value
312 def calculate_dynamic_fun(self) -> Callable:
313 if not self.settings.use_weather_data:
314 return lambda tau, *args, **kwargs: (
315 (self.temperature_delta * -np.cos(np.pi / 43200 * (tau + self.settings.start_shift - 14400))) / 2
316 + self.middle_temperature
317 )
318 # use weather data
319 return self.settings.weather_data.get_interpolator("exterior_temperature")
321 def calculate_dynamic(self, *args, exterior_temperature=None, **kwargs):
322 return exterior_temperature
325class ExteriorBoundaryCondition(FluidBoundaryCondition, ExteriorTemperatureInputMixin):
326 def __init__(
327 self,
328 *args,
329 temperature_delta=None,
330 middle_temperature=None,
331 rc_objects: RCObjects = initial_rc_objects,
332 rc_solution: RCSolution = solution_object,
333 settings: Settings = initial_settings,
334 **kwargs: bool | float | int,
335 ):
336 FluidBoundaryCondition.__init__(
337 self, *args, rc_objects=rc_objects, rc_solution=rc_solution, settings=settings, **kwargs
338 )
339 ExteriorTemperatureInputMixin.__init__(self, temperature_delta, middle_temperature, settings=settings)
341 def get_kwargs_functions(self) -> dict:
342 return ExteriorTemperatureInputMixin.get_kwargs_functions(self)
344 def calculate_static(self, *args, **kwargs):
345 return self.calculate_dynamic(*args, **kwargs)
347 def calculate_dynamic(self, tau, temp_vector, _input_vector, *args, **kwargs):
348 # Logic is in ExteriorTemperatureInputMixin.calculate_dynamic_fun.
349 # In **kwargs there must be a keyword "exterior_temperature" to work correctly
350 return ExteriorTemperatureInputMixin.calculate_dynamic(self, **kwargs)
353class ExteriorBoundaryConditionGeometric(ExteriorBoundaryCondition, Geometric):
354 def __init__(self, *args, position, **kwargs):
355 Geometric.__init__(self, position=position)
356 ExteriorBoundaryCondition.__init__(self, *args, **kwargs)
359class ExteriorBoundaryConditionCell(ExteriorBoundaryCondition, Cell):
360 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
361 Cell.__init__(self, position=position, delta=delta)
362 ExteriorBoundaryCondition.__init__(self, *args, **kwargs)
365class SolidBoundaryCondition(BoundaryCondition):
366 def __init__(
367 self, *args, rc_objects: RCObjects = initial_rc_objects, rc_solution: RCSolution = solution_object, **kwargs
368 ):
369 super().__init__(*args, rc_objects=rc_objects, rc_solution=rc_solution, is_fluid=False, **kwargs)
372class SolidBoundaryConditionGeometric(SolidBoundaryCondition, Geometric):
373 def __init__(self, *args, position, **kwargs):
374 Geometric.__init__(self, position=position)
375 SolidBoundaryCondition.__init__(self, *args, **kwargs)
378class SolidBoundaryConditionCell(SolidBoundaryCondition, Cell):
379 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
380 Cell.__init__(self, position=position, delta=delta)
381 SolidBoundaryCondition.__init__(self, *args, **kwargs)
384class FlowBoundaryCondition(FluidBoundaryCondition, ConnectedFlowObject):
385 def __init__(
386 self,
387 *args,
388 volume_flow=None,
389 rc_objects: RCObjects = initial_rc_objects,
390 rc_solution: RCSolution = solution_object,
391 **kwargs,
392 ):
393 FluidBoundaryCondition.__init__(self, *args, rc_objects=rc_objects, rc_solution=rc_solution, **kwargs)
394 ConnectedFlowObject.__init__(self)
396 self.volume_flow = volume_flow
398 if self.is_mass_flow_start:
399 self.volume_flow_is_balanced = True
401 @property
402 def balance(self):
403 from pyrc.core.resistors import MassTransport
405 return calculate_balance_for_resistors(self, [res for res in self.neighbours if isinstance(res, MassTransport)])
407 @property
408 def volume_flow(self):
409 return super().volume_flow
411 @volume_flow.setter
412 def volume_flow(self, value):
413 self._volume_flow = value
415 @property
416 def sinks(self) -> list[MassFlowNode]:
417 """
418 A list with all `MassFlowNode`\\s that are sinks of mass flow for `self`\\.
420 Returns
421 -------
422 list[MassFlowNode]
423 """
424 from pyrc.core.resistors import MassTransport
426 result = []
427 if self.is_mass_flow_start:
428 for neighbour in self.neighbours:
429 if isinstance(neighbour, MassTransport):
430 result.append(neighbour.get_connected_node(self))
431 return result
433 @property
434 def sources(self) -> list[MassFlowNode]:
435 """
436 A list with all `MassFlowNode`\\s that are sources of mass flow for `self`\\.
438 Returns
439 -------
440 list[MassFlowNode]
441 """
442 from pyrc.core.resistors import MassTransport
444 result = []
445 if not self.is_mass_flow_start:
446 for neighbour in self.neighbours:
447 if isinstance(neighbour, MassTransport):
448 result.append(neighbour.get_connected_node(self))
449 return result
451 def check_balance(self) -> bool:
452 """
453 If the sum of all sinks and sources of `self` is 0 this method returns True, False otherwise.
455 Returns
456 -------
457 bool
458 """
459 if self.volume_flow_is_balanced:
460 return True
461 # check if start or end
462 if self.is_mass_flow_start:
463 self.volume_flow_is_balanced = True
464 return True
465 # self is end. So check all connected ConnectedFlowObjects for check_balance
466 flow_objects = []
467 resistor: Resistor
468 for node in [resistor.get_connected_node(self) for resistor in self.neighbours]:
469 if isinstance(node, ConnectedFlowObject):
470 if node in flow_objects:
471 continue
472 flow_objects.append(node)
473 for flow_object in flow_objects:
474 if flow_object.check_balance():
475 continue
476 else:
477 return False
478 return True
480 def connect(self, *args, **kwargs):
481 from pyrc.core.resistors import MassTransport
483 super().connect(*args, **kwargs)
484 # Prevent the connection to every other Resistor than MassTransport.
485 assert isinstance(self.neighbours[-1], MassTransport)
488class FlowBoundaryConditionGeometric(FlowBoundaryCondition, Geometric):
489 def __init__(self, *args, position, **kwargs):
490 Geometric.__init__(self, position=position)
491 FlowBoundaryCondition.__init__(self, *args, **kwargs)
494class FlowBoundaryConditionCell(FlowBoundaryCondition, Cell):
495 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
496 Cell.__init__(self, position=position, delta=delta)
497 FlowBoundaryCondition.__init__(self, *args, **kwargs)
500class ExteriorInletFlow(FlowBoundaryCondition, ExteriorTemperatureInputMixin):
501 """
502 A boundary condition with constant mass flow and the temperature of `ExteriorTemperatureInput`\\.
504 This is the start of a mass flow.
505 """
507 def __init__(
508 self,
509 *args,
510 volume_flow=None,
511 temperature_delta=None,
512 middle_temperature=None,
513 rc_objects: RCObjects = initial_rc_objects,
514 rc_solution: RCSolution = solution_object,
515 settings: Settings = initial_settings,
516 **kwargs: np.ndarray | bool | float | int,
517 ):
518 FlowBoundaryCondition.__init__(
519 self,
520 *args,
521 volume_flow=volume_flow,
522 rc_objects=rc_objects,
523 rc_solution=rc_solution,
524 settings=settings,
525 **kwargs,
526 )
527 ExteriorTemperatureInputMixin.__init__(self, temperature_delta, middle_temperature, settings=settings)
528 self.is_mass_flow_start = True
530 def get_kwargs_functions(self) -> dict:
531 return ExteriorTemperatureInputMixin.get_kwargs_functions(self)
533 def calculate_static(self, *args, **kwargs):
534 return self.calculate_dynamic(*args, **kwargs)
536 def calculate_dynamic(self, tau, temp_vector, _input_vector, *args, **kwargs):
537 # Logic is in ExteriorTemperatureInputMixin.calculate_dynamic_fun.
538 # In **kwargs there must be a keyword "exterior_temperature" to work correctly
539 return ExteriorTemperatureInputMixin.calculate_dynamic(self, **kwargs)
542class ExteriorInletFlowGeometric(ExteriorInletFlow, Geometric):
543 def __init__(self, *args, position, **kwargs):
544 Geometric.__init__(self, position=position)
545 ExteriorInletFlow.__init__(self, *args, **kwargs)
548class ExteriorInletFlowCell(ExteriorInletFlow, Cell):
549 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
550 Cell.__init__(self, position=position, delta=delta)
551 ExteriorInletFlow.__init__(self, *args, **kwargs)
554class ExteriorOutletFlow(FlowBoundaryCondition, ExteriorTemperatureInputMixin):
555 """
556 A boundary condition serving as the end of a mass flow. The `volume_flow` value has no effect.
558 The temperature is set as `ExteriorTemperatureInput` to easily calculate the heat flux between the connected
559 `MassFlowNode` and the exterior Temperature. You could redefine this if you want to calculate another
560 difference. It has no effect for solving the RC network because only `MassTransport` Resistors are allowed as
561 neighbors and they are always just passing energy INTO this ExteriorOutletFlow boundary condition and never out
562 of it.
564 This is the end of a mass flow.
565 """
567 def __init__(
568 self,
569 *args,
570 temperature_delta=None,
571 middle_temperature=None,
572 rc_objects: RCObjects = initial_rc_objects,
573 rc_solution: RCSolution = solution_object,
574 settings: Settings = initial_settings,
575 **kwargs: np.ndarray | bool | float | int,
576 ):
577 FlowBoundaryCondition.__init__(
578 self, *args, rc_objects=rc_objects, rc_solution=rc_solution, settings=settings, **kwargs
579 )
580 ExteriorTemperatureInputMixin.__init__(self, temperature_delta, middle_temperature, settings=settings)
581 self.is_mass_flow_start = False
583 def get_kwargs_functions(self) -> dict:
584 return ExteriorTemperatureInputMixin.get_kwargs_functions(self)
586 def calculate_static(self, *args, **kwargs):
587 return self.calculate_dynamic(*args, **kwargs)
589 def calculate_dynamic(self, tau, temp_vector, _input_vector, *args, **kwargs):
590 # Logic is in ExteriorTemperatureInputMixin.calculate_dynamic_fun.
591 # In **kwargs there must be a keyword "exterior_temperature" to work correctly
592 return ExteriorTemperatureInputMixin.calculate_dynamic(self, **kwargs)
595class ExteriorOutletFlowGeometric(ExteriorOutletFlow, Geometric):
596 def __init__(self, *args, position, **kwargs):
597 Geometric.__init__(self, position=position)
598 ExteriorOutletFlow.__init__(self, *args, **kwargs)
601class ExteriorOutletFlowCell(ExteriorOutletFlow, Cell):
602 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs):
603 Cell.__init__(self, position=position, delta=delta)
604 ExteriorOutletFlow.__init__(self, *args, **kwargs)
607class InternalHeatSource(EquationItem, Input):
608 def __init__(
609 self,
610 node: Capacitor,
611 power: float | int | Expr = None,
612 specific_power_in_w_per_cubic_meter: float | int | Expr = None,
613 specific_power_in_w_per_meter_squared: float | int | Expr = None,
614 area_direction: np.ndarray = None,
615 settings: Settings = initial_settings,
616 ):
617 """
618 Internal heat source (energy source or sink).
620 Parameters
621 ----------
622 node : Capacitor
623 The Capacitor it belongs to.
624 power : float | int | Expr, optional
625 The power of the heat source. Negative values act as sink.
626 specific_power_in_w_per_cubic_meter : float | int | Expr, optional
627 The volume specific power of the heat source. Negative values act as sink.
628 specific_power_in_w_per_meter_squared : float | int | Expr, optional
629 The area specific power of the heat source. Negative values act as sink.
630 The area used to calculate the actual value is determined by area_direction vector. It points to the
631 surface that should be used.
632 Works only for Nodes, not for Capacitors that are no Cells.
633 area_direction : np.ndarray, optional
634 The direction to the area that should be used to calculate the area specific power.
635 settings : Settings, optional
636 The settings object to pass to Input class.
637 """
638 EquationItem.__init__(self)
639 Input.__init__(self, settings=settings)
640 self.node: Capacitor = node
641 self.__power: float | int | Expr = power
642 self.volume_specific_power: float | int | Expr = specific_power_in_w_per_cubic_meter # in W/(m**3)
643 self.__area_specific_power: float | int | Expr = specific_power_in_w_per_meter_squared # in W/(m**3)
644 self.symbol = symbols(f"Q_dot_{self.node.id}")
645 self.area_direction: np.ndarray = area_direction # should be like (1,0,0) in any order and sign
647 # must be placed after initialization!
648 power_params = [specific_power_in_w_per_cubic_meter, specific_power_in_w_per_meter_squared, power]
649 if sum(p is not None for p in power_params) > 1:
650 raise ValueError(
651 "Set only one of: specific_power_in_w_per_cubic_meter, specific_power_in_w_per_meter_squared, power."
652 )
653 if specific_power_in_w_per_meter_squared is not None and area_direction is None:
654 raise ValueError("If specific_power_in_w_per_meter_squared is set, area_direction must be set too.")
655 if (
656 area_direction is not None
657 and specific_power_in_w_per_meter_squared is None
658 and any(p is not None for p in power_params)
659 ):
660 warnings.warn("area_direction is set but specific_power_in_w_per_meter_squared is not used.")
661 if specific_power_in_w_per_meter_squared is not None:
662 assert isinstance(self.node, Cell), "When using area specific power self.node must be a Cell object."
664 # The calculation of the volume_specific_power is done later because during initialization the volume isn't
665 # known (because it depends on the connected Nodes that are still created during initialization).
667 # check, if self.volume_specific_power is None and replace it with 0 if so
668 if self.volume_specific_power is None and self.__area_specific_power is None:
669 self.volume_specific_power = np.float64(0)
670 self._area = None
672 @property
673 def initial_value(self) -> float | int | Expr:
674 return self.power
676 @initial_value.setter
677 def initial_value(self, value):
678 """
679 Set the intial power value.
681 Parameters
682 ----------
683 value : float | int | Expr
684 The initial power value in Watts.
685 """
686 self.__power = value
688 def no_node(self):
689 """
690 Checks, if self.node is not of class Node and warns if so.
692 Returns
693 -------
694 bool :
695 False if self.node is of class Node.
696 """
697 if isinstance(self.node, Cell):
698 return False
699 print(f"This only works for Cell nodes, not Capacitors.")
700 return True
702 @property
703 def area(self):
704 if self._area is None:
705 if self.no_node():
706 return None
707 if self._area is None:
708 self._area = self.node.area(self.area_direction)
709 return self._area
711 @property
712 def index(self) -> int:
713 if not self._index:
714 self._index = self.node.rc_objects.inputs.index(self)
715 return self._index
717 @property
718 def power(self) -> float | int | Expr:
719 if self.__power is None:
720 if self.no_node():
721 ValueError(
722 "If InternalHeatSource.node is a Capacitor the power must be set direct, "
723 "not with area/volume specific parameters."
724 )
725 if self.volume_specific_power is None:
726 self.set_area_specific_power(self.__area_specific_power)
727 self.__power = self.volume_specific_power * self.node.volume
728 return self.__power
730 @property
731 def area_specific_power(self):
732 if self.area_direction is None:
733 return None
734 return self.power / self.area
736 def set_area_specific_power(
737 self, area_specific_power_in_w_per_square_meter: float | int | Expr, direction: np.ndarray = None
738 ):
739 """
740 Sets the volume specific power by calculating it with the area specific power and a direction/normal of the
741 effective surface.
743 Parameters
744 ----------
745 area_specific_power_in_w_per_square_meter : float | int | Expr
746 The area specific power in W/m**2.
747 direction : np.ndarray, optional
748 The normal of the surface where the power is applied to. Should be (1,0,0) with any order and sign.
749 Is used to get the area of the node using ``self.node.area(direction)``\\.
751 """
752 assert not self.no_node(), "All area/volume specific values can only be used with Nodes, not with Capacitors."
753 if direction is None:
754 assert self.area_direction is not None
755 if self.area_direction is None:
756 assert isinstance(direction, np.ndarray) and len(direction) == 3, "Direction to face must be passed!"
757 self.area_direction = direction
758 else:
759 if direction is not None:
760 assert isinstance(direction, np.ndarray) and len(direction) == 3
761 print("Warning: direction is overwritten.")
762 self.area_direction = direction
763 self.volume_specific_power = area_specific_power_in_w_per_square_meter * self.area / self.node.volume
765 def calculate_static(self, tau, temp_vector, _input_vector, **kwargs):
766 return self.power
768 @property
769 def symbols(self) -> list:
770 return [self.symbol]
772 @property
773 def values(self) -> list:
774 return [self.power]
777class Radiation(InternalHeatSource):
778 def __init__(self, *args, epsilon_short=0.7, epsilon_long=0.93, **kwargs):
779 super().__init__(*args, **kwargs)
780 self.epsilon_short = epsilon_short
781 self.epsilon_long = epsilon_long
782 self.epsilon_long_boltzmann = self.epsilon_long * Stefan_Boltzmann # pre-calculation of calculate_dynamic
783 self._bc_temp_input_index = None
785 @property
786 def bc_temp_input_index(self):
787 if self._bc_temp_input_index is None:
788 for fbc in self.node.get_connected_nodes(variant=FluidBoundaryCondition):
789 # TODO: If multiple BoundaryConditions are connected, only the last one is used for temp. calculation
790 # If one capacitor has multiple BCs connected the calculation won't work as it is implemented currently
791 # with calculate_dynamic_functions if no weather data is used.
792 fbc: FluidBoundaryCondition
793 self._bc_temp_input_index = fbc.index
794 assert self._bc_temp_input_index is not None
795 return self._bc_temp_input_index
797 def get_kwargs_functions(self) -> dict:
798 if self.settings.calculate_static:
799 return self.calculate_static_functions()
800 return self.calculate_dynamic_functions()
802 def calculate_static_functions(self) -> dict:
803 dyn_functions: dict = self.calculate_dynamic_functions()
804 result = {}
805 for name, fun in dyn_functions.items():
806 result[name] = (
807 lambda f: lambda tau, temp_vec, input_vec, *args, **kwargs: f(0, temp_vec, input_vec, *args, **kwargs)
808 )(fun)
809 return result
811 def calculate_dynamic_functions(self) -> dict:
812 """
813 This function is used to pre-calculate some values during solving that are node independent.
815 This way calculation time can be decreased.
817 Returns
818 -------
819 dict :
820 A dictionary where the key stands for the parameter that is passed to the function calculating node
821 dependent stuff and the value is its value (crazy, I know).
822 """
823 result = {}
825 if self.settings.use_weather_data:
826 result["incoming_area_specific_power_long"] = self.settings.area_specific_radiation_interpolator_long
827 result["incoming_area_specific_power_short"] = self.settings.area_specific_radiation_interpolator_short
828 else:
829 dt = datetime.fromisoformat(self.settings.start_date)
830 seconds_today = dt.hour * 3600 + dt.minute * 60 + dt.second + dt.microsecond / 1e6
831 result["incoming_area_specific_power_short"] = lambda tau, *args, **kwargs: (
832 sin_function(tau, seconds_today) * self.settings.maximum_area_specific_power
833 )
834 result["incoming_area_specific_power_long"] = lambda tau, *args, **kwargs: 0
836 # the following only works if only one boundary condition is used for all Radiation objects (
837 # e.g. the ExteriorTemperature and all Radiation objects are influenced by the same. If multiple
838 # influences between Radiation objects and BCs would exists, the index is changing, depending on the
839 # Radiation object. For that, calculate the value directly in each object (increases calculation overhead)
840 # or create a unique kwarg for each of them (recommended with new classes))
841 index = self.bc_temp_input_index
842 result["sky_temp_4_diff"] = lambda tau, temp_vector, _input_vector, *args, _index=index, **kwargs: (
843 sky_temp_according_to_swinbank(_input_vector.ravel()[_index]) ** 4
844 - sky_temp_according_to_swinbank(_input_vector.ravel()[_index]) ** 4
845 )
846 return result
848 def calculate_static(self, tau, temp_vector, _input_vector, **kwargs):
849 return self.calculate_dynamic(0, temp_vector, _input_vector, **kwargs)
851 def calculate_dynamic(
852 self,
853 tau,
854 temp_vector,
855 _input_vector,
856 incoming_area_specific_power_short=np.nan,
857 incoming_area_specific_power_long=np.nan,
858 sky_temp_4_diff=np.nan,
859 **kwargs,
860 ):
861 temp_vector_flat = temp_vector.ravel()
863 node_temp_4 = temp_vector_flat[self.node.index] ** 4
865 if self.settings.use_weather_data:
866 outgoing_spec_power = self.epsilon_long_boltzmann * node_temp_4 # in W/m^2
867 else:
868 # here incoming_area_specific_power only contains the direct radiation. So the radiation towards sky and
869 # ambient has to be calculated, too.
870 # TODO: Use the sight factor of the wall for both sky and bc/ambient (but this information is not accessible
871 # here)
872 outgoing_spec_power = 0.5 * self.epsilon_long_boltzmann * (2 * node_temp_4 - sky_temp_4_diff)
873 power = (
874 incoming_area_specific_power_long * self.epsilon_long
875 + incoming_area_specific_power_short * self.epsilon_short
876 - outgoing_spec_power
877 ) * self.area
878 self.volume_specific_power = power / self.node.volume
879 return power
882def sky_temp_according_to_swinbank(air_temperature: float):
883 return 0.0552 * air_temperature**1.5
886def sin_function(tau, shift_start_time):
887 return np.maximum(0.0, -np.cos(np.pi / 43200 * (tau + shift_start_time)))
890# must be at the end of this module
891# This checks if for every BoundaryCondition also a similar class exists that inherits from Geometry / Cell (both separately)
892_module = sys.modules[__name__]
893for _counterpart in _bc_missing_counterparts:
894 if not hasattr(_module, _counterpart):
895 import warnings
897 warnings.warn(f"Missing counterpart {_counterpart}", stacklevel=2)