Coverage for pyrc\core\components\resistor.py: 84%

158 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 _warnings import warn 

11from typing import TYPE_CHECKING, Any 

12 

13import numpy as np 

14from sympy import Expr, symbols 

15 

16from pyrc.core.components.templates import ObjectWithPorts, SymbolMixin 

17from pyrc.tools.functions import contains_symbol 

18from pyrc.tools.science import round_valid 

19 

20if TYPE_CHECKING: 

21 from pyrc.core.components.capacitor import Capacitor 

22 from pyrc.core.components.node import TemperatureNode 

23 

24 

25class Resistor(ObjectWithPorts, SymbolMixin): 

26 resistor_counter = 0 

27 

28 def __init__(self, resistance: float | int | Expr = np.nan): 

29 """ 

30 (Thermal) resistor element of an RC network. 

31 

32 This is the base class of all Resistors and heat and mass transfer/transport elements calculating the 

33 resistance by their own. 

34 

35 Parameters 

36 ---------- 

37 resistance : float | int | Expr 

38 The (thermal) resistance of the `Resistor` in K/W. 

39 """ 

40 ObjectWithPorts.__init__(self) 

41 if not contains_symbol(resistance): 

42 resistance = np.float64(resistance) 

43 self._resistance = resistance 

44 Resistor.resistor_counter += 1 

45 self.id = Resistor.resistor_counter 

46 self.__direct_connected_node_templates = None 

47 

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

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

50 self.resistance_symbol = symbols(f"R_{self.id}") 

51 self._equivalent_resistance_symbol = None 

52 

53 @classmethod 

54 def reset_counter(cls): 

55 Resistor.resistor_counter = 0 

56 

57 def __str__(self): 

58 return self.__repr__() 

59 

60 def __repr__(self): 

61 if isinstance(self._resistance, Expr): 

62 return f"{self.__class__.__name__} {self.id}: R={str(self._resistance)}" 

63 return f"{self.__class__.__name__} {self.id}: 1/R={round_valid(1 / self._resistance, 3)}" 

64 

65 @property 

66 def nodes(self): 

67 """ 

68 Returns both connected nodes, no matter, if other resistors are between them. 

69 

70 Returns 

71 ------- 

72 list : 

73 The Nodes in a list. 

74 """ 

75 result = [] 

76 seen = {self} 

77 to_check = self.neighbours.copy() 

78 

79 from pyrc.core.components.node import TemperatureNode 

80 

81 while to_check and len(result) < 2: 

82 current = to_check.pop() 

83 if current in seen: 

84 continue 

85 seen.add(current) 

86 

87 if isinstance(current, TemperatureNode): 

88 result.append(current) 

89 else: 

90 to_check.extend(n for n in current.neighbours if n not in seen) 

91 assert len(result) <= 2, ( 

92 "Each Resistor should only be connected two a maximum of 2 TemperatureNodes.\nIf it is " 

93 "connected to more than 2 (also over other Resistors) you must insert a Node." 

94 ) 

95 return sorted(result, key=lambda node: node.id) 

96 

97 @property 

98 def direct_connected_node_templates(self): 

99 if self.__direct_connected_node_templates is None: 

100 from pyrc.core.components.node import TemperatureNode 

101 

102 self.__direct_connected_node_templates = [ 

103 neighbour for neighbour in self.neighbours if isinstance(neighbour, TemperatureNode) 

104 ] 

105 return self.__direct_connected_node_templates 

106 

107 @property 

108 def symbols(self) -> list: 

109 # return [self.resistance_symbol, self.equivalent_resistance_symbol] 

110 return [self.equivalent_resistance_symbol] 

111 

112 @property 

113 def values(self) -> list: 

114 # return [self.resistance, self.equivalent_resistance] 

115 return [self.equivalent_resistance] 

116 

117 @property 

118 def all_resistors_inbetween(self) -> list[Resistor]: 

119 """ 

120 Returns all resistors between both connected `TemperatureNode`\\s including self. 

121 

122 Returns 

123 ------- 

124 list[Resistor] : 

125 All Resistors that form the equivalent resistor between both connected `TemperatureNode`\\s. 

126 """ 

127 seen = {self} 

128 to_check = self.neighbours.copy() 

129 while to_check: 

130 current = to_check.pop() 

131 if current not in seen and isinstance(current, Resistor): 

132 seen.add(current) 

133 to_check.extend([n for n in current.neighbours if n not in seen]) 

134 return sorted(list(seen), key=lambda res: res.id) 

135 

136 @property 

137 def equivalent_resistance(self) -> np.float64 | float | int | Expr: 

138 """ 

139 Returns the equivalent resistance from one node to the other. 

140 

141 This considers both serial and parallel Resistors between the same Nodes. 

142 

143 Returns 

144 ------- 

145 np.float64 : 

146 The equivalent resistance from one node to the other. 

147 """ 

148 nodes = self.direct_connected_node_templates 

149 assert len(nodes) > 0, ( 

150 "This property is meant to be executed only for resistors that are connected to one TemperatureNode." 

151 ) 

152 if len(nodes) == 2: 

153 return self.resistance 

154 

155 def run_through_resistors(start_resistor: Resistor, start_node: TemperatureNode): 

156 _equivalent_serial = start_resistor.resistance 

157 _parallel_resistors = [ 

158 neighbour for neighbour in start_resistor.neighbours if isinstance(neighbour, Resistor) 

159 ] 

160 _current = start_resistor 

161 while len(_parallel_resistors) == 1: 

162 _equivalent_serial += _parallel_resistors[0].resistance 

163 _parallel_resistors, _current = ( 

164 [ 

165 neighbour 

166 for neighbour in _parallel_resistors[0].neighbours 

167 if isinstance(neighbour, Resistor) 

168 and neighbour is not _current 

169 and neighbour.get_connected_node(_parallel_resistors[0]) is not start_node 

170 ], 

171 _parallel_resistors[0], 

172 ) 

173 return _equivalent_serial, _parallel_resistors, _current 

174 

175 # calculate the equivalent resistance 

176 equivalent_serial, parallel_resistors, current = run_through_resistors(self, nodes[0]) 

177 if len(parallel_resistors) > 1: 

178 equivalent_parallel = parallel_equivalent_resistance(current) 

179 else: 

180 # Doing the same thing just the other way round. 

181 # parallel resistors list is empty because the Resistor "current" at the end (right before a Node) 

182 # now you need to check if there are parallel Resistors if walking in the other direction 

183 equivalent_serial, parallel_resistors, current = run_through_resistors( 

184 current, current.direct_connected_node_templates[0] 

185 ) 

186 if len(parallel_resistors) > 1: 

187 equivalent_parallel = parallel_equivalent_resistance(current) 

188 else: 

189 # parallel_resistors is empty 

190 equivalent_parallel = 0 

191 

192 # serial resistance between both equivalents 

193 return equivalent_parallel + equivalent_serial 

194 

195 @property 

196 def equivalent_resistance_symbol(self): 

197 if self._equivalent_resistance_symbol is None: 

198 resistors = self.all_resistors_inbetween 

199 symbol = symbols("_".join([f"{r}" for r in ["R_eq", *[res.id for res in resistors]]])) 

200 

201 # set this symbol to all other resistors (including self) 

202 for r in resistors: 

203 r._equivalent_resistance_symbol = symbol 

204 return self._equivalent_resistance_symbol 

205 

206 # def get_equivalent_resistors(self) -> list: 

207 # """ 

208 # Returns a list with all resistors between the two nodes self is connected with. 

209 # 

210 # Returns 

211 # ------- 

212 # list : 

213 # """ 

214 # node1, node2 = self.nodes 

215 # result = set() 

216 # 

217 # neighbour: Resistor 

218 # for neighbour in node1.neighbours: 

219 # if neighbour.get_connected_node(node1) == node2: 

220 # result.add(neighbour) 

221 # connected_resistors 

222 # 

223 # def get_connected_resistors(self, include_self=False) -> list: 

224 # """ 

225 # Returns a list with all resistors that are connected to this resistor or the connected ones. 

226 # 

227 # Parameters 

228 # ---------- 

229 # include_self : bool, optional 

230 # If True, self is included in the result. 

231 # 

232 # Returns 

233 # ------- 

234 # list : 

235 # All connected resistors. 

236 # """ 

237 # result = [] 

238 # visited = set() 

239 # to_check = [neighbour for neighbour in self.neighbours if isinstance(neighbour, Resistor)] 

240 # 

241 # for resistor in to_check: 

242 # visited.add(resistor) 

243 # 

244 # while to_check: 

245 # resistor = to_check.pop() 

246 # result.append(resistor) 

247 # for neighbour in resistor.neighbours: 

248 # if isinstance(neighbour, Resistor) and neighbour not in visited: 

249 # visited.add(neighbour) 

250 # to_check.append(neighbour) 

251 # 

252 # if include_self: 

253 # result.append(self) 

254 # 

255 # return result 

256 

257 @property 

258 def resistance(self) -> float | int | Expr: 

259 return self._resistance 

260 

261 def heat_flux(self, asking_node: Capacitor) -> float: 

262 """ 

263 Returns the accumulated heat flux of this node that is transferred into the ``asking_node``. 

264 

265 Parameters 

266 ---------- 

267 asking_node : Capacitor 

268 The Node asking for the heat flux. 

269 If the result is positive that heat flows into the asking_node. 

270 

271 Returns 

272 ------- 

273 float : 

274 The heat flux flowing into the asking_node in W. 

275 """ 

276 if self.neighbours[0] == asking_node: 

277 other_node = self.neighbours[1] 

278 else: 

279 other_node = self.neighbours[0] 

280 other_node: Capacitor 

281 

282 return 1 / self.resistance * (other_node.temperature - asking_node.temperature) 

283 

284 def connect(self, neighbour, direction: tuple | list | np.ndarray | Any = None, node_direction_points_to=None): 

285 """ 

286 Overloading for further checking and warning. 

287 

288 Parameters 

289 ---------- 

290 neighbour 

291 direction 

292 node_direction_points_to 

293 """ 

294 from pyrc.core.components.templates import Cell 

295 

296 if direction is not None and node_direction_points_to is None and len(self.nodes) == 1: 

297 node_direction_points_to = self.nodes[0] 

298 

299 super().connect(neighbour, direction, node_direction_points_to) 

300 # check, if direction is set manually if BoundaryCondition and Cell are involved 

301 

302 if len(self.nodes) == 2 and isinstance(neighbour, Cell): 

303 from pyrc.core.nodes import BoundaryCondition 

304 

305 for i, resistor_neighbour in enumerate(self.nodes): 

306 if isinstance(resistor_neighbour, BoundaryCondition) and not isinstance(resistor_neighbour, Cell): 

307 # check, if manual_directions of other resistor neighbour was set, otherwise the cells automatics 

308 # are broken 

309 if isinstance(self.nodes[(i + 1) % 2], Cell): 

310 if resistor_neighbour not in self.nodes[(i + 1) % 2].manual_directions: 

311 raise ValueError("Direction must be set manually if BoundaryCondition is involved.") 

312 

313 def get_connected(self, asking_node: ObjectWithPorts) -> list[ObjectWithPorts]: 

314 """ 

315 Returns the neighbour that isn't the asking one. 

316 

317 Parameters 

318 ---------- 

319 asking_node : ObjectWithPorts 

320 The asking Node. The method will return the other neighbour of ``self``. 

321 

322 Returns 

323 ------- 

324 TemperatureNode : 

325 The other neighbour of ``self``. 

326 """ 

327 assert asking_node in self.neighbours, "The asking_node is not connected to self." 

328 return [n for n in self.neighbours if n is not asking_node] 

329 

330 def get_connected_node(self, asking_node: TemperatureNode | Resistor) -> TemperatureNode: 

331 """ 

332 Returns the connected TemperatureNode that isn't the asking one. It is routed through all connected Resistors to 

333 the TemperatureNode. 

334 

335 Parameters 

336 ---------- 

337 asking_node : TemperatureNode | Resistor 

338 The asking Node. The method will return the other node connected (over other `Resistor`\\s) to self. 

339 

340 Returns 

341 ------- 

342 TemperatureNode : 

343 The other node connected to ``self``. 

344 """ 

345 assert asking_node in self.neighbours, "The asking_node is not connected to self." 

346 if isinstance(asking_node, Resistor) and len(self.neighbours) > 2: 

347 warn("It is not defined, which node is returned. The first one found is returned.") 

348 seen_nodes = {asking_node, self} 

349 to_check = [n for n in self.neighbours if n is not asking_node] 

350 

351 from pyrc.core.components.node import TemperatureNode 

352 

353 while to_check: 

354 current = to_check.pop() 

355 if current in seen_nodes: 

356 continue 

357 seen_nodes.add(current) 

358 

359 if isinstance(current, TemperatureNode): 

360 return current 

361 

362 to_check.extend(n for n in current.neighbours if n not in seen_nodes) 

363 raise ValueError(f"No connected TemperatureNode found: {self}") 

364 

365 

366def parallel_equivalent_resistance(start_resistor: Resistor, ignore_resistor: Resistor = None) -> np.float64: 

367 """ 

368 Calculates the equivalent resistance of the connected Resistors. 

369 

370 It also checks if the connected Resistors are connected to other Resistors in serial and consider this, too. 

371 

372 This only works, if no other parallel resistors occur. But this should be okay, because this doesn't make any sense 

373 thermo-physically. 

374 

375 Parameters 

376 ---------- 

377 start_resistor : Resistor 

378 The start Resistor, where in one "direction" parallel resistors are connected to (can also be just one). 

379 ignore_resistor : Resistor, optional 

380 If given this Resistor is excluded from the neighbours list of start_resistor. 

381 This is needed to determine some kind of "direction" and only calculate the equivalent resistance for the 

382 Resistors that are actually parallel. 

383 

384 Returns 

385 ------- 

386 np.float64 : 

387 The equivalent resistance of the connected Resistors without the resistance of the start_resistor. 

388 """ 

389 parallel_resistors = [neighbour for neighbour in start_resistor.neighbours if isinstance(neighbour, Resistor)] 

390 if ignore_resistor is not None: 

391 try: 

392 parallel_resistors.remove(ignore_resistor) 

393 except ValueError: 

394 # ignore_resistor not in neighbours of start_resistor 

395 pass 

396 parallel_resistance_list = [] 

397 for resistor in parallel_resistors: 

398 parallel_resistance = resistor.resistance 

399 current = resistor 

400 connected: list = current.get_connected(start_resistor) 

401 assert len(connected) == 1, "Currently in parallel circuits no other nested parallel circuits are allowed." 

402 connected = connected[0] 

403 while isinstance(connected, Resistor): 

404 assert len(connected.neighbours) == 2, ( 

405 "No series of parallel Resistors are allowed. Just one Resistor " 

406 "that is connected to multiple others in parallel\n" 

407 "which themselves can be serial connected to others, but not parallel!" 

408 ) 

409 parallel_resistance += connected.resistance 

410 connected, current = connected.get_connected(current), connected 

411 assert len(connected) == 1, "Currently in parallel circuits no other nested parallel circuits are allowed." 

412 connected = connected[0] 

413 parallel_resistance_list.append(parallel_resistance) 

414 return 1 / np.float64(sum([1 / np.float64(r) for r in parallel_resistance_list]))