flextool 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. flextool-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,703 @@
1
+ """Recursive solve structure builder — rolling/nested solve expansion.
2
+
3
+ Architecture notes
4
+ ------------------
5
+
6
+ * This is the algorithmically most-complex orchestration module. It
7
+ walks the solve tree, expanding rolling-window solves into per-roll
8
+ sub-solves and threading parent scope through nested ``contains_solves``
9
+ chains. Every orchestration phase downstream reads the flat
10
+ ``all_solves`` list and per-solve time lists this module produces;
11
+ any divergence manifests as wrong solve ordering, missing rolls, or
12
+ mis-scoped active times.
13
+
14
+ * :class:`RecursiveSolveBuilder` mutates several fields of
15
+ ``state.solve``:
16
+
17
+ - ``roll_counter[solve] += 1`` per roll (R-O5). Reset at the start
18
+ of every top-level ``define_solve_recursive`` call so repeated
19
+ invocations on the same SolveConfig don't desync.
20
+ - ``real_solves`` accumulates every solve name encountered (including
21
+ renamed solves).
22
+ - ``first_of_complete_solve`` / ``last_of_solve`` track per-complete-solve
23
+ boundary names for state initial / terminal constraints downstream.
24
+ - ``duplicate_solve(...)`` is invoked for the single-matching-period
25
+ rename path which fans an input solve into ``solve + "_" + period``.
26
+
27
+ * Lazy import of :class:`StochasticSolver`: ``recursive_solve`` only
28
+ needs ``find_next_timestep`` (called once when a child is constrained
29
+ by a parent's roll start). ``_stochastic`` does not currently import
30
+ back, but the lazy import is defensive against future cross-module
31
+ growth.
32
+
33
+ * :func:`get_active_time` is imported from :mod:`_timeline` and called
34
+ at every node of the tree walk.
35
+
36
+ Behavioural quirks
37
+ ------------------
38
+
39
+ * **Rolling expansion** produces names ``solve + "_roll_" + str(counter)``.
40
+ The counter is sourced from ``state.solve.roll_counter[solve]`` and
41
+ incremented per roll.
42
+
43
+ * **Single-matching-period rename**: when a child solve's period set
44
+ intersects its parent's by exactly one period, the child is renamed
45
+ to ``solve + "_" + period`` and ``duplicate_solve`` carbon-copies the
46
+ parent's lockstep dicts under the new name. ``duplicate_solve`` does
47
+ NOT carbon-copy the period attributes (``invest_periods``,
48
+ ``realized_periods``, …); the empty-scope fallback at the parent_scope
49
+ computation site catches this.
50
+
51
+ * **Multi-period child** (``len(matching_periods) > 1``) is the
52
+ single-solve-with-multi-period parent case: keep the original solve
53
+ name and let the active-time filter constrain it later.
54
+
55
+ * **"More than one solve in a rolling solve, not managed"** raise:
56
+ the fix-storage handoff between sibling rolling children of a
57
+ rolling parent isn't implemented downstream.
58
+ """
59
+ from __future__ import annotations
60
+
61
+ import logging
62
+ from collections import namedtuple
63
+ from typing import TYPE_CHECKING
64
+
65
+ from flextool.engine_polars._solve_state import (
66
+ FlexToolConfigError,
67
+ SolveResult,
68
+ )
69
+ from flextool.engine_polars._timeline import get_active_time
70
+
71
+ if TYPE_CHECKING:
72
+ from flextool.engine_polars._solve_state import RunnerState
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # ParentSolveInfo — walked through the tree
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ ParentSolveInfo = namedtuple("ParentSolveInfo", ["solve", "roll"])
81
+ """Parent context threaded into recursive ``define_solve_recursive`` calls.
82
+
83
+ * ``solve``: parent solve name, or ``None`` for top-level solves.
84
+ * ``roll``: parent's per-roll name, or ``None`` if the parent is single-
85
+ solve mode (or the call is top-level).
86
+ """
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # RecursiveSolveBuilder
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ class RecursiveSolveBuilder:
95
+ """Walks the solve tree and produces the flat ``all_solves`` ordering.
96
+
97
+ The entry point :meth:`define_solve_recursive` is called once per
98
+ top-level solve in ``state.solve.model_solve``. It dispatches to
99
+ :meth:`_process_rolling_solve` or :meth:`_process_single_solve`
100
+ based on the current solve's ``solve_mode`` and threads parent
101
+ scope through any ``contains_solves`` chains.
102
+ """
103
+
104
+ def __init__(self, state: "RunnerState") -> None:
105
+ self.state = state
106
+ self.logger: logging.Logger = state.logger
107
+
108
+ # ------------------------------------------------------------------
109
+ # Rolling expansion
110
+ # ------------------------------------------------------------------
111
+
112
+ def create_rolling_solves(
113
+ self,
114
+ solve: str,
115
+ full_active_time_list: dict,
116
+ jump: float,
117
+ horizon: float,
118
+ start: list | None = None,
119
+ duration: float = -1,
120
+ ) -> tuple[list[str], dict, dict]:
121
+ """Expand *solve* into an overlapping sequence of "rolls".
122
+
123
+ Each roll is a sub-solve with name ``solve + "_roll_" + str(roll_counter)``
124
+ whose active-time list is a slice of *full_active_time_list*
125
+ bounded by the roll's start and horizon, and whose realized-time
126
+ list is a slice from the start to the next jump boundary. The
127
+ ``state.solve.roll_counter[solve]`` counter is incremented per
128
+ roll produced.
129
+
130
+ Args:
131
+ solve: Parent solve name (without roll suffix).
132
+ full_active_time_list: ``{period: [ActiveTimeEntry, ...]}``
133
+ covering the entire solve's domain.
134
+ jump: Per-roll commitment horizon (hours).
135
+ horizon: Per-roll optimisation horizon (hours).
136
+ start: Optional ``[period, timestep]`` to start from. When
137
+ ``None``, the first entry of *full_active_time_list*
138
+ wins.
139
+ duration: Total duration (hours) the rolling sequence
140
+ should cover. ``-1`` means "until the active time
141
+ list is exhausted".
142
+
143
+ Returns:
144
+ ``(roll_solve_names, active_time_lists, realized_time_lists)``.
145
+
146
+ Raises:
147
+ FlexToolConfigError: Start point not found in active time.
148
+ """
149
+ active_time_lists: dict = dict()
150
+ realized_time_lists: dict = dict()
151
+ solves: list[str] = []
152
+ starts: list[list] = []
153
+ jumps: list[list] = []
154
+ horizons: list[list] = []
155
+ duration_counter: float = 0
156
+ horizon_counter: float = 0
157
+ jump_counter: float = 0
158
+ started: bool = False
159
+ ended: bool = False
160
+ last_index: list = [None, None]
161
+
162
+ # Single-pass walk: record ``[period, index]`` pairs at each
163
+ # jump / horizon / duration boundary.
164
+ for period, active_time in list(full_active_time_list.items()):
165
+ for i, step in enumerate(active_time):
166
+ if not ended:
167
+ if started:
168
+ if duration_counter >= float(duration) and duration != -1:
169
+ jumps.append(last_index)
170
+ horizons.append(last_index)
171
+ ended = True
172
+ break
173
+ if jump_counter >= float(jump):
174
+ jumps.append(last_index)
175
+ starts.append([period, i])
176
+ jump_counter -= float(jump)
177
+ if horizon_counter >= float(horizon):
178
+ horizons.append(last_index)
179
+ horizon_counter -= float(jump)
180
+ horizon_counter += float(step.duration)
181
+ jump_counter += float(step.duration)
182
+ duration_counter += float(step.duration)
183
+ last_index = [period, i]
184
+ else:
185
+ if start is None or (start == [period, step.timestep]):
186
+ starts.append([period, i])
187
+ started = True
188
+ horizon_counter += float(step.duration)
189
+ jump_counter += float(step.duration)
190
+ duration_counter += float(step.duration)
191
+ last_index = [period, i]
192
+ if not started:
193
+ message = "Start point not found"
194
+ self.logger.error(message)
195
+ raise FlexToolConfigError(message)
196
+
197
+ # If a roll opened but didn't end (no jump/horizon boundary
198
+ # crossed before the active time exhausted), close it at the
199
+ # last index.
200
+ diff = len(starts) - len(horizons)
201
+ for _ in range(0, diff):
202
+ horizons.append(last_index)
203
+ diff = len(starts) - len(jumps)
204
+ for _ in range(0, diff):
205
+ jumps.append(last_index)
206
+
207
+ # Pre-compute ordered period list for index-based range extraction.
208
+ period_order = list(full_active_time_list.keys())
209
+ period_pos = {p: i for i, p in enumerate(period_order)}
210
+
211
+ # Slice the full active-time list per-roll using the recorded
212
+ # boundary indexes.
213
+ for index, roll_start in enumerate(starts):
214
+ solve_name = solve + "_roll_" + str(self.state.solve.roll_counter[solve])
215
+ self.state.solve.roll_counter[solve] += 1
216
+ solves.append(solve_name)
217
+ active = self._extract_time_range(
218
+ full_active_time_list,
219
+ period_order,
220
+ period_pos,
221
+ roll_start,
222
+ horizons[index],
223
+ )
224
+ realized = self._extract_time_range(
225
+ full_active_time_list,
226
+ period_order,
227
+ period_pos,
228
+ roll_start,
229
+ jumps[index],
230
+ )
231
+ active_time_lists[solve_name] = active
232
+ realized_time_lists[solve_name] = realized
233
+ return solves, active_time_lists, realized_time_lists
234
+
235
+ @staticmethod
236
+ def _extract_time_range(
237
+ full_active_time_list: dict,
238
+ period_order: list[str],
239
+ period_pos: dict[str, int],
240
+ range_start: list,
241
+ range_end: list,
242
+ ) -> dict:
243
+ """Slice *full_active_time_list* between *range_start* and *range_end*.
244
+
245
+ Both endpoints are inclusive ``[period, index_within_period]``
246
+ pairs. The four boundary cases are handled explicitly:
247
+
248
+ 1. **Single-period range** (``range_start[0] == range_end[0]``):
249
+ one slice ``[start[1] : end[1] + 1]``.
250
+ 2. **Start period** of a multi-period range: from ``start[1]``
251
+ to end-of-period.
252
+ 3. **End period** of a multi-period range: from beginning to
253
+ ``end[1] + 1``.
254
+ 4. **Middle period(s)**: full period included.
255
+
256
+ Args:
257
+ full_active_time_list: ``{period: [ActiveTimeEntry, ...]}``.
258
+ period_order: ordered list of period names from
259
+ *full_active_time_list*.
260
+ period_pos: ``period_name -> position_in_period_order``.
261
+ range_start: ``[period, index]`` start (inclusive).
262
+ range_end: ``[period, index]`` end (inclusive).
263
+
264
+ Returns:
265
+ New dict with the sliced sub-time-list.
266
+ """
267
+ result: dict = {}
268
+ start_pos = period_pos[range_start[0]]
269
+ end_pos = period_pos[range_end[0]]
270
+ for pos in range(start_pos, end_pos + 1):
271
+ period = period_order[pos]
272
+ if pos == start_pos and pos == end_pos:
273
+ result[period] = full_active_time_list[period][
274
+ range_start[1] : range_end[1] + 1
275
+ ]
276
+ elif pos == start_pos:
277
+ result[period] = full_active_time_list[period][range_start[1] :]
278
+ elif pos == end_pos:
279
+ result[period] = full_active_time_list[period][0 : range_end[1] + 1]
280
+ else:
281
+ result[period] = full_active_time_list[period]
282
+ return result
283
+
284
+ @staticmethod
285
+ def _filter_time_list_by_periods(
286
+ full_time_list: dict,
287
+ period_dict: dict,
288
+ solve_name: str,
289
+ ) -> dict:
290
+ """Filter *full_time_list* to periods listed in *period_dict[solve_name]*.
291
+
292
+ Args:
293
+ full_time_list: ``{period: [ActiveTimeEntry, ...]}``.
294
+ period_dict: ``{solve: [(p_from, p_in), ...]}``.
295
+ solve_name: Lookup key in *period_dict*.
296
+
297
+ Returns:
298
+ Sub-dict of *full_time_list* containing only periods present
299
+ in ``period_dict[solve_name]``.
300
+ """
301
+ filtered: dict = dict()
302
+ for period_tuple in period_dict.get(solve_name, []):
303
+ period = period_tuple[1] # period_included
304
+ if period in full_time_list:
305
+ filtered[period] = full_time_list[period]
306
+ return filtered
307
+
308
+ @staticmethod
309
+ def _get_periods_from_parent_time_list(parent_time_list: dict) -> set[str]:
310
+ """Return the set of periods in *parent_time_list*."""
311
+ return set(parent_time_list.keys())
312
+
313
+ @staticmethod
314
+ def _filter_time_list_by_parent_scope(
315
+ child_time_list: dict,
316
+ parent_periods: set[str],
317
+ ) -> dict:
318
+ """Restrict *child_time_list* to *parent_periods*."""
319
+ filtered: dict = dict()
320
+ for period, timesteps in child_time_list.items():
321
+ if period in parent_periods:
322
+ filtered[period] = timesteps
323
+ return filtered
324
+
325
+ # ------------------------------------------------------------------
326
+ # Rolling-solve dispatcher
327
+ # ------------------------------------------------------------------
328
+
329
+ def _process_rolling_solve(
330
+ self,
331
+ solve: str,
332
+ complete_solve_name: str,
333
+ full_active_time_list: dict,
334
+ parent_info: ParentSolveInfo,
335
+ start: list | None,
336
+ duration: float,
337
+ ) -> SolveResult:
338
+ """Expand a rolling-window solve into per-roll sub-solves.
339
+
340
+ Recurses into ``contains_solves`` once per roll, threading the
341
+ roll's first timestep + the parent's jump as the child's
342
+ ``start`` / ``duration``.
343
+ """
344
+ # Lazy import: keep the cross-module dependency one-way at
345
+ # import time; both modules are needed only when a parent's roll
346
+ # constrains a child.
347
+ from flextool.engine_polars._stochastic import StochasticSolver
348
+
349
+ solves: list[str] = []
350
+ complete_solves: dict = dict()
351
+ active_time_lists: dict = dict()
352
+ fix_storage_time_lists: dict = dict()
353
+ realized_time_lists: dict = dict()
354
+ parent_roll_lists: dict = dict()
355
+
356
+ rolling_times = self.state.solve.rolling_times[solve] # [jump, horizon, duration]
357
+ if duration == -1:
358
+ duration = float(rolling_times[2])
359
+
360
+ # Find start timestep if constrained by parent.
361
+ period_start_timestep = start
362
+ if start is not None:
363
+ start_timestep = StochasticSolver(self.state).find_next_timestep(
364
+ full_active_time_list, start, parent_info.solve, solve
365
+ )
366
+ period_start_timestep = [start[0], start_timestep]
367
+
368
+ # Create rolling solves.
369
+ roll_solves, roll_active_time_lists, roll_realized_time_lists = (
370
+ self.create_rolling_solves(
371
+ solve,
372
+ full_active_time_list,
373
+ float(rolling_times[0]),
374
+ float(rolling_times[1]),
375
+ period_start_timestep,
376
+ duration,
377
+ )
378
+ )
379
+
380
+ # Track metadata for each roll.
381
+ for roll_name in roll_solves:
382
+ complete_solves[roll_name] = complete_solve_name
383
+ parent_roll_lists[roll_name] = parent_info.roll
384
+
385
+ active_time_lists.update(roll_active_time_lists)
386
+
387
+ # For rolling solves, fix_storage and realized are the same
388
+ # (the "jump" portion).
389
+ fix_storage_time_lists.update(roll_realized_time_lists)
390
+ realized_time_lists.update(roll_realized_time_lists)
391
+
392
+ # Mark first solve of this complete solve (for state start
393
+ # constraints). When this rolling solve is itself nested under
394
+ # another (parent_info.roll != None), only mark it as a
395
+ # first-of-complete if the parent roll was already in the list.
396
+ if parent_info.roll is not None:
397
+ if parent_info.roll in self.state.solve.first_of_complete_solve:
398
+ self.state.solve.first_of_complete_solve.append(roll_solves[0])
399
+ else:
400
+ self.state.solve.first_of_complete_solve.append(roll_solves[0])
401
+
402
+ self.state.solve.last_of_solve.append(roll_solves[-1])
403
+
404
+ # Process contained solves.
405
+ if solve in self.state.solve.contains_solves:
406
+ contain_solves = self.state.solve.contains_solves[solve]
407
+ if len(contain_solves) > 1:
408
+ message = "More than one solve in a rolling solve, not managed"
409
+ logging.error(message)
410
+ raise FlexToolConfigError(message)
411
+
412
+ contains_solve = contain_solves[0]
413
+
414
+ for index, roll_name in enumerate(roll_solves):
415
+ solves.append(roll_name)
416
+
417
+ # The first roll passes start=None (use first active-time
418
+ # entry); subsequent rolls explicitly pass the roll's
419
+ # first timestep as the child's start point.
420
+ if index != 0:
421
+ first_period = list(roll_active_time_lists[roll_name].keys())[0]
422
+ first_timestep = roll_active_time_lists[roll_name][first_period][0].timestep
423
+ child_start = [first_period, first_timestep]
424
+ else:
425
+ child_start = None
426
+
427
+ # Child duration equals parent's jump (the realized portion).
428
+ child_duration = float(rolling_times[0])
429
+
430
+ # Get parent's realized periods for child to use as scope.
431
+ parent_realized_periods = set(roll_realized_time_lists[roll_name].keys())
432
+
433
+ # Recursively process child solve.
434
+ child_parent_info = ParentSolveInfo(solve=solve, roll=roll_name)
435
+ child = self.define_solve_recursive(
436
+ contains_solve,
437
+ child_parent_info,
438
+ parent_realized_periods,
439
+ child_start,
440
+ child_duration,
441
+ )
442
+
443
+ solves += child.solves
444
+ complete_solves.update(child.complete_solves)
445
+ parent_roll_lists.update(child.parent_roll_lists)
446
+ active_time_lists.update(child.active_time_lists)
447
+ fix_storage_time_lists.update(child.fix_storage_time_lists)
448
+ realized_time_lists.update(child.realized_time_lists)
449
+ else:
450
+ solves += roll_solves
451
+
452
+ return SolveResult(
453
+ solves=solves,
454
+ complete_solves=complete_solves,
455
+ active_time_lists=active_time_lists,
456
+ fix_storage_time_lists=fix_storage_time_lists,
457
+ realized_time_lists=realized_time_lists,
458
+ parent_roll_lists=parent_roll_lists,
459
+ )
460
+
461
+ # ------------------------------------------------------------------
462
+ # Single-solve dispatcher
463
+ # ------------------------------------------------------------------
464
+
465
+ def _process_single_solve(
466
+ self,
467
+ solve: str,
468
+ full_active_time_list: dict,
469
+ parent_info: ParentSolveInfo,
470
+ complete_solve_name: str | None = None,
471
+ ) -> SolveResult:
472
+ """Process a single-solve (non-rolling) solve.
473
+
474
+ When *solve* has children (``contains_solves[solve]``) recurse
475
+ into each child with parent scope = union of fix_storage,
476
+ realized, and realized_invest periods of *solve*. When the
477
+ scope is empty (because *solve* was renamed via the
478
+ single-matching-period path and ``duplicate_solve`` did not
479
+ carbon-copy the period attributes), fall back to *solve*'s own
480
+ active periods.
481
+
482
+ Args:
483
+ solve: Active solve name (may be a renamed copy).
484
+ full_active_time_list: ``{period: [ActiveTimeEntry, ...]}``.
485
+ parent_info: ParentSolveInfo from the caller.
486
+ complete_solve_name: Original (un-renamed) solve name. When
487
+ ``None`` defaults to *solve*. See module docstring for
488
+ why renamed solves keep their original complete-solve
489
+ pointer.
490
+ """
491
+ if complete_solve_name is None:
492
+ complete_solve_name = solve
493
+
494
+ solves: list[str] = [solve]
495
+ complete_solves: dict = dict()
496
+ active_time_lists: dict = dict()
497
+ invest_time_lists: dict = dict()
498
+ fix_storage_time_lists: dict = dict()
499
+ realized_time_lists: dict = dict()
500
+ parent_roll_lists: dict = dict()
501
+
502
+ complete_solves[solve] = complete_solve_name
503
+ parent_roll_lists[solve] = parent_info.roll
504
+ active_time_lists[solve] = full_active_time_list
505
+
506
+ # Period-shaped subsets of the full active time list.
507
+ invest_time_lists[solve] = self._filter_time_list_by_periods(
508
+ full_active_time_list, self.state.solve.invest_periods, solve
509
+ )
510
+ fix_storage_time_lists[solve] = self._filter_time_list_by_periods(
511
+ full_active_time_list, self.state.solve.fix_storage_periods, solve
512
+ )
513
+ realized_time_lists[solve] = self._filter_time_list_by_periods(
514
+ full_active_time_list, self.state.solve.realized_periods, solve
515
+ )
516
+
517
+ self.state.solve.first_of_complete_solve.append(solve)
518
+ self.state.solve.last_of_solve.append(solve)
519
+
520
+ # Process contained solves.
521
+ if solve in self.state.solve.contains_solves:
522
+ contain_solves = self.state.solve.contains_solves[solve]
523
+
524
+ # Parent scope: union of fix_storage, realized, and
525
+ # realized_invest periods. Invest solves only have
526
+ # invest/realized_invest periods (not fix_storage/realized),
527
+ # so realized_invest is needed to provide scope for their
528
+ # children.
529
+ realized_invest_time_list = self._filter_time_list_by_periods(
530
+ full_active_time_list, self.state.solve.realized_invest_periods, solve
531
+ )
532
+ parent_scope_periods = (
533
+ set(fix_storage_time_lists[solve].keys())
534
+ | set(realized_time_lists[solve].keys())
535
+ | set(realized_invest_time_list.keys())
536
+ )
537
+
538
+ # Fallback: when a solve was renamed (e.g.
539
+ # ``storage_fullYear_6h_p2020``), its period attributes
540
+ # aren't copied by ``duplicate_solve``, leaving scope empty.
541
+ # Use the solve's own active periods as scope in that case.
542
+ if not parent_scope_periods:
543
+ parent_scope_periods = set(full_active_time_list.keys())
544
+
545
+ for contain_solve in contain_solves:
546
+ child_parent_info = ParentSolveInfo(solve=solve, roll=solve)
547
+ child = self.define_solve_recursive(
548
+ contain_solve, child_parent_info, parent_scope_periods, None, -1
549
+ )
550
+
551
+ solves += child.solves
552
+ complete_solves.update(child.complete_solves)
553
+ parent_roll_lists.update(child.parent_roll_lists)
554
+ active_time_lists.update(child.active_time_lists)
555
+ fix_storage_time_lists.update(child.fix_storage_time_lists)
556
+ realized_time_lists.update(child.realized_time_lists)
557
+
558
+ return SolveResult(
559
+ solves=solves,
560
+ complete_solves=complete_solves,
561
+ active_time_lists=active_time_lists,
562
+ fix_storage_time_lists=fix_storage_time_lists,
563
+ realized_time_lists=realized_time_lists,
564
+ parent_roll_lists=parent_roll_lists,
565
+ )
566
+
567
+ # ------------------------------------------------------------------
568
+ # Recursive entry
569
+ # ------------------------------------------------------------------
570
+
571
+ def define_solve_recursive(
572
+ self,
573
+ solve: str,
574
+ parent_info: ParentSolveInfo,
575
+ parent_scope_periods: set[str] | None = None,
576
+ start: list | None = None,
577
+ duration: float = -1,
578
+ ) -> SolveResult:
579
+ """Walk the solve tree rooted at *solve*.
580
+
581
+ For each invocation:
582
+
583
+ 1. Append *solve* to ``state.solve.real_solves`` (deduplicated).
584
+ 2. If *solve* has a parent and exactly one period overlaps,
585
+ rename it to ``solve + "_" + period`` via
586
+ :meth:`SolveConfig.duplicate_solve` and re-shape
587
+ ``timesets_used_by_solves`` to keep only entries for that
588
+ period.
589
+ 3. Compute the full active-time list via :func:`get_active_time`.
590
+ 4. If *parent_scope_periods* is non-empty, restrict the active-
591
+ time list to those periods.
592
+ 5. Dispatch to :meth:`_process_rolling_solve` or
593
+ :meth:`_process_single_solve`.
594
+
595
+ Args:
596
+ solve: Solve name to process.
597
+ parent_info: ParentSolveInfo (top-level call uses
598
+ ``ParentSolveInfo(None, None)``).
599
+ parent_scope_periods: Periods the parent committed to.
600
+ ``None`` for top-level solves.
601
+ start: ``[period, timestep]`` start (rolling-window child
602
+ only).
603
+ duration: Hours of duration to cover (rolling-window child
604
+ only).
605
+ """
606
+ new_name = solve
607
+ if new_name not in self.state.solve.real_solves:
608
+ self.state.solve.real_solves.append(new_name)
609
+
610
+ # Single-matching-period rename path. When the child's period
611
+ # set intersects the parent's by exactly one period, rename
612
+ # ``solve`` → ``solve + "_" + period`` and carbon-copy the
613
+ # parent dicts. Multiple-period overlap keeps the original
614
+ # name (single-solve-with-multi-period parent case).
615
+ if parent_info.solve:
616
+ current_solve_periods = (
617
+ {t[0] for t in self.state.solve.invest_periods[solve]}
618
+ | {t[0] for t in self.state.solve.fix_storage_periods[solve]}
619
+ | {t[0] for t in self.state.solve.realized_periods[solve]}
620
+ )
621
+ parent_period = (
622
+ {t[0] for t in self.state.solve.invest_periods[parent_info.solve]}
623
+ | {t[0] for t in self.state.solve.fix_storage_periods[parent_info.solve]}
624
+ | {t[0] for t in self.state.solve.realized_periods[parent_info.solve]}
625
+ )
626
+ matching_periods = list(current_solve_periods & parent_period)
627
+ if len(matching_periods) == 1:
628
+ current_solve_period = matching_periods[0]
629
+ new_name = solve + "_" + str(current_solve_period)
630
+ self.state.solve.duplicate_solve(
631
+ solve, new_name, update_model_solves=False
632
+ )
633
+ self.state.solve.solve_period_years_represented[new_name] = (
634
+ self.state.solve.solve_period_years_represented[solve]
635
+ )
636
+
637
+ new_period_timeset_list = [
638
+ pt
639
+ for pt in self.state.solve.timesets_used_by_solves.get(solve, [])
640
+ if pt[0] == current_solve_period
641
+ ]
642
+ if new_name not in self.state.solve.timesets_used_by_solves:
643
+ self.state.solve.timesets_used_by_solves[new_name] = (
644
+ new_period_timeset_list
645
+ )
646
+ else:
647
+ existing = self.state.solve.timesets_used_by_solves[new_name]
648
+ for item in new_period_timeset_list:
649
+ if item not in existing:
650
+ existing.append(item)
651
+ # When multiple periods match (single-solve parent with
652
+ # multiple periods), keep the original solve name and all
653
+ # its timesets — the active time will be filtered by
654
+ # parent scope later.
655
+
656
+ # Full active time list for this solve (every timestep it
657
+ # could potentially use).
658
+ full_active_time_list_own = get_active_time(
659
+ new_name,
660
+ self.state.solve.timesets_used_by_solves,
661
+ self.state.timeline.timeset_durations,
662
+ self.state.timeline.timelines,
663
+ self.state.timeline.timesets__timeline,
664
+ )
665
+
666
+ # Restrict to parent scope when applicable.
667
+ if not parent_scope_periods:
668
+ full_active_time_list = full_active_time_list_own
669
+ else:
670
+ full_active_time_list = self._filter_time_list_by_parent_scope(
671
+ full_active_time_list_own, parent_scope_periods
672
+ )
673
+
674
+ # Determine solve mode.
675
+ solve_mode = self.state.solve.solve_modes.get(new_name, "single_solve")
676
+ if solve_mode == "rolling_window":
677
+ complete_solve_name = solve
678
+ return self._process_rolling_solve(
679
+ new_name,
680
+ complete_solve_name,
681
+ full_active_time_list,
682
+ parent_info,
683
+ start,
684
+ duration,
685
+ )
686
+ else:
687
+ # Single-solve: pass the *original* name as
688
+ # complete_solve_name so renamed solves (e.g.
689
+ # ``storage_fullYear_6h_p2020``) keep mapping back to the
690
+ # complete-solve name in ``state.solve.real_solves`` /
691
+ # ``solve_period_history``.
692
+ return self._process_single_solve(
693
+ new_name,
694
+ full_active_time_list,
695
+ parent_info,
696
+ complete_solve_name=solve,
697
+ )
698
+
699
+
700
+ __all__ = [
701
+ "ParentSolveInfo",
702
+ "RecursiveSolveBuilder",
703
+ ]