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,1416 @@
1
+ """Timeline-level configuration loaded from a SpineDB scenario.
2
+
3
+ Architecture notes
4
+ ------------------
5
+
6
+ * Every downstream module (recursive solve builder, stochastic,
7
+ orchestration loop) reads ``state.timeline.<dict>`` keys produced
8
+ here. Quirks:
9
+
10
+ - ``timeset_weights`` and ``representative_period_weights`` decoders
11
+ accept BOTH :class:`spinedb_api.Map` and ``list[tuple]`` shapes
12
+ (Spine API quirk; see ``audit/solve_orchestration_plan.md §3.2``).
13
+ - ``new_step_durations`` is solve-scoped as of DB v50 (was
14
+ timeset-scoped pre-v50; ``update_flextool/db_migration.py`` upgrades
15
+ older DBs before this loader runs).
16
+ - The six-rule fallback chain in :meth:`TimelineConfig.create_assumptive_parts`
17
+ mutates *both* ``self`` AND the supplied ``solve_config`` in three
18
+ of its rules. Order matters; idempotency is achieved through the
19
+ membership checks each rule applies before mutating.
20
+
21
+ * :func:`get_active_time` is the most-called free function — every
22
+ recursive solve invocation walks it. It reads :attr:`timesets`
23
+ (``list[str]``) but the parameter is sometimes a *dict* of timeset
24
+ durations passed by the orchestration layer; the function tolerates
25
+ either by taking timeset durations as the second argument.
26
+
27
+ * ``create_averaged_timeseries`` aggregates per-(``solve``) timeline
28
+ data by averaging or summing matched timesteps. The
29
+ ``pt_node_inflow.csv`` / ``pbt_node_inflow.csv`` files are summed;
30
+ every other ``pt_*.csv`` / ``pbt_*.csv`` is averaged. The
31
+ ``storage_state_reference_value`` row in any of those files bypasses
32
+ aggregation entirely.
33
+
34
+ * ``_derived_params.py::dt_and_step_duration_from_source`` is a
35
+ polars-lazy alternative that consumes the per-(entity_class,
36
+ parameter_name) ``InputSource`` Protocol; this module uses
37
+ ``params_to_dict`` directly. The two paths produce identical
38
+ timeline / timeset state and coexist because they serve different
39
+ consumers — the InputSource one feeds Param-frame helpers, this one
40
+ feeds the orchestration loop.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import math
46
+ from collections import defaultdict
47
+ from pathlib import Path
48
+ from typing import TYPE_CHECKING, Any
49
+
50
+ import polars as pl
51
+ import spinedb_api as api
52
+
53
+ from flextool.engine_polars._solve_config import (
54
+ DictMode,
55
+ get_single_entities,
56
+ params_to_dict,
57
+ )
58
+ from flextool.engine_polars._solve_state import (
59
+ ActiveTimeEntry,
60
+ FlexToolConfigError,
61
+ )
62
+ from flextool.engine_polars._emit_provider_io import (
63
+ _provider_key,
64
+ )
65
+
66
+ if TYPE_CHECKING:
67
+ from spinedb_api import DatabaseMapping
68
+
69
+ from flextool.engine_polars._flex_data_provider import FlexDataProvider
70
+ from flextool.engine_polars._solve_config import SolveConfig
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # TimelineConfig — main container
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ class TimelineConfig:
79
+ """All timeline definitions and timeset mappings for a native run.
80
+
81
+ Attributes
82
+ ----------
83
+ timelines : defaultdict[str, list[tuple]]
84
+ timeline_name -> [(timestep, duration), ...]
85
+ timesets : list[str]
86
+ timeset entity name list.
87
+ timesets__timeline : defaultdict[str, str]
88
+ timeset_name -> timeline_name.
89
+ timeset_durations : defaultdict[str, list[tuple]]
90
+ timeset_name -> [(start, count), ...].
91
+ new_step_durations : dict[str, str]
92
+ solve_name -> new_duration (hours). Solve-scoped as of DB v50
93
+ (prior to v50 this was keyed by timeset_name; the runtime
94
+ already required all timesets within a solve to carry the same
95
+ value, so the effective scope is unchanged).
96
+ rp_weights : dict[str, dict]
97
+ timeset_name -> {base_start: {rep_start: weight}}.
98
+ timeset_weights : dict[str, dict[str, float]]
99
+ timeset_name -> {timestep: weight}. Raw user input; runner
100
+ normalizes during ``write_timeset_cost_weight``.
101
+ stochastic_timesteps : defaultdict[str, list[tuple]]
102
+ Mutable — populated during the solve loop (Γ.8.C).
103
+ original_timeline : defaultdict[str, str]
104
+ new_timeline_name -> original_timeline_name.
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ timelines: defaultdict,
110
+ timesets: list,
111
+ timesets__timeline: defaultdict,
112
+ timeset_durations: defaultdict,
113
+ new_step_durations: dict,
114
+ rp_weights: dict | None = None,
115
+ timeset_weights: dict | None = None,
116
+ ) -> None:
117
+ self.timelines = timelines
118
+ self.timesets = timesets
119
+ self.timesets__timeline = timesets__timeline
120
+ self.timeset_durations = timeset_durations
121
+ self.new_step_durations = new_step_durations
122
+ self.rp_weights: dict = rp_weights or {}
123
+ self.timeset_weights: dict[str, dict[str, float]] = (
124
+ timeset_weights or {}
125
+ )
126
+
127
+ # Mutable state — populated later by orchestration / stochastic.
128
+ self.stochastic_timesteps: defaultdict[str, list] = defaultdict(list)
129
+ self.original_timeline: defaultdict[str, str] = defaultdict()
130
+
131
+ # ------------------------------------------------------------------
132
+ # Factories
133
+ # ------------------------------------------------------------------
134
+
135
+ @classmethod
136
+ def load_from_db(
137
+ cls, db: "DatabaseMapping", logger: logging.Logger
138
+ ) -> "TimelineConfig":
139
+ """Read all timeline-level parameters from *db* into a TimelineConfig.
140
+
141
+ Reads:
142
+
143
+ * ``timeline.timestep_duration`` — base timeline definitions.
144
+ * ``timeset`` entities — flat list.
145
+ * ``timeset.timeset_duration`` — (start, count) blocks per timeset.
146
+ * ``timeset.timeline`` — timeset → timeline mapping.
147
+ * ``solve.new_stepduration`` — per-solve aggregation override
148
+ (DB v50+; older DBs are upgraded by db_migration first).
149
+ * ``timeset.representative_period_weights`` — nested Map decoder
150
+ (handles BOTH ``api.Map`` and ``list[tuple]`` shapes).
151
+ * ``timeset.timeset_weights`` — flat Map decoder (same dual-
152
+ shape handling).
153
+ """
154
+ timelines: defaultdict = params_to_dict(
155
+ db=db,
156
+ cl="timeline",
157
+ par="timestep_duration",
158
+ mode=DictMode.DEFAULTDICT,
159
+ )
160
+ timesets: list = get_single_entities(
161
+ db=db, entity_class_name="timeset"
162
+ )
163
+ timeset_durations: defaultdict = params_to_dict(
164
+ db=db,
165
+ cl="timeset",
166
+ par="timeset_duration",
167
+ mode=DictMode.DEFAULTDICT,
168
+ )
169
+ timesets__timeline: defaultdict = params_to_dict(
170
+ db=db, cl="timeset", par="timeline", mode=DictMode.DEFAULTDICT
171
+ )
172
+ new_step_durations: dict = params_to_dict(
173
+ db=db,
174
+ cl="solve",
175
+ par="new_stepduration",
176
+ mode=DictMode.DICT,
177
+ )
178
+ # Representative period weights: nested Map
179
+ # ``base_start -> {rep_start -> weight}``. ``params_to_dict``
180
+ # returns the raw spinedb value because the inner is a Map
181
+ # (the DICT mode doesn't auto-flatten nested Map-of-Map).
182
+ rp_weights_raw = params_to_dict(
183
+ db=db,
184
+ cl="timeset",
185
+ par="representative_period_weights",
186
+ mode=DictMode.DICT,
187
+ )
188
+ rp_weights: dict = _decode_rp_weights(rp_weights_raw)
189
+
190
+ # Flat Map: ``timestep -> weight``. Used for non-RP per-step
191
+ # cost/slack weighting. Decodes BOTH ``api.Map`` and
192
+ # ``list[tuple]`` shapes (Spine API quirk).
193
+ timeset_weights_raw = params_to_dict(
194
+ db=db,
195
+ cl="timeset",
196
+ par="timeset_weights",
197
+ mode=DictMode.DICT,
198
+ )
199
+ timeset_weights: dict[str, dict[str, float]] = (
200
+ _decode_timeset_weights(timeset_weights_raw)
201
+ )
202
+ return cls(
203
+ timelines=timelines,
204
+ timesets=timesets,
205
+ timesets__timeline=timesets__timeline,
206
+ timeset_durations=timeset_durations,
207
+ new_step_durations=new_step_durations,
208
+ rp_weights=rp_weights,
209
+ timeset_weights=timeset_weights,
210
+ )
211
+
212
+ @classmethod
213
+ def load_from_db_url(
214
+ cls,
215
+ db_url: str,
216
+ scenario: str,
217
+ logger: logging.Logger | None = None,
218
+ ) -> "TimelineConfig":
219
+ """Convenience factory: open *db_url*, apply *scenario*, load.
220
+
221
+ Mirrors :meth:`flextool.engine_polars._solve_config.SolveConfig.load_from_db_url`
222
+ — same pattern.
223
+ """
224
+ from spinedb_api import DatabaseMapping
225
+ from spinedb_api.filters.scenario_filter import (
226
+ apply_scenario_filter_to_subqueries,
227
+ )
228
+
229
+ if logger is None:
230
+ logger = logging.getLogger(f"flextool.engine_polars.timeline[{scenario}]")
231
+ url = str(db_url)
232
+ if "://" not in url:
233
+ url = f"sqlite:///{url}"
234
+ with DatabaseMapping(url) as db:
235
+ apply_scenario_filter_to_subqueries(db, scenario)
236
+ # Pre-warm caches — see SolveConfig.load_from_db_url for
237
+ # the rationale. Measured 1.76× speedup on large customer
238
+ # DBs (H2_trade), insignificant noise on small test DBs.
239
+ db.fetch_all("entity")
240
+ db.fetch_all("parameter_value")
241
+ return cls.load_from_db(db, logger)
242
+
243
+ @classmethod
244
+ def load_from_source(
245
+ cls,
246
+ source: object,
247
+ logger: logging.Logger | None = None,
248
+ ) -> "TimelineConfig":
249
+ """Load via the :class:`InputSource` Protocol (Γ.8.D wiring).
250
+
251
+ Currently only :class:`flextool.engine_polars._spinedb_reader.SpineDbReader`
252
+ sources are supported — they expose ``db_url`` and ``scenario`` so
253
+ the canonical :meth:`load_from_db_url` path can be reused.
254
+ In-memory and CSV-backed sources are deferred to Γ.8.D.
255
+ """
256
+ from flextool.engine_polars._spinedb_reader import SpineDbReader
257
+
258
+ if isinstance(source, SpineDbReader):
259
+ return cls.load_from_db_url(
260
+ source.db_url, source.scenario, logger=logger
261
+ )
262
+ raise NotImplementedError(
263
+ f"TimelineConfig.load_from_source does not yet support "
264
+ f"{type(source).__name__!r} sources. Use load_from_db / "
265
+ f"load_from_db_url with a Spine DB for now; the in-memory and "
266
+ f"CSV adapters land in Gamma.8.D when chain.run_chain is rewired."
267
+ )
268
+
269
+ # ------------------------------------------------------------------
270
+ # Methods
271
+ # ------------------------------------------------------------------
272
+
273
+ def create_timeline_from_timestep_duration(
274
+ self, solve_config: "SolveConfig"
275
+ ) -> None:
276
+ """Synthesise per-solve aggregated timelines.
277
+
278
+ For each solve that sets ``new_stepduration``, build a freshly
279
+ aggregated timeline ``"{timeline}_{solve}"`` and re-point every
280
+ timeset the solve uses at that new timeline. Preserves the
281
+ ``original_timeline`` map so :meth:`create_averaged_timeseries`
282
+ can find the unaggregated source.
283
+
284
+ Mirrors lines 161-236 of the flextool source.
285
+
286
+ A defensive guard (``"fail loudly"`` per
287
+ ``audit/handoff_post_split_todo.md``): if the same timeset is
288
+ shared between two solves with *different* ``new_stepduration``
289
+ values, raise rather than silently overwrite. Flextool's source
290
+ comment claims this is "rejected at migration time" but doesn't
291
+ check; the native engine enforces the invariant.
292
+ """
293
+ logger = logging.getLogger(__name__)
294
+
295
+ # Defensive check for "shared timeset, conflicting new_stepduration".
296
+ # Walk all (solve, timeset) pairs and assert at most one
297
+ # ``new_stepduration`` value per timeset.
298
+ timeset_to_step_duration: dict[str, tuple[str, float]] = {}
299
+ for solve_name, step_duration_raw in self.new_step_durations.items():
300
+ if step_duration_raw is None:
301
+ continue
302
+ sd = float(step_duration_raw)
303
+ for _period, ts in solve_config.timesets_used_by_solves.get(
304
+ solve_name, []
305
+ ):
306
+ prev = timeset_to_step_duration.get(ts)
307
+ if prev is None:
308
+ timeset_to_step_duration[ts] = (solve_name, sd)
309
+ elif prev[1] != sd:
310
+ message = (
311
+ f"Timeset '{ts}' is shared between solves "
312
+ f"'{prev[0]}' (new_stepduration={prev[1]}) and "
313
+ f"'{solve_name}' (new_stepduration={sd}); "
314
+ f"flextool needs one timeline per timeset."
315
+ )
316
+ logger.error(message)
317
+ raise FlexToolConfigError(message)
318
+
319
+ for solve_name, step_duration_raw in self.new_step_durations.items():
320
+ if step_duration_raw is None:
321
+ continue
322
+ step_duration = float(step_duration_raw)
323
+ period_timesets = solve_config.timesets_used_by_solves.get(
324
+ solve_name, []
325
+ )
326
+ # De-duplicate timeset names while keeping their order.
327
+ seen_timesets: set[str] = set()
328
+ timesets_in_solve: list[str] = []
329
+ for _period, timeset_name in period_timesets:
330
+ if timeset_name in seen_timesets:
331
+ continue
332
+ seen_timesets.add(timeset_name)
333
+ timesets_in_solve.append(timeset_name)
334
+
335
+ for timeset_name in timesets_in_solve:
336
+ timeset = self.timeset_durations.get(timeset_name)
337
+ if not timeset:
338
+ continue
339
+ timeline_name = self.timesets__timeline[timeset_name]
340
+ old_steps = self.timelines[timeline_name]
341
+ new_steps: list[tuple[str, str]] = []
342
+ new_timesets: list[tuple[str, int]] = []
343
+ for ts in timeset:
344
+ first_step = ts[0]
345
+ first_index = [step[0] for step in old_steps].index(ts[0])
346
+ step_counter = 0.0
347
+ last_index = first_index + int(float(ts[1]))
348
+ added_steps = 0
349
+ for step in old_steps[first_index:last_index]:
350
+ if step_counter >= step_duration:
351
+ new_steps.append((first_step, str(step_counter)))
352
+ first_step = step[0]
353
+ step_counter = 0.0
354
+ added_steps += 1
355
+ if step_counter > step_duration:
356
+ logger.warning(
357
+ "Warning: All new steps are not the "
358
+ "size of the given step duration. The "
359
+ "new step duration has to be multiple "
360
+ "of old step durations for this to "
361
+ "happen."
362
+ )
363
+ step_counter += float(step[1])
364
+ new_steps.append((first_step, str(step_counter)))
365
+ added_steps += 1
366
+ new_timesets.append((ts[0], added_steps))
367
+ self.timeset_durations[timeset_name] = new_timesets
368
+ new_timeline_name = timeline_name + "_" + solve_name
369
+ self.timelines[new_timeline_name] = new_steps
370
+ self.timesets__timeline[timeset_name] = new_timeline_name
371
+ self.original_timeline[new_timeline_name] = timeline_name
372
+
373
+ def create_assumptive_parts(self, solve_config: "SolveConfig") -> None:
374
+ """Fill in missing time-structure parts from assumptions when
375
+ the input data is incomplete.
376
+
377
+ Six fallback rules (lines 238-382 of the flextool source):
378
+
379
+ 1. No timeset → synthesise from a single timeline.
380
+ 2. No ``timeset_durations`` → full-timeline timeset.
381
+ 3. No ``timesets__timeline`` mapping → use the sole timeline.
382
+ 4. No ``model:solves`` → use the sole solve (mutates
383
+ ``solve_config.model_solve``).
384
+ 5. No ``period_timeset`` → use the sole timeset (mutates
385
+ ``solve_config.timesets_used_by_solves`` and
386
+ ``solve_config.realized_periods``).
387
+ 6. No ``solve_period_years_represented`` → 1 year per period
388
+ (mutates ``solve_config.solve_period_years_represented``).
389
+
390
+ The rules mutate ``self`` AND ``solve_config`` in-place. Each
391
+ rule's guard checks whether its target is already populated;
392
+ running :meth:`create_assumptive_parts` twice on the same pair
393
+ is a no-op (idempotent).
394
+ """
395
+ logger = logging.getLogger(__name__)
396
+
397
+ # Rule 1: No timeset → synthesise "full_timeline" from sole timeline.
398
+ if not self.timesets and len(self.timelines) == 1:
399
+ timeset_name = "full_timeline"
400
+ self.timesets = list(timeset_name)
401
+
402
+ # Rule 2: No timeset_durations + sole timeline → block the whole
403
+ # timeline as one timeset_duration entry.
404
+ for timeset_name in self.timesets:
405
+ if not self.timeset_durations and len(self.timelines) == 1:
406
+ self.timesets__timeline[timeset_name] = list(
407
+ self.timelines.keys()
408
+ )[0]
409
+ self.timeset_durations[timeset_name] = [
410
+ (
411
+ list(self.timelines.values())[0][0][0],
412
+ len(list(self.timelines.values())[0]),
413
+ )
414
+ ]
415
+
416
+ # Rule 3: timeset:timeline missing + sole timeline → use it.
417
+ # If multiple timelines exist, the user must declare which one
418
+ # this timeset uses — fail loudly.
419
+ for timeset_name, _block in self.timeset_durations.items():
420
+ if timeset_name not in self.timesets__timeline:
421
+ if len(self.timelines) == 1:
422
+ self.timesets__timeline[timeset_name] = list(
423
+ self.timelines.keys()
424
+ )[0]
425
+ elif len(self.timelines) > 1:
426
+ message = (
427
+ "More than one timeline available and FlexTool "
428
+ "does not know which ones to use. Please use "
429
+ "'timeline' parameter of 'timeset' class to "
430
+ "define which timelines are part of the "
431
+ "timeset(s) in the model instance"
432
+ )
433
+ logger.error(message)
434
+ raise FlexToolConfigError(message)
435
+
436
+ # Rule 4: No model:solves → pick the sole solve (or fail).
437
+ # MUTATES solve_config.model_solve.
438
+ if not solve_config.model_solve:
439
+ if len(solve_config.model) == 1:
440
+ solve = None
441
+ if len(solve_config.timesets_used_by_solves) == 1:
442
+ solve = list(
443
+ solve_config.timesets_used_by_solves.keys()
444
+ )[0]
445
+ elif len(solve_config.timesets_used_by_solves) > 1:
446
+ message = (
447
+ "Data contains multiple solve entities and "
448
+ "FlexTool does not know which to use. Please use "
449
+ "'solves' parameter of 'model' class to inform "
450
+ "which solves are to be included in the model "
451
+ "instance."
452
+ )
453
+ logger.error(message)
454
+ raise FlexToolConfigError(message)
455
+ all_solves_in_periods = (
456
+ set(solve_config.realized_periods.keys())
457
+ | set(solve_config.invest_periods.keys())
458
+ )
459
+ for solve_name in all_solves_in_periods:
460
+ if solve:
461
+ if solve_name != solve:
462
+ message = (
463
+ "Data contains multiple solve entities "
464
+ "and FlexTool does not know which to "
465
+ "use. Please use 'solves' parameter of "
466
+ "'model' class to inform which solves "
467
+ "are to be included in the model "
468
+ "instance."
469
+ )
470
+ logger.error(message)
471
+ raise FlexToolConfigError(message)
472
+ else:
473
+ solve = solve_name
474
+ solve_config.model_solve[solve_config.model[0]] = [solve]
475
+ else:
476
+ message = (
477
+ "More than one model entity found in the database "
478
+ "and FlexTool does not know which to use."
479
+ )
480
+ logger.error(message)
481
+ raise FlexToolConfigError(message)
482
+
483
+ # Rule 5a: Solve listed but no realized_periods/invest_periods/
484
+ # timesets_used_by_solves → fall back to model.periods_available.
485
+ # MUTATES solve_config.realized_periods.
486
+ for model, solves in solve_config.model_solve.items():
487
+ for solve in solves:
488
+ if (
489
+ solve not in solve_config.realized_periods
490
+ and solve not in solve_config.invest_periods
491
+ and solve not in solve_config.timesets_used_by_solves
492
+ ):
493
+ if model in solve_config.periods_available:
494
+ for period in solve_config.periods_available[model]:
495
+ solve_config.realized_periods[solve].append(
496
+ (period, period)
497
+ )
498
+ else:
499
+ message = (
500
+ f"The solve {solve} in the model: solves "
501
+ f"array does not have any periods defined: "
502
+ f"(period_timeset, realized_periods, "
503
+ f"invest_periods)\nAlternatively add "
504
+ f"periods_available to the model to create "
505
+ f"simple full timelines for those periods"
506
+ )
507
+ logger.error(message)
508
+ raise FlexToolConfigError(message)
509
+
510
+ # Rule 5b: solve has no period_timeset but a single timeset
511
+ # exists → use it. MUTATES timesets_used_by_solves.
512
+ for solve in list(solve_config.model_solve.values())[0]:
513
+ if solve not in list(
514
+ solve_config.timesets_used_by_solves.keys()
515
+ ):
516
+ if len(self.timeset_durations) == 1:
517
+ period__timeset_list: list[tuple[str, str]] = []
518
+ timeset_name = list(self.timeset_durations.keys())[0]
519
+ for period_tuple in (
520
+ solve_config.invest_periods.get(solve, [])
521
+ + solve_config.realized_periods.get(solve, [])
522
+ ):
523
+ period__timeset_list.append(
524
+ (period_tuple[1], timeset_name)
525
+ )
526
+ solve_config.timesets_used_by_solves[solve] = (
527
+ period__timeset_list
528
+ )
529
+ else:
530
+ message = (
531
+ "More than one timeset available and FlexTool "
532
+ "does not know which ones to use. Please use "
533
+ "'period_timeset' parameter of 'solve' class to "
534
+ "define which periods and which timesets are "
535
+ "part of the solve(s) in the model instance"
536
+ )
537
+ logger.error(message)
538
+ raise FlexToolConfigError(message)
539
+
540
+ # Rule 5c: solve has period__timeset but no realized/invest/
541
+ # contains_solves → assume all in period_timeset are realized.
542
+ # MUTATES solve_config.realized_periods.
543
+ for solve in list(solve_config.model_solve.values())[0]:
544
+ if (
545
+ solve not in solve_config.realized_periods
546
+ and solve not in solve_config.invest_periods
547
+ and not solve_config.contains_solves[solve]
548
+ and solve in solve_config.timesets_used_by_solves
549
+ ):
550
+ for period_timeset in solve_config.timesets_used_by_solves[
551
+ solve
552
+ ]:
553
+ solve_config.realized_periods[solve].append(
554
+ (period_timeset[0], period_timeset[0])
555
+ )
556
+
557
+ # Rule 6: solve_period_years_represented missing → 1 year/period.
558
+ # MUTATES solve_config.solve_period_years_represented.
559
+ for solve in list(solve_config.model_solve.values())[0]:
560
+ all_periods_tuples = (
561
+ solve_config.realized_periods[solve]
562
+ + solve_config.invest_periods[solve]
563
+ )
564
+ all_periods = {item for tup in all_periods_tuples for item in tup}
565
+ for period in all_periods:
566
+ if solve not in solve_config.solve_period_years_represented:
567
+ solve_config.solve_period_years_represented[solve].append(
568
+ [period, 1.0]
569
+ )
570
+
571
+ def create_averaged_timeseries(
572
+ self,
573
+ solve: str,
574
+ solve_config: "SolveConfig",
575
+ logger: logging.Logger,
576
+ *,
577
+ provider: "FlexDataProvider",
578
+ work_folder: "Path | None" = None,
579
+ ) -> None:
580
+ """Average or sum input timeseries to match a coarsened timeline.
581
+
582
+ If ``solve`` does not set ``new_stepduration`` (solve-scoped as
583
+ of DB v50), pass ``input/pt_*`` frames through verbatim under
584
+ the ``solve_data/pt_*`` Provider key. Otherwise re-aggregate
585
+ each timeseries row-by-row to match the new step size and
586
+ ``provider.put`` the aggregated frame under the canonical
587
+ ``solve_data/<basename>`` key.
588
+
589
+ Aggregation rules (lines 405-422 of the flextool source):
590
+
591
+ * ``pt_node_inflow.csv`` and ``pbt_node_inflow.csv`` →
592
+ ``"sum"`` (inflow energies sum across coarsened steps).
593
+ * Every other ``pt_*.csv`` / ``pbt_*.csv`` → ``"average"``.
594
+ * Rows with parameter ``"storage_state_reference_value"`` are
595
+ ALWAYS bypassed (line 474) — they're per-step state targets
596
+ rather than time-integrated quantities.
597
+
598
+ After file aggregation, ``input/p_node``'s ``inflow`` rows
599
+ are appended to ``solve_data/pt_node_inflow`` as one entry
600
+ per new timestep, scaled by the new step's duration.
601
+
602
+ Args:
603
+ solve: Active solve name.
604
+ solve_config: SolveConfig containing the
605
+ ``timesets_used_by_solves`` map.
606
+ logger: Logger for error reporting.
607
+ provider: Active cascade-input :class:`FlexDataProvider`.
608
+ Required — supplies the ``input/pt_*`` frames and
609
+ receives the derived ``solve_data/pt_*`` frames.
610
+ work_folder: Working directory. When provided,
611
+ ``solve_data/`` is resolved under it (used only to
612
+ build canonical Provider keys); defaults to the
613
+ current working directory.
614
+ """
615
+ wf = Path(work_folder) if work_folder is not None else Path.cwd()
616
+ timeseries_map: dict[str, str] = {
617
+ "pt_node_inflow.csv": "sum",
618
+ "pt_commodity.csv": "average",
619
+ "pt_group.csv": "average",
620
+ "pt_node.csv": "average",
621
+ "pt_process.csv": "average",
622
+ "pt_profile.csv": "average",
623
+ "pt_process_source.csv": "average",
624
+ "pt_process_sink.csv": "average",
625
+ "pt_reserve__upDown__group.csv": "average",
626
+ "pbt_node_inflow.csv": "sum",
627
+ "pbt_node.csv": "average",
628
+ "pbt_process.csv": "average",
629
+ "pbt_profile.csv": "average",
630
+ "pbt_process_source.csv": "average",
631
+ "pbt_process_sink.csv": "average",
632
+ "pbt_reserve__upDown__group.csv": "average",
633
+ }
634
+ # As of DB v50 new_stepduration is keyed by solve. ``None`` is
635
+ # the parameter_definition default in the master template; treat
636
+ # it as "not set" and skip aggregation.
637
+ create = (
638
+ solve in self.new_step_durations
639
+ and self.new_step_durations[solve] is not None
640
+ )
641
+ if not create:
642
+ for timeseries in timeseries_map:
643
+ src_path = wf / "input" / timeseries
644
+ dst_path = wf / "solve_data" / timeseries
645
+ src_key = _provider_key(src_path)
646
+ dst_key = _provider_key(dst_path)
647
+ frame = _provider_get_or_read(provider, src_key, src_path)
648
+ if frame is None:
649
+ continue
650
+ provider.put(dst_key, frame)
651
+ return
652
+
653
+ # All timesets used by *solve* must resolve to the same
654
+ # timeline — new_stepduration rewrites that timeline once per
655
+ # solve and we re-aggregate every timeseries row by it.
656
+ timelines_list: list[str] = []
657
+ for _period, timeset in solve_config.timesets_used_by_solves[solve]:
658
+ timeline = self.timesets__timeline[timeset]
659
+ if timeline not in timelines_list:
660
+ if len(timelines_list) != 0:
661
+ message = (
662
+ f"solve '{solve}' sets new_stepduration but its "
663
+ f"timesets resolve to more than one timeline; "
664
+ f"new_stepduration requires a single shared "
665
+ f"timeline per solve."
666
+ )
667
+ logger.error(message)
668
+ raise FlexToolConfigError(message)
669
+ timelines_list.append(timeline)
670
+ # Pre-build timeline duration lookup for O(1) access per row.
671
+ timeline_duration_lookup: dict[str, int] = {}
672
+ for timeline in timelines_list:
673
+ new_timeline = self.timelines[timeline]
674
+ for timeline_row in new_timeline:
675
+ timeline_duration_lookup[timeline_row[0]] = int(
676
+ float(timeline_row[1])
677
+ )
678
+
679
+ # node-name → constant-inflow value (used to append per-step
680
+ # contributions to pt_node_inflow below).
681
+ node__inflow: list[list[str]] = []
682
+ p_node_path = wf / "input" / "p_node.csv"
683
+ p_node_key = _provider_key(p_node_path)
684
+ p_node_frame = _provider_get_or_read(
685
+ provider, p_node_key, p_node_path,
686
+ )
687
+ if p_node_frame is not None and p_node_frame.height > 0:
688
+ # Expected (node, nodeParam, p_node); the 'inflow' rows
689
+ # have value in column index 2.
690
+ for row in p_node_frame.iter_rows():
691
+ if len(row) >= 3 and row[1] == "inflow":
692
+ node__inflow.append([row[0], row[2]])
693
+
694
+ for timeseries in timeseries_map:
695
+ src_path = wf / "input" / timeseries
696
+ dst_path = wf / "solve_data" / timeseries
697
+ src_key = _provider_key(src_path)
698
+ dst_key = _provider_key(dst_path)
699
+ src_frame = _provider_get_or_read(provider, src_key, src_path)
700
+ if src_frame is None:
701
+ continue
702
+ headers = src_frame.columns
703
+ try:
704
+ time_index = headers.index("time")
705
+ except ValueError:
706
+ # No time column — pass through verbatim.
707
+ provider.put(dst_key, src_frame)
708
+ continue
709
+ # Row-by-row state machine port of the legacy CSV loop.
710
+ rows_iter = iter(src_frame.iter_rows())
711
+ out_rows: list[list[Any]] = []
712
+ timeline_for_bypass = (
713
+ timelines_list[0] if timelines_list else None
714
+ )
715
+ try:
716
+ while True:
717
+ datain = next(rows_iter)
718
+ timeline_step_duration = (
719
+ timeline_duration_lookup.get(datain[time_index])
720
+ )
721
+ if timeline_step_duration is not None:
722
+ values: list[float] = []
723
+ params = list(datain[0:time_index])
724
+ row = list(datain[0 : time_index + 1])
725
+ values.append(float(datain[time_index + 1]))
726
+ if (
727
+ time_index >= 2
728
+ and datain[1] != "storage_state_reference_value"
729
+ ) or time_index < 2:
730
+ for _i in range(timeline_step_duration - 1):
731
+ try:
732
+ datain = next(rows_iter)
733
+ except StopIteration:
734
+ break
735
+ if list(datain[0:time_index]) != params:
736
+ message = (
737
+ "Cannot find the same "
738
+ "timesteps in input data as "
739
+ "in timeline for file "
740
+ + timeseries
741
+ + " after "
742
+ + str(row[-1])
743
+ )
744
+ logger.error(message)
745
+ raise FlexToolConfigError(message)
746
+ values.append(
747
+ float(datain[time_index + 1])
748
+ )
749
+
750
+ if timeseries_map[timeseries] == "average":
751
+ out_value = round(
752
+ sum(values) / len(values), 6
753
+ )
754
+ else:
755
+ out_value = sum(values)
756
+ row.append(out_value)
757
+ out_rows.append(row)
758
+ else:
759
+ # Bypass aggregation for
760
+ # storage_state_reference_value rows — pin to
761
+ # the new timeline's nearest step at-or-before
762
+ # the original target step.
763
+ if (
764
+ time_index >= 2
765
+ and datain[1] == "storage_state_reference_value"
766
+ and timeline_for_bypass is not None
767
+ ):
768
+ original_tl = self.original_timeline[
769
+ timeline_for_bypass
770
+ ]
771
+ new_timeline = self.timelines[
772
+ timeline_for_bypass
773
+ ]
774
+ counter = 0
775
+ current_index = 0
776
+ for timestep in self.timelines[original_tl]:
777
+ if datain[2] == timestep[0]:
778
+ current_index = counter
779
+ counter += 1
780
+ found = False
781
+ new_index = None
782
+ for timestep in reversed(
783
+ self.timelines[original_tl][
784
+ 0 : current_index + 1
785
+ ]
786
+ ):
787
+ for timeline_row in new_timeline:
788
+ if timeline_row[0] == timestep[0]:
789
+ new_index = timeline_row[0]
790
+ found = True
791
+ break
792
+ if found:
793
+ break
794
+ if found:
795
+ row = list(datain[0:time_index])
796
+ row.append(new_index)
797
+ row.append(datain[time_index + 1])
798
+ out_rows.append(row)
799
+ except StopIteration:
800
+ pass
801
+
802
+ # Append per-step inflow contributions from p_node to
803
+ # pt_node_inflow only; scaled by each new step's duration.
804
+ if timeseries == "pt_node_inflow.csv" and node__inflow:
805
+ for timeline in timelines_list:
806
+ new_timeline = self.timelines[timeline]
807
+ for node__value in node__inflow:
808
+ for timeline_row in new_timeline:
809
+ tl_step_duration = int(float(timeline_row[1]))
810
+ value = (
811
+ float(node__value[1]) * tl_step_duration
812
+ )
813
+ out_rows.append(
814
+ [node__value[0], timeline_row[0], value]
815
+ )
816
+
817
+ provider.put(
818
+ dst_key,
819
+ _build_frame_with_headers(headers, out_rows),
820
+ )
821
+
822
+
823
+ # ---------------------------------------------------------------------------
824
+ # Helpers (private)
825
+ # ---------------------------------------------------------------------------
826
+
827
+
828
+ def _provider_get_or_read(
829
+ provider: "FlexDataProvider | None",
830
+ key: str,
831
+ path: "Path", # noqa: ARG001 — kept for symmetry with call sites
832
+ ) -> "pl.DataFrame | None":
833
+ """Return the frame for *key* from the Provider.
834
+
835
+ Provider-only — the disk fallback was deleted in Step 2.5.
836
+ Returns ``None`` when the Provider is missing or doesn't carry
837
+ *key*. Callers that previously seeded inputs to disk without
838
+ populating the Provider must now seed the Provider directly.
839
+ """
840
+ if provider is None:
841
+ return None
842
+ if not provider.has(key):
843
+ return None
844
+ return provider.get(key)
845
+
846
+
847
+ def _build_frame_with_headers(
848
+ headers: list[str], rows: list[list],
849
+ ) -> "pl.DataFrame":
850
+ """Build an all-Utf8 polars frame matching *headers* with *rows*.
851
+
852
+ Each row's values are stringified verbatim (``str(v)``) so the
853
+ frame round-trips through :meth:`polars.DataFrame.write_csv`
854
+ byte-identical to the legacy ``csv.writer.writerow`` output.
855
+ Rows shorter than ``headers`` are padded with empty strings;
856
+ rows longer are truncated.
857
+ """
858
+ n = len(headers)
859
+ cols: list[list[str]] = [[] for _ in range(n)]
860
+ for row in rows:
861
+ for i in range(n):
862
+ if i < len(row):
863
+ v = row[i]
864
+ cols[i].append("" if v is None else str(v))
865
+ else:
866
+ cols[i].append("")
867
+ schema = {h: pl.Utf8 for h in headers}
868
+ if not rows:
869
+ return pl.DataFrame(schema=schema)
870
+ return pl.DataFrame(
871
+ {headers[i]: cols[i] for i in range(n)},
872
+ schema=schema,
873
+ )
874
+
875
+
876
+ def _decode_rp_weights(rp_weights_raw: dict) -> dict:
877
+ """Decode :attr:`TimelineConfig.rp_weights`.
878
+
879
+ Spine API quirk: a ``Map`` of ``Map`` may come back from
880
+ ``params_to_dict`` either as :class:`spinedb_api.Map` (when the
881
+ outer Map is read directly) or as ``list[tuple]`` (when the outer
882
+ is flattened via ``convert_map_to_table``). Both shapes must
883
+ decode to the same nested ``{base: {rep: weight}}`` dict.
884
+
885
+ Mirrors lines 100-130 of the flextool source.
886
+ """
887
+ rp_weights: dict = {}
888
+ for timeset_name, nested_map in rp_weights_raw.items():
889
+ if isinstance(nested_map, api.Map):
890
+ weight_dict: dict[str, dict[str, float]] = {}
891
+ for i, base_key in enumerate(nested_map.indexes):
892
+ inner = nested_map.values[i]
893
+ if isinstance(inner, api.Map):
894
+ weight_dict[str(base_key)] = {
895
+ str(k): float(v)
896
+ for k, v in zip(inner.indexes, inner.values)
897
+ }
898
+ rp_weights[timeset_name] = weight_dict
899
+ elif isinstance(nested_map, list):
900
+ # Two list-of-entry shapes coming from ``params_to_dict``:
901
+ # (a) Flat triples [base, rep, weight] — what
902
+ # ``convert_map_to_table`` produces when a 2-level Map
903
+ # is flattened.
904
+ # (b) Nested [(base, inner_map_or_list), ...] — emitted when
905
+ # only the outer Map is flattened.
906
+ # Both decode to {base: {rep: weight}}.
907
+ weight_dict: dict[str, dict[str, float]] = {}
908
+ for entry in nested_map:
909
+ if len(entry) >= 3 and not isinstance(entry[1], (list, api.Map)):
910
+ # (a) flat triple
911
+ base_key = str(entry[0])
912
+ rep_key = str(entry[1])
913
+ weight = float(entry[2])
914
+ weight_dict.setdefault(base_key, {})[rep_key] = weight
915
+ elif len(entry) >= 2:
916
+ # (b) [base, inner]
917
+ base_key = str(entry[0])
918
+ inner_data = entry[1]
919
+ if isinstance(inner_data, list):
920
+ weight_dict[base_key] = {
921
+ str(k): float(v) for k, v in inner_data
922
+ }
923
+ elif isinstance(inner_data, api.Map):
924
+ weight_dict[base_key] = {
925
+ str(k): float(v)
926
+ for k, v in zip(
927
+ inner_data.indexes, inner_data.values
928
+ )
929
+ }
930
+ if weight_dict:
931
+ rp_weights[timeset_name] = weight_dict
932
+ return rp_weights
933
+
934
+
935
+ def _decode_timeset_weights(
936
+ timeset_weights_raw: dict,
937
+ ) -> dict[str, dict[str, float]]:
938
+ """Decode :attr:`TimelineConfig.timeset_weights`.
939
+
940
+ Like :func:`_decode_rp_weights` but flat (one level of Map).
941
+ Handles both ``api.Map`` and ``list[tuple]`` shapes.
942
+
943
+ Mirrors lines 132-146 of the flextool source.
944
+ """
945
+ timeset_weights: dict[str, dict[str, float]] = {}
946
+ for timeset_name, flat_map in timeset_weights_raw.items():
947
+ if isinstance(flat_map, api.Map):
948
+ timeset_weights[timeset_name] = {
949
+ str(k): float(v)
950
+ for k, v in zip(flat_map.indexes, flat_map.values)
951
+ }
952
+ elif isinstance(flat_map, list):
953
+ timeset_weights[timeset_name] = {
954
+ str(entry[0]): float(entry[1])
955
+ for entry in flat_map
956
+ if len(entry) >= 2
957
+ }
958
+ return timeset_weights
959
+
960
+
961
+ # ---------------------------------------------------------------------------
962
+ # Free functions (pure — no side effects beyond return values / file I/O)
963
+ # ---------------------------------------------------------------------------
964
+
965
+
966
+ def get_active_time(
967
+ current_solve: str,
968
+ timesets_used_by_solves: dict,
969
+ timesets: dict,
970
+ timelines: dict,
971
+ timesets__timelines: dict,
972
+ ) -> defaultdict:
973
+ """Map periods to their corresponding timeline entries for a solve.
974
+
975
+ Returns a defaultdict mapping period IDs to lists of
976
+ :class:`ActiveTimeEntry` records. The recursive solve builder
977
+ (Γ.8.C) calls this at every node of its tree walk; the shape is
978
+ load-bearing for downstream :func:`make_step_jump` and
979
+ :func:`make_period_block` callers.
980
+
981
+ Args:
982
+ current_solve: Active solve name.
983
+ timesets_used_by_solves: ``solve -> [(period, timeset), ...]``.
984
+ timesets: ``timeset -> [(start, count), ...]`` (this is the
985
+ ``timeset_durations`` field of :class:`TimelineConfig`,
986
+ despite the parameter name).
987
+ timelines: ``timeline -> [(timestep, duration), ...]``.
988
+ timesets__timelines: ``timeset -> timeline``.
989
+ """
990
+ active_time: defaultdict = defaultdict(list)
991
+
992
+ if current_solve not in timesets_used_by_solves:
993
+ raise ValueError(
994
+ f"{current_solve}: this solve does not have period_timeset "
995
+ "defined. Check that it has period_timeset parameter "
996
+ "defined and the names of period and timeset are spelled "
997
+ "correctly (case sensitive). Check that the alternative is "
998
+ "included in the scenario."
999
+ )
1000
+
1001
+ # Pre-build index for O(1) timestep-to-position lookup per timeline.
1002
+ timeline_index_cache: dict[str, dict[str, int]] = {}
1003
+
1004
+ # Track which (timeset, block-start) first claimed each (period,
1005
+ # timestep) so an overlap can name the two colliding blocks.
1006
+ seen_by_period: dict[str, dict[str, tuple[str, str]]] = defaultdict(dict)
1007
+ # (period, timestep, incumbent (timeset, block_start), colliding (…)).
1008
+ overlaps: list[tuple[str, str, tuple[str, str], tuple[str, str]]] = []
1009
+
1010
+ for period, timeset_id in timesets_used_by_solves[current_solve]:
1011
+ timeline_id = timesets__timelines.get(timeset_id)
1012
+ if not timeline_id:
1013
+ continue
1014
+ timeline_data = timelines.get(timeline_id, [])
1015
+ if not timeline_data:
1016
+ continue
1017
+ if timeline_id not in timeline_index_cache:
1018
+ timeline_index_cache[timeline_id] = {
1019
+ time_val: idx
1020
+ for idx, (time_val, _) in enumerate(timeline_data)
1021
+ }
1022
+ time_val_to_idx = timeline_index_cache[timeline_id]
1023
+ seen = seen_by_period[period]
1024
+ for start_time, duration in timesets[timeset_id]:
1025
+ idx = time_val_to_idx.get(start_time)
1026
+ if idx is not None:
1027
+ for step in range(int(float(duration))):
1028
+ if idx + step < len(timeline_data):
1029
+ entry = timeline_data[idx + step]
1030
+ # An overlap means a single calendar timestep would
1031
+ # belong to two representative-period blocks at once.
1032
+ # We cannot silently collapse it: the timeline
1033
+ # (``steps_in_use``) needs each timestep exactly once,
1034
+ # but its representation weight is emitted per block
1035
+ # (:func:`_compute_rp_frames` → ``timestep_weight``),
1036
+ # so a collapse would keep one block's weight and drop
1037
+ # the other — a silently wrong annualisation rather
1038
+ # than the sum. Refuse and point at the source data.
1039
+ if entry[0] in seen:
1040
+ overlaps.append(
1041
+ (period, entry[0], seen[entry[0]],
1042
+ (timeset_id, start_time)))
1043
+ continue
1044
+ seen[entry[0]] = (timeset_id, start_time)
1045
+ active_time[period].append(
1046
+ ActiveTimeEntry(
1047
+ timestep=entry[0],
1048
+ index=idx + step,
1049
+ duration=entry[1],
1050
+ )
1051
+ )
1052
+
1053
+ if overlaps:
1054
+ n = len(overlaps)
1055
+
1056
+ def _block(b: tuple[str, str]) -> str:
1057
+ ts, start = b
1058
+ return f"block starting {start!r}" + (
1059
+ f" (timeset {ts!r})" if ts != overlaps[0][3][0] else "")
1060
+
1061
+ sample = "; ".join(
1062
+ f"period {p!r} timestep {t!r} is in both {_block(a)} and "
1063
+ f"{_block(c)}"
1064
+ for p, t, a, c in overlaps[:5]
1065
+ )
1066
+ more = "" if n <= 5 else f" (+{n - 5} more)"
1067
+ bad_timesets = sorted(
1068
+ {o[2][0] for o in overlaps} | {o[3][0] for o in overlaps})
1069
+ raise ValueError(
1070
+ f"{current_solve}: overlapping timeset_duration blocks put the "
1071
+ f"same timestep into more than one representative period. "
1072
+ f"{n} overlapping timestep(s) in timeset(s) "
1073
+ f"{', '.join(repr(ts) for ts in bad_timesets)}: {sample}{more}. "
1074
+ "Each timestep may belong to exactly one representative period — "
1075
+ "adjust the timeset_duration start/length values so the blocks "
1076
+ "do not overlap (a shorter block wholly inside a longer one, or "
1077
+ "two blocks whose windows intersect, both trigger this)."
1078
+ )
1079
+
1080
+ if not active_time:
1081
+ raise ValueError(
1082
+ f"{current_solve}: Failed to map timeset to timeline. "
1083
+ "Check that all timeset entities have timeline parameter "
1084
+ "defined and the name of the timeline is spelled correctly "
1085
+ "(case sensitive). Check that the alternative of the "
1086
+ "timeline parameter is included in the modelled scenario."
1087
+ )
1088
+
1089
+ return active_time
1090
+
1091
+
1092
+ def make_step_jump(
1093
+ active_time_list: dict,
1094
+ period__branch: list[tuple],
1095
+ solve_branch__time_branch_list: list[tuple],
1096
+ ) -> list[tuple]:
1097
+ """Build a list of step-jump entries for the solver.
1098
+
1099
+ Each entry describes the jump from one simulation step to the
1100
+ next, including cross-period jumps. Stochastic branches use the
1101
+ last-realized-step on a sibling branch as the predecessor.
1102
+ """
1103
+ step_lengths: list[tuple] = []
1104
+ period_start_pos = 0
1105
+ period_counter = -1
1106
+ first_period_name = list(active_time_list)[0]
1107
+ last_period_name = list(active_time_list)[-1]
1108
+ for period, active_time in reversed(active_time_list.items()):
1109
+ period_counter -= 1
1110
+ period_last = len(active_time)
1111
+ block_last = len(active_time) - 1
1112
+ if period == first_period_name:
1113
+ previous_period_name = last_period_name
1114
+ else:
1115
+ previous_period_name = list(active_time_list)[period_counter]
1116
+ for i, step in enumerate(reversed(active_time)):
1117
+ j = period_last - i - 1
1118
+ if j > 0:
1119
+ jump = active_time[j].index - active_time[j - 1].index
1120
+ if jump > 1:
1121
+ step_lengths.insert(
1122
+ period_start_pos,
1123
+ (
1124
+ period,
1125
+ step.timestep,
1126
+ active_time[j - 1].timestep,
1127
+ active_time[block_last].timestep,
1128
+ period,
1129
+ active_time[j - 1].timestep,
1130
+ jump,
1131
+ ),
1132
+ )
1133
+ block_last = j - 1
1134
+ else:
1135
+ step_lengths.insert(
1136
+ period_start_pos,
1137
+ (
1138
+ period,
1139
+ step.timestep,
1140
+ active_time[j - 1].timestep,
1141
+ active_time[j - 1].timestep,
1142
+ period,
1143
+ active_time[j - 1].timestep,
1144
+ jump,
1145
+ ),
1146
+ )
1147
+ else:
1148
+ if (period, period) not in period__branch:
1149
+ original_period = None
1150
+ for pb in period__branch:
1151
+ if pb[1] == period:
1152
+ original_period = pb[0]
1153
+ if (
1154
+ original_period is not None
1155
+ and (original_period, original_period)
1156
+ in period__branch
1157
+ and original_period in active_time_list
1158
+ ):
1159
+ jump = (
1160
+ active_time[j].index - active_time[-1].index
1161
+ )
1162
+ step_lengths.insert(
1163
+ period_start_pos,
1164
+ (
1165
+ period,
1166
+ step.timestep,
1167
+ active_time[j - 1].timestep,
1168
+ active_time[block_last].timestep,
1169
+ period,
1170
+ active_time_list[period][-1].timestep,
1171
+ jump,
1172
+ ),
1173
+ )
1174
+ elif (
1175
+ original_period is not None
1176
+ and (original_period, original_period)
1177
+ in period__branch
1178
+ ):
1179
+ jump = (
1180
+ active_time[j].index - active_time[-1].index
1181
+ )
1182
+ step_lengths.insert(
1183
+ period_start_pos,
1184
+ (
1185
+ period,
1186
+ step.timestep,
1187
+ active_time[j - 1].timestep,
1188
+ active_time[block_last].timestep,
1189
+ period,
1190
+ active_time_list[period][-1].timestep,
1191
+ jump,
1192
+ ),
1193
+ )
1194
+ else:
1195
+ time_branch = None
1196
+ for sb_tb in solve_branch__time_branch_list:
1197
+ if sb_tb[0] == period:
1198
+ time_branch = sb_tb[1]
1199
+ past = False
1200
+ found = False
1201
+ previous_period_with_branch = None
1202
+ for solve_period, _a_t in reversed(
1203
+ active_time_list.items()
1204
+ ):
1205
+ if past:
1206
+ for sb_tb in solve_branch__time_branch_list:
1207
+ if (
1208
+ sb_tb[0] == solve_period
1209
+ and sb_tb[1] == time_branch
1210
+ ):
1211
+ previous_period_with_branch = (
1212
+ solve_period
1213
+ )
1214
+ found = True
1215
+ if found:
1216
+ break
1217
+ else:
1218
+ if solve_period == period:
1219
+ past = True
1220
+ jump = (
1221
+ active_time[j].index
1222
+ - active_time_list[
1223
+ previous_period_with_branch
1224
+ ][-1].index
1225
+ )
1226
+ step_lengths.insert(
1227
+ period_start_pos,
1228
+ (
1229
+ period,
1230
+ step.timestep,
1231
+ active_time[j - 1].timestep,
1232
+ active_time[block_last].timestep,
1233
+ previous_period_with_branch,
1234
+ active_time_list[
1235
+ previous_period_with_branch
1236
+ ][-1].timestep,
1237
+ jump,
1238
+ ),
1239
+ )
1240
+ else:
1241
+ jump = (
1242
+ active_time[j].index
1243
+ - active_time_list[previous_period_name][-1].index
1244
+ )
1245
+ step_lengths.insert(
1246
+ period_start_pos,
1247
+ (
1248
+ period,
1249
+ step.timestep,
1250
+ active_time[j - 1].timestep,
1251
+ active_time[block_last].timestep,
1252
+ previous_period_name,
1253
+ active_time_list[previous_period_name][
1254
+ -1
1255
+ ].timestep,
1256
+ jump,
1257
+ ),
1258
+ )
1259
+ return step_lengths
1260
+
1261
+
1262
+ def make_period_block(
1263
+ active_time_list: dict,
1264
+ ) -> tuple[list[tuple], list[tuple]]:
1265
+ """Build period_block_time and period_block_succ data.
1266
+
1267
+ Blocks are maximal contiguous runs of active steps in the timeline
1268
+ — same detection rule as :func:`make_step_jump` (jump > 1 starts a
1269
+ new block). Used by the ``bind_intraperiod_blocks`` storage
1270
+ binding method.
1271
+
1272
+ Returns:
1273
+ period_block_time: ``[(period, block_first, step), ...]``,
1274
+ one row per active step.
1275
+ period_block_succ: ``[(period, block_first, block_first_next),
1276
+ ...]``, cyclic within period.
1277
+ """
1278
+ period_block_time: list[tuple] = []
1279
+ period_block_succ: list[tuple] = []
1280
+
1281
+ for period, active_time in active_time_list.items():
1282
+ if not active_time:
1283
+ continue
1284
+ block_firsts_in_order: list[str] = [active_time[0].timestep]
1285
+ cur_block_first = active_time[0].timestep
1286
+ for j, step in enumerate(active_time):
1287
+ if (
1288
+ j > 0
1289
+ and active_time[j].index - active_time[j - 1].index > 1
1290
+ ):
1291
+ cur_block_first = step.timestep
1292
+ block_firsts_in_order.append(cur_block_first)
1293
+ period_block_time.append(
1294
+ (period, cur_block_first, step.timestep)
1295
+ )
1296
+
1297
+ n_blocks = len(block_firsts_in_order)
1298
+ for i, b_first in enumerate(block_firsts_in_order):
1299
+ b_next = block_firsts_in_order[(i + 1) % n_blocks]
1300
+ period_block_succ.append((period, b_first, b_next))
1301
+
1302
+ return period_block_time, period_block_succ
1303
+
1304
+
1305
+ def make_steps(steplist: list, start: int, stop: int) -> list:
1306
+ """Return a slice of *steplist* from *start* to *stop* (inclusive)."""
1307
+ active_step = start
1308
+ steps: list = []
1309
+ while active_step <= stop:
1310
+ steps.append(steplist[active_step])
1311
+ active_step += 1
1312
+ return steps
1313
+
1314
+
1315
+ def make_timeset_timeline(
1316
+ steplist: list, start: str, length: float
1317
+ ) -> list:
1318
+ """Build a timeset timeline from a steplist.
1319
+
1320
+ Returns a slice of *steplist* starting at *start* and continuing
1321
+ for ``ceil(length)`` steps.
1322
+ """
1323
+ result: list = []
1324
+ startnum = steplist.index(start)
1325
+ for i in range(startnum, math.ceil(startnum + float(length))):
1326
+ result.append(steplist[i])
1327
+ return result
1328
+
1329
+
1330
+ def separate_period_and_timeseries_data(
1331
+ timelines: dict,
1332
+ solve__period__timeset: dict,
1333
+ *,
1334
+ provider: "FlexDataProvider",
1335
+ work_folder: "Path | None" = None,
1336
+ ) -> None:
1337
+ """Split ``input/pdt_*.csv`` into per-period and per-timeseries shards.
1338
+
1339
+ For each ``pdt_<X>.csv`` (currently ``pdt_commodity.csv`` and
1340
+ ``pdt_group.csv``), put two derived frames back onto the Provider:
1341
+
1342
+ * ``input/pd_<X>`` — rows whose third column is a period identifier.
1343
+ * ``input/pt_<X>`` — rows whose third column is a timestep identifier.
1344
+
1345
+ The discriminator is column-2 membership in the union of all
1346
+ timesteps (across all timelines) vs. all periods (from the
1347
+ ``solve__period__timeset`` map).
1348
+
1349
+ *provider* is required: the cascade's Provider supplies the
1350
+ ``input/pdt_<X>`` frames and receives the two derived shards
1351
+ keyed under the same canonical ``<parent>/<stem>`` convention
1352
+ other writer-port modules use. The Provider is consulted first
1353
+ for the source frame; ``_provider_open`` falls back to disk for
1354
+ the off-cascade test harness only.
1355
+ """
1356
+ wf = Path(work_folder) if work_folder is not None else Path.cwd()
1357
+
1358
+ inputfiles = ["pdt_commodity.csv", "pdt_group.csv"]
1359
+ timesteps: set[str] = set()
1360
+ for timeline in timelines.values():
1361
+ for step in timeline:
1362
+ timesteps.add(step[0])
1363
+ periods: set[str] = set()
1364
+ for period__timesets in solve__period__timeset.values():
1365
+ for period__timeset in period__timesets:
1366
+ periods.add(period__timeset[0])
1367
+
1368
+ for inputfile in inputfiles:
1369
+ suffix = inputfile[4:-4] # e.g. "commodity"
1370
+ in_path = wf / "input" / inputfile
1371
+ in_key = _provider_key(in_path)
1372
+ pd_key = _provider_key(wf / "input" / f"pd_{suffix}.csv")
1373
+ pt_key = _provider_key(wf / "input" / f"pt_{suffix}.csv")
1374
+
1375
+ if not provider.has(in_key):
1376
+ # No source — skip the split. Downstream consumers tolerate
1377
+ # missing keys. Step 2.5: Provider-only — no disk fallback.
1378
+ continue
1379
+ df = provider.get(in_key)
1380
+ key_col = df.columns[2]
1381
+ pt_frame = df.filter(pl.col(key_col).is_in(timesteps))
1382
+ pd_frame = _rename_pdt_to_pd(
1383
+ df.filter(pl.col(key_col).is_in(periods))
1384
+ )
1385
+ provider.put(pt_key, pt_frame)
1386
+ provider.put(pd_key, pd_frame)
1387
+
1388
+
1389
+ def _rename_pdt_to_pd(df: "pl.DataFrame") -> "pl.DataFrame":
1390
+ """Rename a ``pdt_<X>`` frame's last-two columns to the ``pd_<X>``
1391
+ convention: ``[..., 'period', 'pd_<X>_value']``.
1392
+
1393
+ Legacy ``write_csv`` emitted ``headers[:-2] + ['period',
1394
+ f'pd_{headers[-1][3:]}']``; reproduce the same column-name
1395
+ transform on the frame so downstream readers (which key by
1396
+ position) see byte-identical column orderings.
1397
+ """
1398
+ cols = df.columns
1399
+ if len(cols) < 2:
1400
+ return df
1401
+ new_names = list(cols)
1402
+ new_names[-2] = "period"
1403
+ last = cols[-1]
1404
+ new_names[-1] = f"pd_{last[3:]}" if len(last) > 3 else last
1405
+ return df.rename(dict(zip(cols, new_names)))
1406
+
1407
+
1408
+ __all__ = [
1409
+ "TimelineConfig",
1410
+ "get_active_time",
1411
+ "make_step_jump",
1412
+ "make_period_block",
1413
+ "make_steps",
1414
+ "make_timeset_timeline",
1415
+ "separate_period_and_timeseries_data",
1416
+ ]