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

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 typing import TYPE_CHECKING 

12 

13import numpy as np 

14from scipy.constants import Stefan_Boltzmann 

15from sympy import symbols 

16 

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) 

30 

31if TYPE_CHECKING: 

32 from sympy import Expr 

33 

34 from pyrc import Capacitor 

35 from pyrc.core.components.resistor import Resistor 

36 from pyrc.core.nodes import MassFlowNode, Node 

37 

38_bc_missing_counterparts: list[str] = [] 

39 

40 

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. 

43 

44 

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. 

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

78 

79 @property 

80 def heat_transfer_coefficient(self): 

81 return self.htc 

82 

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`\\. 

86 

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". 

90 

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

92 

93 Parameters 

94 ---------- 

95 kwargs 

96 

97 Returns 

98 ------- 

99 

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) 

107 

108 @property 

109 def is_solid(self): 

110 return not self._is_fluid 

111 

112 @property 

113 def is_fluid(self): 

114 return self._is_fluid 

115 

116 def __str__(self): 

117 return self.__repr__() 

118 

119 def __repr__(self): 

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

121 

122 @property 

123 def index(self) -> int: 

124 """ 

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

126 

127 The value is cached to improve performance. 

128 

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 

137 

138 @property 

139 def initial_value(self): 

140 return self.initial_temperature 

141 

142 @initial_value.setter 

143 def initial_value(self, value): 

144 self.initial_temperature = value 

145 

146 @property 

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

148 """ 

149 The temperature of `self`\\. 

150 

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

152 

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) 

160 

161 @property 

162 def temperature_vector(self): 

163 """ 

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

165 

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

167 

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] 

176 

177 

178class BoundaryConditionGeometric(BoundaryCondition, Geometric): 

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

180 Geometric.__init__(self, position=position) 

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

182 

183 

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) 

188 

189 

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 """ 

199 

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) 

210 

211 

212class FluidBoundaryConditionGeometric(FluidBoundaryCondition, Geometric): 

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

214 Geometric.__init__(self, position=position) 

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

216 

217 

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) 

222 

223 

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) 

229 

230 

231class SolidBoundaryConditionGeometric(SolidBoundaryCondition, Geometric): 

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

233 Geometric.__init__(self, position=position) 

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

235 

236 

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) 

241 

242 

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 """ 

254 

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) 

269 

270 self.volume_flow = volume_flow 

271 self.is_mass_flow_start: bool = is_mass_flow_start 

272 

273 if self.is_mass_flow_start: 

274 self.volume_flow_is_balanced = True 

275 

276 @property 

277 def balance(self): 

278 from pyrc.core.resistors import MassTransport 

279 

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

281 

282 @property 

283 def volume_flow(self): 

284 return super().volume_flow 

285 

286 @volume_flow.setter 

287 def volume_flow(self, value): 

288 self._volume_flow = value 

289 

290 @property 

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

292 """ 

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

294 

295 Returns 

296 ------- 

297 list[MassFlowNode] 

298 """ 

299 from pyrc.core.resistors import MassTransport 

300 

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 

307 

308 @property 

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

310 """ 

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

312 

313 Returns 

314 ------- 

315 list[MassFlowNode] 

316 """ 

317 from pyrc.core.resistors import MassTransport 

318 

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 

325 

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. 

329 

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 

354 

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

356 from pyrc.core.resistors import MassTransport 

357 

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

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

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

361 

362 

363class FlowBoundaryConditionGeometric(FlowBoundaryCondition, Geometric): 

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

365 Geometric.__init__(self, position=position) 

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

367 

368 

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) 

373 

374 

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). 

386 

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 

410 

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." 

427 

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). 

430 

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 

435 

436 @property 

437 def initial_value(self) -> float | int | Expr: 

438 return self.power 

439 

440 @initial_value.setter 

441 def initial_value(self, value): 

442 """ 

443 Set the initial power value. 

444 

445 Parameters 

446 ---------- 

447 value : float | int | Expr 

448 The initial power value in Watts. 

449 """ 

450 self.__power = value 

451 

452 def no_node(self): 

453 """ 

454 Checks, if self.node is not of class Node and warns if so. 

455 

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 

465 

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 

475 

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 

481 

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 

496 

497 @property 

498 def area_specific_power(self): 

499 if self.area_direction is None: 

500 return None 

501 return self.power / self.area 

502 

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. 

509 

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)``\\. 

517 

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 

531 

532 @property 

533 def symbols(self) -> list: 

534 return [self.symbol] 

535 

536 @property 

537 def values(self) -> list: 

538 return [self.power] 

539 

540 

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 

548 

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 

560 

561 

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 

568 

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