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

175 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 

10from typing import TYPE_CHECKING, Any 

11 

12import numpy as np 

13from sympy import symbols 

14 

15from pyrc.core.components.node import TemperatureNode 

16from pyrc.core.components.templates import ( 

17 initial_rc_objects, 

18 solution_object, 

19 ConnectedFlowObject, 

20 Cell, 

21) 

22 

23if TYPE_CHECKING: 

24 from pyrc.core.components.resistor import Resistor 

25 from pyrc.core.nodes import Node 

26 from pyrc.core.resistors import MassTransport 

27 from pyrc.core.inputs import InternalHeatSource 

28 from pyrc.core.nodes import MassFlowNode 

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

30 

31 

32class Capacitor(TemperatureNode): 

33 def __init__( 

34 self, 

35 capacity: float, 

36 temperature, 

37 rc_objects: RCObjects = initial_rc_objects, 

38 temperature_derivative=0, 

39 internal_heat_source: InternalHeatSource = None, 

40 rc_solution: RCSolution = solution_object, 

41 ): 

42 """ 

43 Capacitor building part, currently designed as thermal capacitor. 

44 

45 Parameters 

46 ---------- 

47 capacity : float 

48 The capacity of the capacitor. 

49 temperature : float | int 

50 The temperature of the node. 

51 temperature_derivative : float | int 

52 The temperature derivative of the node. 

53 """ 

54 super().__init__( 

55 temperature=temperature, 

56 rc_objects=rc_objects, 

57 temperature_derivative=temperature_derivative, 

58 rc_solution=rc_solution, 

59 ) 

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

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

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

63 self._capacity = capacity 

64 

65 self.__internal_heat_source: InternalHeatSource | Any = internal_heat_source 

66 

67 # Cashing 

68 self.__connected_mass_flow_nodes = None 

69 

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

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

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

73 

74 @property 

75 def index(self): 

76 if not self._index: 

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

78 return self._index 

79 

80 @property 

81 def internal_heat_source(self) -> InternalHeatSource: 

82 return self.__internal_heat_source 

83 

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

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

86 

87 def add_internal_heat_source(self, heat_source: InternalHeatSource): 

88 assert heat_source.node == self 

89 self.__internal_heat_source = heat_source 

90 

91 @property 

92 def resistors_without_parallel(self): 

93 """ 

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

95 

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

97 or less random). 

98 

99 Returns 

100 ------- 

101 

102 """ 

103 resistors: Resistor = self.neighbours 

104 seen_nodes = set() 

105 result = [] 

106 for resistor in resistors: 

107 connected_node = resistor.get_connected_node(self) 

108 if connected_node not in seen_nodes: 

109 seen_nodes.add(connected_node) 

110 result.append(resistor) 

111 return result 

112 

113 @property 

114 def symbols(self) -> list: 

115 """ 

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

117 

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

119 

120 Returns 

121 ------- 

122 list : 

123 The list of sympy.symbols. 

124 """ 

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

126 if self.internal_heat_source: 

127 result.append(self.internal_heat_source.symbol) 

128 return result 

129 

130 @property 

131 def values(self) -> list: 

132 """ 

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

134 

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

136 

137 Returns 

138 ------- 

139 list : 

140 The list of sympy.symbols. 

141 """ 

142 result = [*self.values_without_capacity, self._capacity] 

143 return result 

144 

145 @property 

146 def values_without_capacity(self) -> list: 

147 """ 

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

149 

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

151 

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

153 

154 Returns 

155 ------- 

156 list : 

157 The list of sympy.symbols. 

158 """ 

159 result = super().values 

160 if self.internal_heat_source: 

161 result.append(self.internal_heat_source.power) 

162 return result 

163 

164 @property 

165 def mass_flow_connections(self) -> int: 

166 """ 

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

168 

169 Returns 

170 ------- 

171 int : 

172 The number of connected `MassFlowNode` s. 

173 """ 

174 return len(self.connected_mass_flow_nodes) 

175 

176 @property 

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

178 if self.__connected_mass_flow_nodes is None: 

179 self.__connected_mass_flow_nodes = self.get_connected_mass_flow_nodes() 

180 return self.__connected_mass_flow_nodes 

181 

182 def temperature_derivative_term(self) -> tuple: 

183 """ 

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

185 

186 The sign convention is: 

187 Heat flux pointing into the node is positive. 

188 

189 Returns 

190 ------- 

191 tuple[sympy expression, set] : 

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

193 """ 

194 from pyrc.core.resistors import MassTransport 

195 

196 result_equation = 0 

197 temperature_symbols = set() 

198 all_symbols = set() 

199 

200 # loop over ports/connections 

201 resistor: Resistor 

202 for resistor in self.filter_resistors_equivalent(): 

203 connected_node: TemperatureNode = resistor.get_connected_node(self) 

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

205 if isinstance(resistor, MassTransport): 

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

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

208 ): 

209 result_equation -= rc_term * self.temperature_symbol 

210 else: 

211 # self is sink 

212 result_equation += rc_term * connected_node.temperature_symbol 

213 temperature_symbols.add(connected_node.temperature_symbol) 

214 else: 

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

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

217 temperature_symbols.add(connected_node.temperature_symbol) 

218 temperature_symbols.add(self.temperature_symbol) 

219 

220 # add internal heat source terms 

221 if self.internal_heat_source: 

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

223 all_symbols.add(self.internal_heat_source.symbol) 

224 

225 all_symbols.update(temperature_symbols) 

226 return result_equation, temperature_symbols, all_symbols 

227 

228 def get_mass_transport_to_node(self, target_node: ConnectedFlowObject): 

229 """ 

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

231 

232 Parameters 

233 ---------- 

234 target_node : ConnectedFlowObject 

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

236 

237 Returns 

238 ------- 

239 MassTransport : 

240 The MassTransport Resistor inbetween self and target_node. 

241 """ 

242 for resistor in self.get_connected_mass_transport_resistors(): 

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

244 return resistor 

245 

246 def reset_properties(self): 

247 self.__connected_mass_flow_nodes = None 

248 

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

250 """ 

251 Returns a list of connected objects with given variant. 

252 

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

254 

255 Parameters 

256 ---------- 

257 variant : None | type 

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

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

260 

261 Returns 

262 ------- 

263 list : 

264 A list with all requested objects. 

265 """ 

266 if variant is None: 

267 return self.neighbours 

268 else: 

269 result = [] 

270 for resistor in self.neighbours: 

271 if isinstance(resistor, variant): 

272 result.append(resistor) 

273 return result 

274 

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

276 """ 

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

278 

279 Parameters 

280 ---------- 

281 except_this : MassTransport | list 

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

283 

284 Returns 

285 ------- 

286 list : 

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

288 """ 

289 from pyrc.core.resistors import MassTransport 

290 

291 result = self.get_neighbours(variant=MassTransport) 

292 if isinstance(except_this, MassTransport): 

293 except_this = [except_this] 

294 

295 if except_this: 

296 for except_resistor in except_this: 

297 if except_resistor in result: 

298 result.remove(except_resistor) 

299 

300 return result 

301 

302 @property 

303 def connected_mass_transport_resistors(self) -> list: 

304 return self.get_connected_mass_transport_resistors() 

305 

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

307 """ 

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

309 

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

311 

312 Parameters 

313 ---------- 

314 variant : None | type 

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

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

317 

318 Returns 

319 ------- 

320 list : 

321 The list of connected `TemperatureNode` s. 

322 """ 

323 result = [] 

324 

325 if variant is None: 

326 neighbour: Resistor 

327 for neighbour in self.neighbours: 

328 connected: list = neighbour.get_connected(self) 

329 for c in connected: 

330 if c not in result: 

331 result.append(c) 

332 else: 

333 neighbour: Resistor 

334 for neighbour in self.neighbours: 

335 connected: list = neighbour.get_connected(self) 

336 for c in connected: 

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

338 result.append(c) 

339 return result 

340 

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

342 """ 

343 Returns a list of connected `MassFlowNode` s. 

344 

345 Returns 

346 ------- 

347 list : 

348 The list of connected `MassFlowNode` s. 

349 """ 

350 from pyrc.core.nodes import MassFlowNode 

351 

352 return self.get_connected_nodes(MassFlowNode) 

353 

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

355 """ 

356 Returns the next air nodes as a list. 

357 

358 Parameters 

359 ---------- 

360 asking_node : Capacitor 

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

362 

363 Returns 

364 ------- 

365 list[MassFlowNode] : 

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

367 """ 

368 result = [] 

369 

370 for node in self.connected_mass_flow_nodes: 

371 if not node == asking_node: 

372 result.append(node) 

373 return result 

374 

375 def resistors_in_direction( 

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

377 ) -> list[Resistor]: 

378 """ 

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

380 

381 Parameters 

382 ---------- 

383 direction : np.ndarray | str 

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

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

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

387 +x,+y,+z,-x,-y,-z,x,y,z 

388 except_resistor_types : list, optional 

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

390 

391 Returns 

392 ------- 

393 list[Resistor] : 

394 The `Resistor` s in the requested direction. 

395 """ 

396 result = [] 

397 if except_resistor_types is None: 

398 except_resistor_types = [] 

399 if not isinstance(except_resistor_types, list): 

400 except_resistor_types = [except_resistor_types] 

401 

402 if isinstance(direction, str): 

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

404 sign = 1 

405 else: 

406 sign = -1 

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

408 case "x": 

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

410 case "y": 

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

412 case "z": 

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

414 case _: 

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

416 

417 neighbour_resistors = [ 

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

419 ] 

420 for resistor in neighbour_resistors: 

421 other_node: Capacitor = resistor.get_connected_node(self) 

422 neighbour_dir = self.get_direction(other_node) 

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

424 result.append(resistor) 

425 return result 

426 

427 def resistors_in_direction_filtered( 

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

429 ) -> list[Resistor]: 

430 """ 

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

432 

433 Parameters 

434 ---------- 

435 direction : np.ndarray | str 

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

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

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

439 +x,+y,+z,-x,-y,-z,x,y,z 

440 except_resistor_types : list, optional 

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

442 

443 Returns 

444 ------- 

445 list[Resistor] : 

446 The `Resistor` s in the requested direction. 

447 """ 

448 resistors = self.resistors_in_direction(direction, except_resistor_types) 

449 return self.filter_resistors_equivalent(resistors) 

450 

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

452 """ 

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

454 

455 Parameters 

456 ---------- 

457 asking_node : Cell 

458 The Node asking for the direction to it. 

459 

460 Returns 

461 ------- 

462 np.ndarray : 

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

464 """ 

465 

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

467 if asking_node in self.manual_directions: 

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

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

470 self: Node 

471 asking_boundaries = asking_node.boundaries 

472 self_boundaries = self.boundaries 

473 

474 diff = [] 

475 for i, element in enumerate(asking_boundaries): 

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

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

478 

479 directions = np.array([ 

480 [1, 0, 0], [-1, 0, 0], 

481 [0, 1, 0], [0, -1, 0], 

482 [0, 0, 1], [0, 0, -1] 

483 ]) 

484 

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

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