Coverage for pyrc\core\simulation.py: 12%

172 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 

8import multiprocessing as mp 

9import os 

10import time 

11from copy import copy 

12from typing import Any, Callable, Iterable 

13 

14from pyrc.core.components.templates import EquationItem, RCObjects, RCSolution 

15from pyrc.core.network import RCNetwork 

16from pyrc.core.settings import Settings 

17from pyrc.core.settings import initial_settings as i_settings 

18from pyrc.tools.functions import add_leading_underscore, subtract_seconds_from_string 

19 

20 

21class Simulation: 

22 def __init__( 

23 self, 

24 network_class: type[RCNetwork] | None = None, 

25 network_keyword_arguments: dict[str, Any] | None = None, 

26 pre_calculation_seconds: int | float = 36000, 

27 t_span: tuple | None = None, 

28 name_add_on: str = "", 

29 settings: Settings = None, 

30 pre_calculation_settings: Settings = None, 

31 print_progress: bool = True, 

32 time_dependent_tuple: tuple[Iterable, Callable] | None = None, 

33 time_dependent_tuple_input: tuple[Iterable, Callable] | None = None, 

34 ) -> None: 

35 """ 

36 Handle one RC network simulation including pre-simulation. 

37 

38 The pre-simulation determines correct initial values for the network by calculating a time range before the real 

39 simulation. The pre-simulation can be done with varying weather data or just static simulation of the initial 

40 boundary conditions of the network. However, because of the dynamic values of all capacities it is 

41 recommended to use the option with varying (realistic) boundary data. 

42 

43 The pre-simulation is saved as single initial values that can be loaded in. It is executed only once and then 

44 loaded in from file. 

45 

46 All parameters can be also set in the run method. However, when you don't want to pass them in each run() 

47 call you can initialize them directly and they will be used (if not overwritten). 

48 

49 Parameters 

50 ---------- 

51 network_class : type[RCNetwork] | None, optional 

52 The RCNetwork class that is used to create an object of it and run the simulation. 

53 This is needed because when using parallelization the RCNetwork cannot be pickled to other CPU cores (on 

54 Windows). Instead, first the network is build on each core. 

55 network_keyword_arguments : dict[str, Any] | None, optional 

56 The keyword arguments for the RCNetwork object that is created. 

57 pre_calculation_seconds : int | float, default=36000 

58 The length of the pre-simulation in seconds. 

59 t_span : tuple | None, optional 

60 The simulation start and end time tuple for the scipy.solve_ivp() in seconds. 

61 name_add_on : str, default="" 

62 settings 

63 pre_calculation_settings 

64 print_progress : bool, default=True 

65 time_dependent_tuple : tuple[Iterable | Callable] | None 

66 A ordered list with the time dependent symbols and the function that calculates their values. 

67 The list represents the order of the output of the function. 

68 The function calculates the value of the time dependent symbols in the order of the list. It gets passed 

69 the time step, temperature vector and input vector (last one only if existing): 

70 value1, value2, ... = my_function(time_step, temperature_vector, input_vector) 

71 """ 

72 self.network_class = network_class 

73 self.network_keyword_arguments: dict[str, Any] | None = network_keyword_arguments 

74 self.pre_calculation_seconds: int | float = pre_calculation_seconds 

75 self.t_span: tuple | None = t_span 

76 self.name_add_on: str = name_add_on 

77 self.settings: Settings | None = settings 

78 self.pre_calculation_settings: Settings | None = pre_calculation_settings 

79 self.print_progress: bool = print_progress 

80 self.time_dependent_tuple: tuple[Iterable, Callable] | None = time_dependent_tuple 

81 self.time_dependent_tuple_input: tuple[Iterable, Callable] | None = time_dependent_tuple_input 

82 

83 self.network = None 

84 

85 def run( 

86 self, 

87 network_class: type[RCNetwork] | None = None, 

88 network_keyword_arguments: dict[str, Any] | None = None, 

89 t_span: tuple | None = None, 

90 name_add_on: str = "", 

91 settings: Settings | None = None, 

92 pre_calculation_settings: Settings | None = None, 

93 print_progress: bool | None = None, 

94 time_dependent_tuple: tuple[Iterable, Callable] | None = None, 

95 time_dependent_tuple_input: tuple[Iterable, Callable] | None = None, 

96 ): 

97 """ 

98 Run the simulation including pre-simulation with the passed network type. 

99 

100 Parameters 

101 ---------- 

102 network_class : type[RCNetwork] | None, optional 

103 The RCNetwork that is created for the simulation. 

104 network_keyword_arguments : dict[str, Any] | None, optional 

105 The keyword arguments for the RCNetwork object that is created. 

106 t_span : tuple | None, optional 

107 The t_span for the simulation: (start, end) in seconds. 

108 It should start at 0 (otherwise it can work but it's not tested). 

109 name_add_on : str, optional 

110 An add-on for the name to identify the name to the worker. 

111 If None, a random five digits integer is used (with leading zeros). 

112 settings : Settings | None, optional 

113 The settings for the network and simulation. 

114 If None, the initial settings dict from the network is used. 

115 pre_calculation_settings : Settings | None, optional 

116 The settings for the pre-calculation. Should only vary in the weather data start date. 

117 If None, the initial settings dict from the network is used, but with static calculation. 

118 print_progress : bool | None, optional 

119 Whether to print some progress information during the simulation. 

120 time_dependent_tuple : tuple[Iterable | Callable] | None, optional 

121 A ordered list with the time dependent symbols and the function that calculates their values. 

122 The list represents the order of the output of the function. 

123 The function calculates the value of the time dependent symbols in the order of the list. It gets passed 

124 the time step, temperature vector and input vector (last one only if existing): 

125 value1, value2, ... = my_function(time_step, temperature_vector, input_vector) 

126 """ 

127 self.network = self._worker( 

128 network_class=network_class or self.network_class, 

129 network_copy_dict=network_keyword_arguments or self.network_keyword_arguments or {}, 

130 pre_calculation_seconds=self.pre_calculation_seconds, 

131 t_span_simulation=t_span or self.t_span, 

132 name_add_on=name_add_on or self.name_add_on, 

133 settings=settings or self.settings, 

134 pre_calculation_settings=pre_calculation_settings or self.pre_calculation_settings, 

135 print_progress=print_progress if print_progress is not None else self.print_progress, 

136 return_network=True, 

137 time_dependent_tuple=time_dependent_tuple or self.time_dependent_tuple, 

138 time_dependent_tuple_input=time_dependent_tuple_input or self.time_dependent_tuple_input, 

139 ) 

140 

141 @staticmethod 

142 def _worker( 

143 network_class: type[RCNetwork], 

144 network_copy_dict: dict, 

145 pre_calculation_seconds: int | float, 

146 t_span_simulation: tuple, 

147 name_add_on: str = "", 

148 settings: Settings | None = None, 

149 pre_calculation_settings: Settings | None = None, 

150 print_progress: bool = True, 

151 return_network: bool = False, 

152 time_dependent_tuple: tuple[Iterable, Callable] | None = None, 

153 time_dependent_tuple_input: tuple[Iterable, Callable] | None = None, 

154 ) -> RCNetwork | None: 

155 """ 

156 Runs a single simulation. 

157 

158 Remember: The network shouldn't exist / built yet because it's not pickable. The network is created newly and if 

159 the matrices are already created they are loaded from file. 

160 Not only the unpickable state of the network forces the creation of the network within this method but also the 

161 network dependency on both rc_objects and rc_solution objects, if the initial objects are used. 

162 

163 Parameters 

164 ---------- 

165 network_class : type(RCNetwork) 

166 The RCNetwork that is created for the simulation. 

167 network_copy_dict : dict 

168 A dictionary to give RCNetwork as keyword arguments for initializing. 

169 This bypasses to move the unpickable RCNetwork object to the worker and instead creates it inside the 

170 worker. 

171 pre_calculation_seconds : int | float 

172 See main class. 

173 t_span_simulation : tuple 

174 The t_span for the simulation: (start, end) in seconds. 

175 It should start at 0 (otherwise it can work but it's not tested). 

176 name_add_on : str, default="" 

177 An add-on for the name to identify the name to the worker. 

178 settings : Settings, optional 

179 The settings for the network and simulation. 

180 If None, the initial settings dict from the network is used. 

181 pre_calculation_settings : Settings, optional 

182 The settings for the pre-calculation. Should only vary in the weather data start date. 

183 If None, the initial settings dict from the network is used, but with static calculation or a shifted 

184 weather start date, if use_weather_data. 

185 time_dependent_tuple : tuple[Iterable | Callable] | None, optional 

186 A ordered list with the time dependent symbols and the function that calculates their values. 

187 The list represents the order of the output of the function. 

188 The function calculates the value of the time dependent symbols in the order of the list. It gets passed 

189 the time step, temperature vector and input vector (last one only if existing): 

190 value1, value2, ... = my_function(time_step, temperature_vector, input_vector) 

191 """ 

192 # Just to be safe: Create new RCObjects and RCSolution instances that are not linked to other simulations. 

193 rc_objects = RCObjects() 

194 network_copy_dict.update( 

195 { 

196 "rc_objects": rc_objects, 

197 "rc_solution": RCSolution(rc_objects=rc_objects), 

198 } 

199 ) 

200 network: RCNetwork = network_class(**network_copy_dict) 

201 if settings is not None: 

202 network.settings = copy(settings) 

203 else: 

204 settings = copy(network.settings) 

205 network.create_network() 

206 

207 name_add_on = add_leading_underscore(name_add_on) 

208 

209 name_prefix = os.path.join(network.settings.save_folder_path, f"{network.hash}{name_add_on}") 

210 single_solution_name = f"{name_prefix}_{pre_calculation_seconds}_single_solution.pickle" 

211 

212 use_time_dependent_system = False 

213 time_dependent_symbols = None 

214 time_dependent_function = None 

215 if time_dependent_tuple is not None: 

216 use_time_dependent_system = True 

217 time_dependent_symbols = time_dependent_tuple[0] 

218 time_dependent_function = time_dependent_tuple[1] 

219 use_time_dependent_input = False 

220 time_dependent_symbols_input = None 

221 time_dependent_function_input = None 

222 if time_dependent_tuple_input is not None: 

223 use_time_dependent_input = True 

224 time_dependent_symbols_input = time_dependent_tuple_input[0] 

225 time_dependent_function_input = time_dependent_tuple_input[1] 

226 

227 if not network.load_initial_values(return_bool=True, pickle_path_single_solution=single_solution_name): 

228 print(f"{network.hash}: starting pre-calculation") 

229 

230 static_time_dependent_function: Callable = time_dependent_function 

231 static_time_dependent_function_input: Callable = time_dependent_function_input 

232 if pre_calculation_settings is not None: 

233 network.settings = pre_calculation_settings 

234 else: 

235 if network.settings.use_weather_data: 

236 original_date = network.settings.start_date 

237 network.settings.start_date = subtract_seconds_from_string(original_date, pre_calculation_seconds) 

238 network.settings.calculate_static = False 

239 print(f"Dynamic pre-calculation with weather start date: {network.settings.start_date}") 

240 else: 

241 print("Static pre-calculation.") 

242 network.settings.calculate_static = True 

243 if network.settings.calculate_static and use_time_dependent_system: 

244 # always use the first value for the static calculation 

245 def static_time_dependent_function(t, temp_vector, *args, **kwargs): 

246 return time_dependent_function(0, temp_vector, *args, **kwargs) 

247 

248 if network.settings.calculate_static and use_time_dependent_input: 

249 

250 def static_time_dependent_function_input(t, temp_vector, *args, **kwargs): 

251 return time_dependent_function_input(0, temp_vector, *args, **kwargs) 

252 

253 if self.use_analytic_pre_calculation: 

254 network.solve_stationary() 

255 else: 

256 t_span = (0, pre_calculation_seconds) 

257 network.solve_network( 

258 t_span, 

259 print_progress=print_progress, 

260 name_add_on=name_add_on + "_pre_calculation", 

261 time_dependent_tuple=(time_dependent_symbols, static_time_dependent_function), 

262 time_dependent_tuple_input=(time_dependent_symbols_input, static_time_dependent_function_input), 

263 ) 

264 

265 # save last solution to load it back in later 

266 network.rc_solution.save_last_step(single_solution_name) 

267 

268 # delete all static solutions in the solutions object and free space for dynamic solution 

269 network.rc_solution.delete_solutions(confirm=True) 

270 network.reset_properties() 

271 

272 assert network.load_initial_values(return_bool=True, pickle_path_single_solution=single_solution_name) 

273 network.settings = settings # change back to original settings 

274 print(f"{network.hash}: pre-calculation done.") 

275 else: 

276 print(f"{network.hash}: pre-calculation was loaded from file.") 

277 

278 t_span = t_span_simulation 

279 network.solve_network( 

280 t_span, 

281 print_progress=print_progress, 

282 name_add_on=name_add_on, 

283 time_dependent_tuple=time_dependent_tuple, 

284 time_dependent_tuple_input=time_dependent_tuple_input, 

285 ) 

286 # result is saved in network.solve_network so it doesn't need to be saved in here. 

287 # file_path = f"{name_prefix}_result.pickle" 

288 # network.rc_solution.save_solution(file_path) 

289 

290 if return_network: 

291 return network 

292 return None 

293 

294 

295class Parameterization(Simulation): 

296 """ 

297 Class to handle `RCNetwork` calculations that are quite similar but differ in one settings parameter. 

298 

299 All calculations are run in parallel. 

300 """ 

301 

302 def __init__( 

303 self, 

304 parameters_tuples: list[tuple], 

305 pre_calculation_seconds=36000, 

306 initial_settings_dict: dict = i_settings, 

307 max_core_number=0, 

308 t_span=None, 

309 ): 

310 """ 

311 

312 Parameters 

313 ---------- 

314 parameters_tuples : list[tuple] 

315 Defining the parameters for each simulation: 

316 for parameters in parameters_tuples: 

317 network_type: type = parameters[0] 

318 settings_dict = parameters[1] 

319 network_parameters = parameters[2] 

320 t_span = parameters[3] 

321 name_add_on = parameters[4] 

322 pre_calculation_seconds : int | float, default=36000 

323 How long the static calculation before the dynamic calculation should be. 

324 initial_settings_dict : dict, default=i_settings 

325 The initial settings to use for the calculations. If not given, the initial ones from ``core.settings`` is 

326 used. 

327 max_core_number : int, default=0 

328 An optional limit how many cores can be used for the calculations. 

329 0 for no limit. 

330 """ 

331 super().__init__(pre_calculation_seconds=pre_calculation_seconds, settings=initial_settings_dict) 

332 self.parameters_tuples: list[tuple] = parameters_tuples 

333 

334 if t_span is None: 

335 t_span = (0, 8760 * 3600) 

336 self.t_span = t_span 

337 

338 if max_core_number <= 0: 

339 max_core_number = mp.cpu_count() 

340 self.max_core_number: int = min(max_core_number, mp.cpu_count()) 

341 

342 def get_parameters(self, parameters: tuple): 

343 """ 

344 Returns the first few parameters for the worker method. 

345 

346 Parameters 

347 ---------- 

348 parameters : tuple 

349 The tuple out of self.parameters_tuples 

350 

351 Returns 

352 ------- 

353 tuple : 

354 The parameters as tuple. 

355 """ 

356 network_type: type = parameters[0] 

357 settings_dict = parameters[1] 

358 network_parameters = parameters[2] 

359 t_span = parameters[3] 

360 name_add_on = parameters[4] 

361 

362 settings = Settings(**settings_dict) 

363 network_parameters.update( 

364 { 

365 "settings": settings, 

366 "load_from_pickle": True, 

367 "save_to_pickle": True, 

368 "num_cores_jacobian": 1, 

369 "rc_objects": RCObjects(), 

370 "rc_solution": RCSolution(), 

371 } 

372 ) 

373 

374 pre_settings_dict = settings_dict.copy() 

375 try: 

376 original_date = settings_dict["start_date"] 

377 except KeyError: 

378 original_date = "2022-01-01T00:00:00" 

379 pre_settings_dict.update( 

380 {"start_date": subtract_seconds_from_string(original_date, self.pre_calculation_seconds)} 

381 ) 

382 pre_settings = Settings(**pre_settings_dict) 

383 

384 return ( 

385 network_type, 

386 network_parameters, 

387 self.pre_calculation_seconds, 

388 t_span, 

389 name_add_on, 

390 settings, 

391 pre_settings, 

392 False, 

393 ) 

394 

395 def pre_create_jacobians(self): 

396 """ 

397 Pre-creates the jacobian matrices of all RCNetworks if not already found in pickle file. 

398 

399 This is useful to quickly generate all jacobian matrices outside the workers so that they only need to load 

400 the matrices and lambdify them. 

401 

402 Returns 

403 ------- 

404 

405 """ 

406 for parameters in self.parameters_tuples: 

407 # Reset class attribute from Equation item to get the same hash values for equal networks 

408 EquationItem.item_counter = 0 

409 params = self.get_parameters(parameters) 

410 network_type, network_parameters = params[:2] 

411 

412 network_parameters = network_parameters.copy() 

413 network_parameters.update( 

414 { 

415 "load_from_pickle": True, 

416 "save_to_pickle": True, 

417 "num_cores_jacobian": self.max_core_number, 

418 "rc_objects": RCObjects(), 

419 "rc_solution": RCSolution(), 

420 } 

421 ) 

422 # {"load_from_pickle": True, "save_to_pickle": True, "num_cores_jacobian": 1}) # for debugging 

423 network: RCNetwork = network_type(**network_parameters) 

424 

425 network.create_network() 

426 network.make_system_matrices() 

427 print(f"{params[5]}: Jacobi pre-calculation done for hash: {network.hash}") 

428 

429 def run(self): 

430 # first create the jacobian matrices for all networks using all cores 

431 self.pre_create_jacobians() 

432 

433 if self.max_core_number == 1: 

434 print("Using single core calculation.") 

435 for parameters in self.parameters_tuples: 

436 worker_parameters = self.get_parameters(parameters) 

437 # check name_add_on 

438 if worker_parameters[5] == "" or worker_parameters[5] is None: 

439 worker_parameters = (*worker_parameters[:5], f"1", *worker_parameters[6:]) 

440 self._worker(*worker_parameters) 

441 print(f"Starting process: {worker_parameters[5]}") 

442 else: 

443 # create single processes that run the parameter simulations 

444 active = [] 

445 process_infos = [] 

446 process_id = 0 

447 for parameters in self.parameters_tuples: 

448 # Wait until a slot is free 

449 while len(active) >= self.max_core_number: 

450 skip_waiting = False 

451 for idx, p in enumerate(active): 

452 if not p.is_alive(): 

453 p.join() 

454 proc_id, name_addon, p_name = process_infos[idx] 

455 print(f"Process {proc_id} with name AddOn '{name_addon}' finished. Proc name {p_name}") 

456 skip_waiting = True 

457 if not skip_waiting: 

458 time.sleep(0.1) # small delay to avoid busy-waiting 

459 mask = [p.is_alive() for p in active] 

460 active = [p for p, alive in zip(active, mask) if alive] 

461 process_infos = [info for info, alive in zip(process_infos, mask) if alive] 

462 

463 worker_parameters = self.get_parameters(parameters) 

464 # check name_add_on 

465 if worker_parameters[5] == "" or worker_parameters[5] is None: 

466 worker_parameters = (*worker_parameters[:5], f"{process_id}", *worker_parameters[6:]) 

467 process = mp.Process(target=self._worker, args=worker_parameters) 

468 print(f"Starting process {process_id} - name AddOn: {worker_parameters[5]} - Proc name {process.name}") 

469 process.start() 

470 active.append(process) 

471 process_infos.append((process_id, worker_parameters[5], process.name)) 

472 process_id += 1 

473 

474 while active: 

475 skip_waiting = False 

476 for idx, p in enumerate(active): 

477 if not p.is_alive(): 

478 p.join() 

479 proc_id, name_addon, p_name = process_infos[idx] 

480 print(f"Process {proc_id} (name: {p_name}) with name AddOn '{name_addon}' finished.") 

481 skip_waiting = True 

482 if not skip_waiting: 

483 time.sleep(0.1) 

484 active_infos = [(p, info) for p, info in zip(active, process_infos) if p.is_alive()] 

485 active, process_infos = zip(*active_infos) if active_infos else ([], []) 

486 

487 print("All processes finished.")