Coverage for pyrc \ core \ inputs.py: 65%

361 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-13 16:59 +0200

1# ------------------------------------------------------------------------------- 

2# Copyright (C) 2026 Joel Kimmich, Tim Jourdan 

3# ------------------------------------------------------------------------------ 

4# License 

5# This file is part of PyRC, distributed under GPL-3.0-or-later. 

6# ------------------------------------------------------------------------------ 

7 

8from __future__ import annotations 

9 

10import sys 

11from datetime import datetime 

12 

13import numpy as np 

14from sympy import symbols 

15from typing import TYPE_CHECKING, Callable 

16from scipy.constants import Stefan_Boltzmann 

17 

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 

32 

33if TYPE_CHECKING: 

34 from pyrc.core.nodes import Node, MassFlowNode 

35 from pyrc.core.components.resistor import Resistor 

36 

37_bc_missing_counterparts: list[str] = [] 

38 

39# TODO: create a new class implementing the settings (and same for rcobjects) as class variable which can be set 

40# initially for all subclasses to eliminate the need of passing the instances to each new class instances. 

41 

42 

43class BoundaryCondition(TemperatureNode, Input): 

44 def __init__( 

45 self, 

46 temperature, 

47 rc_objects: RCObjects = initial_rc_objects, 

48 rc_solution: RCSolution = solution_object, 

49 is_mass_flow_start: bool = False, 

50 heat_transfer_coefficient: float = np.nan, 

51 is_fluid: bool = False, 

52 settings: Settings = initial_settings, 

53 **kwargs, 

54 ): 

55 """ 

56 Boundary condition of the RC network. Only sets a temperature without having a capacity. 

57 

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 is_mass_flow_start : bool 

67 If True, the `BoundaryCondition` is a start of a mass flow. 

68 If so, it must be connected to a `MassTransport` resistor. 

69 heat_transfer_coefficient : float 

70 The heat transfer coefficient used to calculate free convection to the surrounding solid nodes. 

71 is_fluid : bool, optional 

72 If True, the BC is considered as fluid, otherwise as solid Material. 

73 settings : Settings, optional 

74 A `Settings` object with general settings, including `SolveSettings` 

75 kwargs : dict 

76 Optional arguments passed to `TemperatureNode`\. 

77 """ 

78 TemperatureNode.__init__( 

79 self, temperature=temperature, rc_objects=rc_objects, rc_solution=rc_solution, **kwargs 

80 ) 

81 Input.__init__(self, settings=settings) 

82 self._is_fluid = is_fluid 

83 # TODO: The following attributes should be moved to the correct subclasses 

84 self.is_mass_flow_start: bool = is_mass_flow_start 

85 self.htc: float | np.float64 = heat_transfer_coefficient # in W/m/m/K 

86 

87 def __init_subclass__(cls, **kwargs): 

88 """ 

89 Warns if no subclass of this class was found in this module (py-file) that inherits from `Cell`\. 

90 

91 Every BoundaryCondition class should have a counterpart that also is a `Cell` and one the is a `Geometric`\. 

92 This way, the boundary condition can be used in algorithms like Capacitors that also are cells (in meshes). 

93 The classes should be named like the non-Cell/Geometric-classes extended with "Cell"/"Geometric". 

94 

95 This class is only useful during development, when new `BoundaryConditions` are added. 

96 

97 Parameters 

98 ---------- 

99 kwargs 

100 

101 Returns 

102 ------- 

103 

104 """ 

105 super().__init_subclass__(**kwargs) 

106 if cls.__name__.endswith("Geometric") or cls.__name__.endswith("Cell"): 

107 # The method is run in the Cell/Geometric subclass, so no check is needed :D 

108 return 

109 for geometric_version in ["Cell", "Geometric"]: 

110 _bc_missing_counterparts.append(cls.__name__ + geometric_version) 

111 

112 @property 

113 def is_solid(self): 

114 return not self._is_fluid 

115 

116 @property 

117 def is_fluid(self): 

118 return self._is_fluid 

119 

120 def __str__(self): 

121 return self.__repr__() 

122 

123 def __repr__(self): 

124 return f"{self.__class__.__name__}: ϑ={self.temperature}" 

125 

126 @property 

127 def heat_transfer_coefficient(self): 

128 return self.htc 

129 

130 @property 

131 def index(self) -> int: 

132 """ 

133 The index of `self` within the input vector (row in input matrix). 

134 

135 The value is cached to improve performance. 

136 

137 Returns 

138 ------- 

139 int 

140 """ 

141 if not self._index: 

142 self._index = self.rc_objects.inputs.index(self) 

143 return self._index 

144 

145 @property 

146 def initial_value(self): 

147 return self.initial_temperature 

148 

149 @initial_value.setter 

150 def initial_value(self, value): 

151 self.initial_temperature = value 

152 

153 @property 

154 def temperature(self) -> float | int | np.number: 

155 """ 

156 The temperature of `self`\. 

157 

158 If no solution is saved yet, the initial temperature is returned. 

159 

160 Returns 

161 ------- 

162 float | int | np.number 

163 """ 

164 if not self.solutions.input_exists: 

165 return self.initial_temperature 

166 return self.solutions.last_value_input(self.index) 

167 

168 @property 

169 def temperature_vector(self): 

170 """ 

171 The vector with all temperature values of `self` of all (currently existing) time steps. 

172 

173 If no solution is saved (yet), the initial temperature is returned as vector with `time_step` length. 

174 

175 Returns 

176 ------- 

177 np.ndarray | np.number 

178 """ 

179 if not self.solutions.exist: 

180 result = np.array([self.temperature] * self.solutions.time_steps_count) 

181 return result 

182 return self.solutions.input_vectors[:, self.index] 

183 

184 def calculate_static(self, tau, temp_vector, _input_vector, *args, **kwargs): 

185 """ 

186 

187 Parameters 

188 ---------- 

189 tau 

190 temp_vector 

191 _input_vector 

192 args 

193 kwargs 

194 

195 Returns 

196 ------- 

197 

198 """ 

199 return self.temperature 

200 

201 

202class BoundaryConditionGeometric(BoundaryCondition, Geometric): 

203 def __init__(self, *args, position, **kwargs): 

204 Geometric.__init__(self, position=position) 

205 BoundaryCondition.__init__(self, *args, **kwargs) 

206 

207 

208class BoundaryConditionCell(BoundaryCondition, Cell): 

209 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs): 

210 Cell.__init__(self, position=position, delta=delta) 

211 BoundaryCondition.__init__(self, *args, **kwargs) 

212 

213 

214class FluidBoundaryCondition(BoundaryCondition): 

215 def __init__( 

216 self, 

217 *args, 

218 rc_objects: RCObjects = initial_rc_objects, 

219 rc_solution: RCSolution = solution_object, 

220 **kwargs: Settings | float | int | np.ndarray, 

221 ): 

222 super().__init__(*args, rc_objects=rc_objects, rc_solution=rc_solution, is_fluid=True, **kwargs) 

223 

224 

225class FluidBoundaryConditionGeometric(FluidBoundaryCondition, Geometric): 

226 def __init__(self, *args, position, **kwargs): 

227 Geometric.__init__(self, position=position) 

228 FluidBoundaryCondition.__init__(self, *args, **kwargs) 

229 

230 

231class FluidBoundaryConditionCell(FluidBoundaryCondition, Cell): 

232 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs): 

233 Cell.__init__(self, position=position, delta=delta) 

234 FluidBoundaryCondition.__init__(self, *args, **kwargs) 

235 

236 

237class InteriorBoundaryCondition(FluidBoundaryCondition): 

238 pass 

239 

240 

241class InteriorBoundaryConditionGeometric(InteriorBoundaryCondition, Geometric): 

242 def __init__(self, *args, position, **kwargs): 

243 Geometric.__init__(self, position=position) 

244 InteriorBoundaryCondition.__init__(self, *args, **kwargs) 

245 

246 

247class InteriorBoundaryConditionCell(InteriorBoundaryCondition, Cell): 

248 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs): 

249 Cell.__init__(self, position=position, delta=delta) 

250 InteriorBoundaryCondition.__init__(self, *args, **kwargs) 

251 

252 

253class ExteriorTemperatureInputMixin: 

254 def __init__( 

255 self, 

256 temperature_delta=None, 

257 middle_temperature=None, 

258 settings: Settings = initial_settings, 

259 ): 

260 """ 

261 Class to calculate the exterior temperature. 

262 

263 The temperature can be returned: 

264 1. static 

265 2. dynamic: sinus curve using ``temperature_delta`` and ``middle_temperature`` 

266 3. dynamic: weather data. The information is in the ``settings`` object. 

267 

268 Parameters 

269 ---------- 

270 temperature_delta : float | int, optional 

271 The amplitude of the sinus curve. 

272 Not used if ``self.settings.use_weather_data is True`` 

273 middle_temperature : float | int, optional 

274 The middle temperature of the sinus curve. 

275 Not used if ``self.settings.use_weather_data is True`` 

276 settings : Settings, optional 

277 A settings object with all important settings. 

278 If not given the initial Settings object is used. 

279 """ 

280 self.settings = settings 

281 if not self.settings.use_weather_data: 

282 assert temperature_delta is not None and middle_temperature is not None 

283 self.temperature_delta = temperature_delta 

284 self.middle_temperature = middle_temperature 

285 

286 def get_kwargs_functions(self) -> dict: 

287 """ 

288 See Input.get_kwargs() 

289 

290 Returns 

291 ------- 

292 dict : 

293 The names of the values that must be passed to calculate_static/dynamic and the function to calculate 

294 this values. 

295 If for example the exterior temperature is needed for calculate_dynamic, the dict looks like this: 

296 {"exterior_temperature": lambda tau: my_fancy_algorithm_to_calculate/get_the_exterior_temperature(tau)} 

297 To use it, this dict must be passed to calculate_dynamic like this: 

298 my_obj.calculate_dynamic(tau, temp_vector, _input_vector, **the_get_kwargs_dict) 

299 """ 

300 if self.settings.calculate_static: 

301 return {"exterior_temperature": self.calculate_static_fun()} 

302 return {"exterior_temperature": self.calculate_dynamic_fun()} 

303 

304 def calculate_static_fun(self) -> Callable: 

305 dynamic_fun = self.calculate_dynamic_fun() 

306 value = dynamic_fun(0) 

307 return lambda *args, **_kwargs: value 

308 

309 def calculate_dynamic_fun(self) -> Callable: 

310 if not self.settings.use_weather_data: 

311 return lambda tau, *args, **kwargs: ( 

312 (self.temperature_delta * -np.cos(np.pi / 43200 * (tau + self.settings.start_shift - 14400))) / 2 

313 + self.middle_temperature 

314 ) 

315 # use weather data 

316 return self.settings.weather_data.get_interpolator("exterior_temperature") 

317 

318 def calculate_dynamic(self, *args, exterior_temperature=None, **kwargs): 

319 return exterior_temperature 

320 

321 

322class ExteriorBoundaryCondition(FluidBoundaryCondition, ExteriorTemperatureInputMixin): 

323 def __init__( 

324 self, 

325 *args, 

326 temperature_delta=None, 

327 middle_temperature=None, 

328 rc_objects: RCObjects = initial_rc_objects, 

329 rc_solution: RCSolution = solution_object, 

330 settings: Settings = initial_settings, 

331 **kwargs: bool | float | int, 

332 ): 

333 FluidBoundaryCondition.__init__( 

334 self, *args, rc_objects=rc_objects, rc_solution=rc_solution, settings=settings, **kwargs 

335 ) 

336 ExteriorTemperatureInputMixin.__init__(self, temperature_delta, middle_temperature, settings=settings) 

337 

338 def get_kwargs_functions(self) -> dict: 

339 return ExteriorTemperatureInputMixin.get_kwargs_functions(self) 

340 

341 def calculate_static(self, *args, **kwargs): 

342 return self.calculate_dynamic(*args, **kwargs) 

343 

344 def calculate_dynamic(self, tau, temp_vector, _input_vector, *args, **kwargs): 

345 # Logic is in ExteriorTemperatureInputMixin.calculate_dynamic_fun. 

346 # In **kwargs there must be a keyword "exterior_temperature" to work correctly 

347 return ExteriorTemperatureInputMixin.calculate_dynamic(self, **kwargs) 

348 

349 

350class ExteriorBoundaryConditionGeometric(ExteriorBoundaryCondition, Geometric): 

351 def __init__(self, *args, position, **kwargs): 

352 Geometric.__init__(self, position=position) 

353 ExteriorBoundaryCondition.__init__(self, *args, **kwargs) 

354 

355 

356class ExteriorBoundaryConditionCell(ExteriorBoundaryCondition, Cell): 

357 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs): 

358 Cell.__init__(self, position=position, delta=delta) 

359 ExteriorBoundaryCondition.__init__(self, *args, **kwargs) 

360 

361 

362class SolidBoundaryCondition(BoundaryCondition): 

363 def __init__( 

364 self, *args, rc_objects: RCObjects = initial_rc_objects, rc_solution: RCSolution = solution_object, **kwargs 

365 ): 

366 super().__init__(*args, rc_objects=rc_objects, rc_solution=rc_solution, is_fluid=False, **kwargs) 

367 

368 

369class SolidBoundaryConditionGeometric(SolidBoundaryCondition, Geometric): 

370 def __init__(self, *args, position, **kwargs): 

371 Geometric.__init__(self, position=position) 

372 SolidBoundaryCondition.__init__(self, *args, **kwargs) 

373 

374 

375class SolidBoundaryConditionCell(SolidBoundaryCondition, Cell): 

376 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs): 

377 Cell.__init__(self, position=position, delta=delta) 

378 SolidBoundaryCondition.__init__(self, *args, **kwargs) 

379 

380 

381class FlowBoundaryCondition(FluidBoundaryCondition, ConnectedFlowObject): 

382 def __init__( 

383 self, 

384 *args, 

385 volume_flow=None, 

386 rc_objects: RCObjects = initial_rc_objects, 

387 rc_solution: RCSolution = solution_object, 

388 **kwargs, 

389 ): 

390 FluidBoundaryCondition.__init__(self, *args, rc_objects=rc_objects, rc_solution=rc_solution, **kwargs) 

391 ConnectedFlowObject.__init__(self) 

392 

393 self.volume_flow = volume_flow 

394 

395 if self.is_mass_flow_start: 

396 self.volume_flow_is_balanced = True 

397 

398 @property 

399 def balance(self): 

400 from pyrc.core.resistors import MassTransport 

401 

402 return calculate_balance_for_resistors(self, [res for res in self.neighbours if isinstance(res, MassTransport)]) 

403 

404 @property 

405 def volume_flow(self): 

406 return super().volume_flow 

407 

408 @volume_flow.setter 

409 def volume_flow(self, value): 

410 self._volume_flow = value 

411 

412 @property 

413 def sinks(self) -> list[MassFlowNode]: 

414 """ 

415 A list with all `MassFlowNode`\s that are sinks of mass flow for `self`\. 

416 

417 Returns 

418 ------- 

419 list[MassFlowNode] 

420 """ 

421 from pyrc.core.resistors import MassTransport 

422 

423 result = [] 

424 if self.is_mass_flow_start: 

425 for neighbour in self.neighbours: 

426 if isinstance(neighbour, MassTransport): 

427 result.append(neighbour.get_connected_node(self)) 

428 return result 

429 

430 @property 

431 def sources(self) -> list[MassFlowNode]: 

432 """ 

433 A list with all `MassFlowNode`\s that are sources of mass flow for `self`\. 

434 

435 Returns 

436 ------- 

437 list[MassFlowNode] 

438 """ 

439 from pyrc.core.resistors import MassTransport 

440 

441 result = [] 

442 if not self.is_mass_flow_start: 

443 for neighbour in self.neighbours: 

444 if isinstance(neighbour, MassTransport): 

445 result.append(neighbour.get_connected_node(self)) 

446 return result 

447 

448 def check_balance(self) -> bool: 

449 """ 

450 If the sum of all sinks and sources of `self` is 0 this method returns True, False otherwise. 

451 

452 Returns 

453 ------- 

454 bool 

455 """ 

456 if self.volume_flow_is_balanced: 

457 return True 

458 # check if start or end 

459 if self.is_mass_flow_start: 

460 self.volume_flow_is_balanced = True 

461 return True 

462 # self is end. So check all connected ConnectedFlowObjects for check_balance 

463 flow_objects = [] 

464 resistor: Resistor 

465 for node in [resistor.get_connected_node(self) for resistor in self.neighbours]: 

466 if isinstance(node, ConnectedFlowObject): 

467 if node in flow_objects: 

468 continue 

469 flow_objects.append(node) 

470 for flow_object in flow_objects: 

471 if flow_object.check_balance(): 

472 continue 

473 else: 

474 return False 

475 return True 

476 

477 def connect(self, *args, **kwargs): 

478 from pyrc.core.resistors import MassTransport 

479 

480 super().connect(*args, **kwargs) 

481 # Prevent the connection to every other Resistor than MassTransport. 

482 assert isinstance(self.neighbours[-1], MassTransport) 

483 

484 

485class FlowBoundaryConditionGeometric(FlowBoundaryCondition, Geometric): 

486 def __init__(self, *args, position, **kwargs): 

487 Geometric.__init__(self, position=position) 

488 FlowBoundaryCondition.__init__(self, *args, **kwargs) 

489 

490 

491class FlowBoundaryConditionCell(FlowBoundaryCondition, Cell): 

492 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs): 

493 Cell.__init__(self, position=position, delta=delta) 

494 FlowBoundaryCondition.__init__(self, *args, **kwargs) 

495 

496 

497class ExteriorInletFlow(FlowBoundaryCondition, ExteriorTemperatureInputMixin): 

498 """ 

499 A boundary condition with constant mass flow and the temperature of `ExteriorTemperatureInput`\. 

500 

501 This is the start of a mass flow. 

502 """ 

503 

504 def __init__( 

505 self, 

506 *args, 

507 volume_flow=None, 

508 temperature_delta=None, 

509 middle_temperature=None, 

510 rc_objects: RCObjects = initial_rc_objects, 

511 rc_solution: RCSolution = solution_object, 

512 settings: Settings = initial_settings, 

513 **kwargs: np.ndarray | bool | float | int, 

514 ): 

515 FlowBoundaryCondition.__init__( 

516 self, 

517 *args, 

518 volume_flow=volume_flow, 

519 rc_objects=rc_objects, 

520 rc_solution=rc_solution, 

521 settings=settings, 

522 **kwargs, 

523 ) 

524 ExteriorTemperatureInputMixin.__init__(self, temperature_delta, middle_temperature, settings=settings) 

525 self.is_mass_flow_start = True 

526 

527 def get_kwargs_functions(self) -> dict: 

528 return ExteriorTemperatureInputMixin.get_kwargs_functions(self) 

529 

530 def calculate_static(self, *args, **kwargs): 

531 return self.calculate_dynamic(*args, **kwargs) 

532 

533 def calculate_dynamic(self, tau, temp_vector, _input_vector, *args, **kwargs): 

534 # Logic is in ExteriorTemperatureInputMixin.calculate_dynamic_fun. 

535 # In **kwargs there must be a keyword "exterior_temperature" to work correctly 

536 return ExteriorTemperatureInputMixin.calculate_dynamic(self, **kwargs) 

537 

538 

539class ExteriorInletFlowGeometric(ExteriorInletFlow, Geometric): 

540 def __init__(self, *args, position, **kwargs): 

541 Geometric.__init__(self, position=position) 

542 ExteriorInletFlow.__init__(self, *args, **kwargs) 

543 

544 

545class ExteriorInletFlowCell(ExteriorInletFlow, Cell): 

546 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs): 

547 Cell.__init__(self, position=position, delta=delta) 

548 ExteriorInletFlow.__init__(self, *args, **kwargs) 

549 

550 

551class ExteriorOutletFlow(FlowBoundaryCondition, ExteriorTemperatureInputMixin): 

552 """ 

553 A boundary condition serving as the end of a mass flow. The `volume_flow` value has no effect. 

554 

555 The temperature is set as `ExteriorTemperatureInput` to easily calculate the heat flux between the connected 

556 `MassFlowNode` and the exterior Temperature. You could redefine this if you want to calculate another 

557 difference. It has no effect for solving the RC network because only `MassTransport` Resistors are allowed as 

558 neighbours and their are always just passing energy INTO this ExteriorOutletFlow boundary condition and never out 

559 of it. 

560 

561 This is the end of a mass flow. 

562 """ 

563 

564 def __init__( 

565 self, 

566 *args, 

567 temperature_delta=None, 

568 middle_temperature=None, 

569 rc_objects: RCObjects = initial_rc_objects, 

570 rc_solution: RCSolution = solution_object, 

571 settings: Settings = initial_settings, 

572 **kwargs: np.ndarray | bool | float | int, 

573 ): 

574 FlowBoundaryCondition.__init__( 

575 self, *args, rc_objects=rc_objects, rc_solution=rc_solution, settings=settings, **kwargs 

576 ) 

577 ExteriorTemperatureInputMixin.__init__(self, temperature_delta, middle_temperature, settings=settings) 

578 self.is_mass_flow_start = False 

579 

580 def get_kwargs_functions(self) -> dict: 

581 return ExteriorTemperatureInputMixin.get_kwargs_functions(self) 

582 

583 def calculate_static(self, *args, **kwargs): 

584 return self.calculate_dynamic(*args, **kwargs) 

585 

586 def calculate_dynamic(self, tau, temp_vector, _input_vector, *args, **kwargs): 

587 # Logic is in ExteriorTemperatureInputMixin.calculate_dynamic_fun. 

588 # In **kwargs there must be a keyword "exterior_temperature" to work correctly 

589 return ExteriorTemperatureInputMixin.calculate_dynamic(self, **kwargs) 

590 

591 

592class ExteriorOutletFlowGeometric(ExteriorOutletFlow, Geometric): 

593 def __init__(self, *args, position, **kwargs): 

594 Geometric.__init__(self, position=position) 

595 ExteriorOutletFlow.__init__(self, *args, **kwargs) 

596 

597 

598class ExteriorOutletFlowCell(ExteriorOutletFlow, Cell): 

599 def __init__(self, *args, position, delta: np.ndarray | tuple = None, **kwargs): 

600 Cell.__init__(self, position=position, delta=delta) 

601 ExteriorOutletFlow.__init__(self, *args, **kwargs) 

602 

603 

604class InternalHeatSource(EquationItem, Input): 

605 def __init__( 

606 self, 

607 node: Node, 

608 specific_power_in_w_per_cubic_meter=None, 

609 specific_power_in_w_per_meter_squared=None, 

610 area_direction: np.ndarray = None, 

611 settings: Settings = initial_settings, 

612 ): 

613 EquationItem.__init__(self) 

614 Input.__init__(self, settings=settings) 

615 self.node: Node = node 

616 self.volume_specific_power = specific_power_in_w_per_cubic_meter # in W/(m**3) 

617 self.__area_specific_power = specific_power_in_w_per_meter_squared # in W/(m**3) 

618 self.symbol = symbols(f"Q_dot_{self.node.id}") 

619 self.area_direction: np.ndarray = area_direction # should be like (1,0,0) in any order and sign 

620 

621 # must be placed after initialization! 

622 if specific_power_in_w_per_cubic_meter is not None and specific_power_in_w_per_meter_squared is not None: 

623 raise ValueError("Do not set both power variables.") 

624 elif specific_power_in_w_per_cubic_meter is None and specific_power_in_w_per_meter_squared is not None: 

625 if area_direction is None: 

626 raise ValueError("If specific_power_in_w_per_meter_squared is set, the direction must be set, too.") 

627 

628 # The calculation of the volume_specific_power is done later because during initialization the volume isn't 

629 # known (because it depends on the connected Nodes that are still created during initialization). 

630 

631 # check, if self.volume_specific_power is None and replace it with 0 if so 

632 if self.volume_specific_power is None and self.__area_specific_power is None: 

633 self.volume_specific_power = np.float64(0) 

634 self._area = None 

635 

636 @property 

637 def initial_value(self): 

638 return self.power 

639 

640 @initial_value.setter 

641 def initial_value(self, value): 

642 self.volume_specific_power = value / self.node.volume 

643 

644 @property 

645 def area(self): 

646 if self._area is None: 

647 self._area = self.node.area(self.area_direction) 

648 return self._area 

649 

650 @property 

651 def index(self) -> int: 

652 if not self._index: 

653 self._index = self.node.rc_objects.inputs.index(self) 

654 return self._index 

655 

656 @property 

657 def power(self): 

658 if self.volume_specific_power is None: 

659 self.set_area_specific_power(self.__area_specific_power) 

660 return self.volume_specific_power * self.node.volume 

661 

662 @property 

663 def area_specific_power(self): 

664 if self.area_direction is None: 

665 return None 

666 return self.power / self.area 

667 

668 def set_area_specific_power(self, area_specific_power_in_w_per_square_meter, direction: np.ndarray = None): 

669 """ 

670 Sets the volume specific power by calculating it with the area specific power and a direction/normal of the 

671 effective surface. 

672 

673 Parameters 

674 ---------- 

675 area_specific_power_in_w_per_square_meter : float | int 

676 The area specific power in W/m**2. 

677 direction : np.ndarray, optional 

678 The normal of the surface where the power is applied to. Should be (1,0,0) with any order and sign. 

679 Is used to get the area of the node using ``self.node.area(direction)``\. 

680 

681 """ 

682 if direction is None: 

683 assert self.area_direction is not None 

684 if self.area_direction is None: 

685 assert isinstance(direction, np.ndarray) and len(direction) == 3, "Direction to face must be passed!" 

686 self.area_direction = direction 

687 else: 

688 if direction is not None: 

689 assert isinstance(direction, np.ndarray) and len(direction) == 3 

690 print("Warning: direction is overwritten.") 

691 self.area_direction = direction 

692 self.volume_specific_power = area_specific_power_in_w_per_square_meter * self.area / self.node.volume 

693 

694 def calculate_static(self, tau, temp_vector, _input_vector, **kwargs): 

695 return self.power 

696 

697 @property 

698 def symbols(self) -> list: 

699 return [self.symbol] 

700 

701 @property 

702 def values(self) -> list: 

703 return [self.power] 

704 

705 

706class Radiation(InternalHeatSource): 

707 def __init__(self, *args, epsilon_short=0.7, epsilon_long=0.93, **kwargs): 

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

709 self.epsilon_short = epsilon_short 

710 self.epsilon_long = epsilon_long 

711 self.epsilon_long_boltzmann = self.epsilon_long * Stefan_Boltzmann # pre-calculation of calculate_dynamic 

712 self._bc_temp_input_index = None 

713 

714 @property 

715 def bc_temp_input_index(self): 

716 if self._bc_temp_input_index is None: 

717 for fbc in self.node.get_connected_nodes(variant=FluidBoundaryCondition): 

718 # TODO: If multiple BoundaryConditions are connected, only the last one is used for temp. calculation 

719 # If one capacitor has multiple BCs connected the calculation won't work as it is implemented currently 

720 # with calculate_dynamic_functions if no weather data is used. 

721 fbc: FluidBoundaryCondition 

722 self._bc_temp_input_index = fbc.index 

723 assert self._bc_temp_input_index is not None 

724 return self._bc_temp_input_index 

725 

726 def get_kwargs_functions(self) -> dict: 

727 if self.settings.calculate_static: 

728 return self.calculate_static_functions() 

729 return self.calculate_dynamic_functions() 

730 

731 def calculate_static_functions(self) -> dict: 

732 dyn_functions: dict = self.calculate_dynamic_functions() 

733 result = {} 

734 for name, fun in dyn_functions.items(): 

735 result[name] = ( 

736 lambda f: lambda tau, temp_vec, input_vec, *args, **kwargs: f(0, temp_vec, input_vec, *args, **kwargs) 

737 )(fun) 

738 return result 

739 

740 def calculate_dynamic_functions(self) -> dict: 

741 """ 

742 This function is used to pre-calculate some values during solving that are node independent. 

743 

744 This way calculation time can be decreased. 

745 

746 Returns 

747 ------- 

748 dict : 

749 A dictionary where the key stands for the parameter that is passed to the function calculating node 

750 dependent stuff and the value is its value (crazy, I know). 

751 """ 

752 result = {} 

753 

754 if self.settings.use_weather_data: 

755 result["incoming_area_specific_power_long"] = self.settings.area_specific_radiation_interpolator_long 

756 result["incoming_area_specific_power_short"] = self.settings.area_specific_radiation_interpolator_short 

757 else: 

758 dt = datetime.fromisoformat(self.settings.start_date) 

759 seconds_today = dt.hour * 3600 + dt.minute * 60 + dt.second + dt.microsecond / 1e6 

760 result["incoming_area_specific_power_short"] = lambda tau, *args, **kwargs: ( 

761 sin_function(tau, seconds_today) * self.settings.maximum_area_specific_power 

762 ) 

763 result["incoming_area_specific_power_long"] = lambda tau, *args, **kwargs: 0 

764 

765 # the following only works if only one boundary condition is used for all Radiation objects ( 

766 # e.g. the ExteriorTemperature and all Radiation objects are influenced by the same. If multiple 

767 # influences between Radiation objects and BCs would exists, the index is changing, depending on the 

768 # Radiation object. For that, calculate the value directly in each object (increases calculation overhead) 

769 # or create a unique kwarg for each of them (recommended with new classes)) 

770 index = self.bc_temp_input_index 

771 result["sky_temp_4_diff"] = lambda tau, temp_vector, _input_vector, *args, _index=index, **kwargs: ( 

772 sky_temp_according_to_swinbank(_input_vector.ravel()[_index]) ** 4 

773 - sky_temp_according_to_swinbank(_input_vector.ravel()[_index]) ** 4 

774 ) 

775 return result 

776 

777 def calculate_static(self, tau, temp_vector, _input_vector, **kwargs): 

778 return self.calculate_dynamic(0, temp_vector, _input_vector, **kwargs) 

779 

780 def calculate_dynamic( 

781 self, 

782 tau, 

783 temp_vector, 

784 _input_vector, 

785 incoming_area_specific_power_short=np.nan, 

786 incoming_area_specific_power_long=np.nan, 

787 sky_temp_4_diff=np.nan, 

788 **kwargs, 

789 ): 

790 temp_vector_flat = temp_vector.ravel() 

791 

792 node_temp_4 = temp_vector_flat[self.node.index] ** 4 

793 

794 if self.settings.use_weather_data: 

795 outgoing_spec_power = self.epsilon_long_boltzmann * node_temp_4 # in W/m^2 

796 else: 

797 # here incoming_area_specific_power only contains the direct radiation. So the radiation towards sky and 

798 # ambient has to be calculated, too. 

799 # TODO: Use the sight factor of the wall for both sky and bc/ambient (but this information is not accessible 

800 # here) 

801 outgoing_spec_power = 0.5 * self.epsilon_long_boltzmann * (2 * node_temp_4 - sky_temp_4_diff) 

802 power = ( 

803 incoming_area_specific_power_long * self.epsilon_long 

804 + incoming_area_specific_power_short * self.epsilon_short 

805 - outgoing_spec_power 

806 ) * self.area 

807 self.volume_specific_power = power / self.node.volume 

808 return power 

809 

810 

811def sky_temp_according_to_swinbank(air_temperature: float): 

812 return 0.0552 * air_temperature ** 1.5 

813 

814 

815def sin_function(tau, shift_start_time): 

816 return np.maximum(0.0, -np.cos(np.pi / 43200 * (tau + shift_start_time))) 

817 

818 

819# must be at the end of this module 

820# This checks if for every BoundaryCondition also a similar class exists that inherits from Geometry / Cell (both separately) 

821_module = sys.modules[__name__] 

822for _counterpart in _bc_missing_counterparts: 

823 if not hasattr(_module, _counterpart): 

824 import warnings 

825 

826 warnings.warn(f"Missing counterpart {_counterpart}", stacklevel=2)