Coverage for pyrc\core\components\capacitor.py: 54%

182 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 

10from typing import TYPE_CHECKING, Any 

11 

12import numpy as np 

13from sympy import Expr, symbols 

14 

15from pyrc.core.components.node import TemperatureNode 

16from pyrc.core.components.templates import ( 

17 Cell, 

18 ConnectedFlowObject, 

19 initial_rc_objects, 

20 solution_object, 

21) 

22 

23if TYPE_CHECKING: 

24 from pyrc.core.components.resistor import Resistor 

25 from pyrc.core.components.templates import RCObjects, RCSolution 

26 from pyrc.core.inputs import InternalHeatSource 

27 from pyrc.core.nodes import MassFlowNode, Node 

28 from pyrc.core.resistors import MassTransport 

29 

30 

31class Capacitor(TemperatureNode): 

32 def __init__( 

33 self, 

34 capacity: float, 

35 temperature, 

36 rc_objects: RCObjects = initial_rc_objects, 

37 temperature_derivative=0, 

38 internal_heat_source: InternalHeatSource = None, 

39 rc_solution: RCSolution = solution_object, 

40 ): 

41 """ 

42 Capacitor building part, currently designed as thermal capacitor. 

43 

44 Parameters 

45 ---------- 

46 capacity : float | int | Expr 

47 The capacity of the capacitor. 

48 temperature : float | int 

49 The temperature of the node. 

50 temperature_derivative : float | int 

51 The temperature derivative of the node. 

52 """ 

53 super().__init__( 

54 temperature=temperature, 

55 rc_objects=rc_objects, 

56 temperature_derivative=temperature_derivative, 

57 rc_solution=rc_solution, 

58 ) 

59 if type(self) is Capacitor and (capacity is None or capacity == np.nan or capacity == 0): 

60 # subclasses are allowed to set capacity to None (see Node) 

61 raise TypeError("Capacitor requires 'capacity'") 

62 self.__capacity: float | int | Expr = capacity 

63 

64 self.__internal_heat_source: InternalHeatSource | Any = internal_heat_source 

65 

66 # Cashing 

67 self.__connected_mass_flow_nodes = None 

68 

69 # Create sympy symbols to put in the equation(s) 

70 # Has to be at the end of init (after self.id) 

71 self.capacity_symbol = symbols(f"C_{self.id}") 

72 

73 @property 

74 def capacity(self) -> float | int | Expr: 

75 return self.__capacity 

76 

77 @capacity.setter 

78 def capacity(self, value: float | int | Expr): 

79 self.__capacity = value 

80 

81 @property 

82 def index(self): 

83 if not self._index: 

84 self._index = self.rc_objects.nodes.index(self) 

85 return self._index 

86 

87 @property 

88 def internal_heat_source(self) -> InternalHeatSource: 

89 return self.__internal_heat_source 

90 

91 def make_internal_heat_source(self, heat_source_type, **kwargs): 

92 self.__internal_heat_source = heat_source_type(node=self, **kwargs) 

93 

94 def add_internal_heat_source(self, heat_source: InternalHeatSource): 

95 assert heat_source.node == self 

96 self.__internal_heat_source = heat_source 

97 

98 @property 

99 def resistors_without_parallel(self): 

100 """ 

101 Returns every resistor on the node that isn't connected to the same as another. 

102 

103 If multiple resistors are in self.neighbours and are connected to the same node the first one is taken (more 

104 or less random). 

105 

106 Returns 

107 ------- 

108 

109 """ 

110 resistors: Resistor = self.neighbours 

111 seen_nodes = set() 

112 result = [] 

113 for resistor in resistors: 

114 connected_node = resistor.get_connected_node(self) 

115 if connected_node not in seen_nodes: 

116 seen_nodes.add(connected_node) 

117 result.append(resistor) 

118 return result 

119 

120 @property 

121 def symbols(self) -> list: 

122 """ 

123 Returns a list of all sympy.symbols of the object, except time dependent symbols. 

124 

125 Must be in the same order as self.values. 

126 

127 Returns 

128 ------- 

129 list : 

130 The list of sympy.symbols. 

131 """ 

132 result = [*super().symbols, self.capacity_symbol] 

133 return result 

134 

135 @property 

136 def values(self) -> list: 

137 """ 

138 Returns a list of all values of all object symbols, except of time dependent symbols. 

139 

140 Must be in the same order as self.symbols. 

141 

142 Returns 

143 ------- 

144 list : 

145 The list of sympy.symbols. 

146 """ 

147 result = [*super().values, self.capacity] 

148 return result 

149 

150 @property 

151 def values_without_capacity(self) -> list: 

152 """ 

153 Returns a list of all values of all object symbols expect the capacity and the time dependent symbols. 

154 

155 This is used in some subclasses that calculate their capacity on their own. 

156 

157 Must be in the same order as self.symbols. 

158 

159 Returns 

160 ------- 

161 list : 

162 The list of values. 

163 """ 

164 result = super().values 

165 return result 

166 

167 @property 

168 def symbols_without_capacity(self) -> list: 

169 """ 

170 Returns a list of all object symbols expect the capacity and the time dependent symbols. 

171 

172 This is used in some subclasses that calculate their capacity on their own. 

173 

174 Must be in the same order as self.symbols. 

175 

176 Returns 

177 ------- 

178 list : 

179 The list of sympy.symbols. 

180 """ 

181 result = super().symbols 

182 return result 

183 

184 @property 

185 def mass_flow_connections(self) -> int: 

186 """ 

187 Returns the number of `MassFlowNode` s connected to ``self``. 

188 

189 Returns 

190 ------- 

191 int : 

192 The number of connected `MassFlowNode` s. 

193 """ 

194 return len(self.connected_mass_flow_nodes) 

195 

196 @property 

197 def connected_mass_flow_nodes(self) -> list[MassFlowNode]: 

198 if self.__connected_mass_flow_nodes is None: 

199 self.__connected_mass_flow_nodes = self.get_connected_mass_flow_nodes() 

200 return self.__connected_mass_flow_nodes 

201 

202 def temperature_derivative_term(self) -> tuple: 

203 """ 

204 Create the ``sympy`` expression for the temperature derivative (right side of heat flux balance equation). 

205 

206 The sign convention is: 

207 Heat flux pointing into the node is positive. 

208 

209 Returns 

210 ------- 

211 tuple[sympy expression, set, set] : 

212 The term and a set with all involved temperature symbols. 

213 """ 

214 from pyrc.core.resistors import MassTransport 

215 

216 result_equation = 0 

217 temperature_symbols = set() 

218 all_symbols = set() 

219 

220 # loop over ports/connections 

221 resistor: Resistor 

222 for resistor in self.filter_resistors_equivalent(): 

223 connected_node: TemperatureNode = resistor.get_connected_node(self) 

224 rc_term = 1 / (resistor.equivalent_resistance_symbol * self.capacity_symbol) 

225 if isinstance(resistor, MassTransport): 

226 if (resistor.source == self and resistor.volume_flow >= 0) or ( 

227 resistor.sink == self and resistor.volume_flow < 0 

228 ): 

229 result_equation -= rc_term * self.temperature_symbol 

230 else: 

231 # self is sink 

232 result_equation += rc_term * connected_node.temperature_symbol 

233 temperature_symbols.add(connected_node.temperature_symbol) 

234 else: 

235 # resistor is a resistor, so we have to get the other node connected to it. 

236 result_equation += rc_term * (connected_node.temperature_symbol - self.temperature_symbol) 

237 temperature_symbols.add(connected_node.temperature_symbol) 

238 temperature_symbols.add(self.temperature_symbol) 

239 

240 # add internal heat source terms 

241 if self.internal_heat_source: 

242 result_equation += self.internal_heat_source.symbol / self.capacity_symbol 

243 all_symbols.add(self.internal_heat_source.symbol) 

244 

245 all_symbols.update(temperature_symbols) 

246 return result_equation, temperature_symbols, all_symbols 

247 

248 def get_mass_transport_to_node(self, target_node: ConnectedFlowObject): 

249 """ 

250 Returns the `ConnectedFlowObject` `Resistor` lying inbetween self and target_node. 

251 

252 Parameters 

253 ---------- 

254 target_node : ConnectedFlowObject 

255 The `ConnectedFlowObject` to which the MassTransport Resistor is requested for. 

256 

257 Returns 

258 ------- 

259 MassTransport : 

260 The MassTransport Resistor inbetween self and target_node. 

261 """ 

262 for resistor in self.get_connected_mass_transport_resistors(): 

263 if resistor.get_connected_node(self) == target_node: 

264 return resistor 

265 return None 

266 

267 def reset_properties(self): 

268 self.__connected_mass_flow_nodes = None 

269 

270 def get_neighbours(self, variant: type = None) -> list: 

271 """ 

272 Returns a list of connected objects with given variant. 

273 

274 If variant is ``None``, all objects are returned. 

275 

276 Parameters 

277 ---------- 

278 variant : None | type 

279 The type (of `Resistors`) to return. 

280 Example: ``variant=MassTransport`` only returns all `MassTransport` resistors. 

281 

282 Returns 

283 ------- 

284 list : 

285 A list with all requested objects. 

286 """ 

287 if variant is None: 

288 return self.neighbours 

289 else: 

290 result = [] 

291 for resistor in self.neighbours: 

292 if isinstance(resistor, variant): 

293 result.append(resistor) 

294 return result 

295 

296 def get_connected_mass_transport_resistors(self, except_this: MassTransport | list = None) -> list: 

297 """ 

298 Returns a list of connected `MassTransport` resistors except the given ones. 

299 

300 Parameters 

301 ---------- 

302 except_this : MassTransport | list 

303 The given `MassTransport` resistors to exclude from the result. 

304 

305 Returns 

306 ------- 

307 list : 

308 The list of connected `MassTransport` resistors without all given ones. 

309 """ 

310 from pyrc.core.resistors import MassTransport 

311 

312 result = self.get_neighbours(variant=MassTransport) 

313 if isinstance(except_this, MassTransport): 

314 except_this = [except_this] 

315 

316 if except_this: 

317 for except_resistor in except_this: 

318 if except_resistor in result: 

319 result.remove(except_resistor) 

320 

321 return result 

322 

323 @property 

324 def connected_mass_transport_resistors(self) -> list: 

325 return self.get_connected_mass_transport_resistors() 

326 

327 def get_connected_nodes(self, variant: type = None) -> list: 

328 """ 

329 Returns a list of by `Resistor` s connected `TemperatureNode` s. 

330 

331 Duplicates of connected nodes (due to multiple resistors between two unique nodes) are not returned twice. 

332 

333 Parameters 

334 ---------- 

335 variant : None | type 

336 The type (of `TemperatureNode`) to return. 

337 If ``None``, all connected nodes are returned. 

338 

339 Returns 

340 ------- 

341 list : 

342 The list of connected `TemperatureNode` s. 

343 """ 

344 result = [] 

345 

346 if variant is None: 

347 neighbour: Resistor 

348 for neighbour in self.neighbours: 

349 connected: list = neighbour.get_connected(self) 

350 for c in connected: 

351 if c not in result: 

352 result.append(c) 

353 else: 

354 neighbour: Resistor 

355 for neighbour in self.neighbours: 

356 connected: list = neighbour.get_connected(self) 

357 for c in connected: 

358 if isinstance(c, variant) and c not in result: 

359 result.append(c) 

360 return result 

361 

362 def get_connected_mass_flow_nodes(self) -> list[MassFlowNode]: 

363 """ 

364 Returns a list of connected `MassFlowNode` s. 

365 

366 Returns 

367 ------- 

368 list : 

369 The list of connected `MassFlowNode` s. 

370 """ 

371 from pyrc.core.nodes import MassFlowNode 

372 

373 return self.get_connected_nodes(MassFlowNode) 

374 

375 def get_next_air_nodes(self, asking_node: Capacitor) -> list[MassFlowNode]: 

376 """ 

377 Returns the next air nodes as a list. 

378 

379 Parameters 

380 ---------- 

381 asking_node : Capacitor 

382 The Node that asks for the connected `MassFlowNode` s. 

383 

384 Returns 

385 ------- 

386 list[MassFlowNode] : 

387 The connected `MassFlowNode` s of self but without ``asking_node``. 

388 """ 

389 result = [] 

390 

391 for node in self.connected_mass_flow_nodes: 

392 if not node == asking_node: 

393 result.append(node) 

394 return result 

395 

396 def resistors_in_direction( 

397 self, direction: np.ndarray | str, except_resistor_types: list[type] = None 

398 ) -> list[Resistor]: 

399 """ 

400 Returns all `Resistor` s connected to nodes in the given direction. 

401 

402 Parameters 

403 ---------- 

404 direction : np.ndarray | str 

405 The direction to get the `Resistor` s from. 

406 If an array, it has to be parallel to the coordinate axes. 

407 If a string, it should be of the following: 

408 +x,+y,+z,-x,-y,-z,x,y,z 

409 except_resistor_types : list, optional 

410 If not None, these `Resistor` types will not be added to the result. 

411 

412 Returns 

413 ------- 

414 list[Resistor] : 

415 The `Resistor` s in the requested direction. 

416 """ 

417 result = [] 

418 if except_resistor_types is None: 

419 except_resistor_types = [] 

420 if not isinstance(except_resistor_types, list): 

421 except_resistor_types = [except_resistor_types] 

422 

423 if isinstance(direction, str): 

424 if len(direction) == 1 or direction[0] == "+": 

425 sign = 1 

426 else: 

427 sign = -1 

428 match direction[-1].lower(): 

429 case "x": 

430 direction = sign * np.ndarray((1, 0, 0)) 

431 case "y": 

432 direction = sign * np.ndarray((0, 1, 0)) 

433 case "z": 

434 direction = sign * np.ndarray((0, 0, 1)) 

435 case _: 

436 raise ValueError("Invalid direction string. This algorithm only works for rectangular meshes.") 

437 

438 neighbour_resistors = [ 

439 r for r in self.neighbours if not any(isinstance(r, r_type) for r_type in except_resistor_types) 

440 ] 

441 for resistor in neighbour_resistors: 

442 other_node: Capacitor = resistor.get_connected_node(self) 

443 neighbour_dir = self.get_direction(other_node) 

444 if np.allclose((neighbour_dir - direction), 0): 

445 result.append(resistor) 

446 return result 

447 

448 def resistors_in_direction_filtered( 

449 self, direction: np.ndarray | str, except_resistor_types: list = None 

450 ) -> list[Resistor]: 

451 """ 

452 Like resistors_in_direction but only one resistor is returned for parallel resistors. 

453 

454 Parameters 

455 ---------- 

456 direction : np.ndarray | str 

457 The direction to get the `Resistor` s from. 

458 If an array, it has to be parallel to the coordinate axes. 

459 If a string, it should be of the following: 

460 +x,+y,+z,-x,-y,-z,x,y,z 

461 except_resistor_types : list, optional 

462 If not None, these `Resistor` types will not be added to the result. 

463 

464 Returns 

465 ------- 

466 list[Resistor] : 

467 The `Resistor` s in the requested direction. 

468 """ 

469 resistors = self.resistors_in_direction(direction, except_resistor_types) 

470 return self.filter_resistors_equivalent(resistors) 

471 

472 def get_direction(self, asking_node: Cell | Capacitor) -> np.array: 

473 """ 

474 Returns the direction (x,y,z direction) to the asking node. Is returned as normalized vector. 

475 

476 Parameters 

477 ---------- 

478 asking_node : Cell 

479 The Node asking for the direction to it. 

480 

481 Returns 

482 ------- 

483 np.ndarray : 

484 The direction from ``self`` to the asking node. 

485 """ 

486 

487 # compare the boundaries to get the matching one and determine its direction 

488 if asking_node in self.manual_directions: 

489 return np.array(self.manual_directions[asking_node]) 

490 assert isinstance(asking_node, Cell), "Directions have to be set manually if the asking_node is no Cell." 

491 self: Node 

492 asking_boundaries = asking_node.boundaries 

493 self_boundaries = self.boundaries 

494 

495 diff = [] 

496 for i, element in enumerate(asking_boundaries): 

497 unit = -1 + 2 * ((i + 1) % 2) # switch that is -1 if i is odd and +1 if i is even. 

498 diff.append(element - self_boundaries[i + unit]) 

499 

500 directions = np.array([[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]) 

501 

502 # Take the difference that is closest to 0 (without floating point error it will exactly has one 0 in it). 

503 return directions[np.argmin(np.abs(diff))]