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,1249 @@
1
+ """Per-solve preprocessing emitters — timeline / period / branch / RP frames.
2
+
3
+ This module owns the ~36 ``emit_*`` functions that build the
4
+ ``solve_data/*.csv`` artefacts each sub-solve consumes:
5
+
6
+ * Timeline & period: ``steps_in_timeline``, ``steps_in_use``,
7
+ ``steps_complete_solve``, ``step_previous``, ``period_block_time``,
8
+ ``period_block_succ``, ``p_years_represented``,
9
+ ``period_with_history``, ``p_discount_years``,
10
+ ``realized_invest_periods_of_current_solve``,
11
+ ``invest_periods_of_current_solve``, ``period_last``,
12
+ ``period_first_of_solve``, ``period_first``, ``p_model``,
13
+ ``p_nested_model``, ``solve_current``, ``first_timesteps``,
14
+ ``last_timesteps``,
15
+ ``realized_dispatch``, ``fix_storage_timesteps``.
16
+ * Branch: ``period__branch``,
17
+ ``solve_branch_weight``, ``solve_branch__time_branch``.
18
+ * Empty / seed: ``p_entity_invested``, ``p_entity_divested``,
19
+ ``p_entity_period_existing_capacity``, the three
20
+ rolling-accumulator seeds, the four fix-storage seeds.
21
+ * Headers / timesets: header-only seeds, ``timesets_in_use``,
22
+ ``timesets__timeline``.
23
+ * Scaling / delay / RP: ``p_use_row_scaling``,
24
+ ``scale_the_objective``, ``delay_duration``,
25
+ ``dtt__delay_duration``, and the two representative-period CSVs
26
+ (``rp_weights``, ``rp_base_chain``, ``rp_base_first``,
27
+ ``rp_base_last``, ``rp_block_first``, ``rp_block_last``,
28
+ ``rp_block_start_last``, ``timestep_weight``).
29
+
30
+ Each ``emit_*`` builds an all-``Utf8`` polars frame (inline or via a
31
+ shared ``_compute_*`` helper) and registers it on the Provider with the
32
+ dual ``basename`` + ``parent/basename`` keys downstream readers expect.
33
+ When the cascade later flushes to disk (``--csv-dump``), the CRLF line
34
+ terminator preserved through the snapshot path matches the legacy
35
+ ``csv.writer`` + ``newline=""`` byte shape exactly.
36
+
37
+ The scaling CSVs use ``%.17g`` repr formatting for round-trip safety.
38
+ ``emit_rp_data`` preserves the canonical ordering and the
39
+ ``weight > 1e-10`` epsilon filter. ``emit_timeset_cost_weight`` uses
40
+ ``%.10g`` for the normalised float.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import math
45
+ from collections import defaultdict
46
+ from pathlib import Path
47
+ from typing import Any
48
+
49
+ import polars as pl
50
+
51
+ from flextool.engine_polars import _provider_keys as K
52
+ from flextool.engine_polars._emit_provider_io import _emit
53
+
54
+
55
+ def _emit_path(provider, path: "Path | str", df: pl.DataFrame) -> None:
56
+ """Provider-emit *df* under both ``basename`` and ``parent/basename``.
57
+
58
+ The dual-key registration matches downstream readers' lookup
59
+ conventions; either form resolves to the same frame.
60
+ """
61
+ p = Path(path)
62
+ parent = p.parent.name
63
+ key = f"{parent}/{p.name}" if parent else p.name
64
+ _emit(provider, key, df)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Frame construction — :func:`_to_utf8_frame` builds an all-``Utf8``
69
+ # polars frame from a header tuple and a row list. Every value is taken
70
+ # verbatim if already a string, otherwise stringified via ``str(v)`` —
71
+ # matching ``csv.writer``'s behaviour for the types this module emits
72
+ # (``int`` 0/1 flags, ``float`` weights, plain strings). Schema is
73
+ # locked to ``Utf8`` per column so polars' ``write_csv`` does no extra
74
+ # numeric formatting or quoting at flush time.
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ def _to_utf8_frame(
79
+ headers: tuple[str, ...],
80
+ rows: list[tuple[Any, ...]],
81
+ ) -> pl.DataFrame:
82
+ """Build an all-``Utf8`` polars frame from a header tuple + row list.
83
+
84
+ Each row cell is taken as-is when already a string, otherwise
85
+ rendered via ``str(v)`` — matching ``csv.writer``'s exact byte
86
+ output for ``int`` / ``float`` cells.
87
+ """
88
+ cols: dict[str, list[str]] = {h: [] for h in headers}
89
+ for row in rows:
90
+ for h, v in zip(headers, row):
91
+ cols[h].append(v if isinstance(v, str) else str(v))
92
+ return pl.DataFrame(cols, schema={h: pl.Utf8 for h in headers})
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Group A — timeline writers
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def derive_full_timelines(
101
+ stochastic_timesteps: list[tuple[str, str]],
102
+ period__timesets_in_this_solve: list[tuple[str, str]],
103
+ timesets__timeline: dict[str, str],
104
+ timelines: dict[str, list[tuple[str, ...]]],
105
+ ) -> pl.DataFrame:
106
+ """Build the canonical ``steps_in_timeline`` frame (period, step)."""
107
+ rows: list[tuple[Any, ...]] = []
108
+ for period__timeset in period__timesets_in_this_solve:
109
+ for timeline in timelines:
110
+ for timeset_in_timeline, tt in timesets__timeline.items():
111
+ if period__timeset[1] == timeset_in_timeline:
112
+ if timeline == tt:
113
+ for item in timelines[timeline]:
114
+ rows.append((period__timeset[0], item[0]))
115
+ for step in stochastic_timesteps:
116
+ rows.append((step[0], step[1]))
117
+ return _to_utf8_frame(("period", "step"), rows)
118
+
119
+
120
+ def emit_full_timelines(
121
+ stochastic_timesteps: list[tuple[str, str]],
122
+ period__timesets_in_this_solve: list[tuple[str, str]],
123
+ timesets__timeline: dict[str, str],
124
+ timelines: dict[str, list[tuple[str, ...]]],
125
+ filename: str,
126
+ *, provider,
127
+ ) -> None:
128
+ """Emit ``full_timelines`` to the Provider."""
129
+ _emit_path(provider, filename,
130
+ derive_full_timelines(
131
+ stochastic_timesteps,
132
+ period__timesets_in_this_solve,
133
+ timesets__timeline,
134
+ timelines,
135
+ ))
136
+
137
+
138
+ def derive_active_timelines(
139
+ timeline: dict[str, list[tuple[str, ...]]],
140
+ complete: bool = False,
141
+ ) -> pl.DataFrame:
142
+ """Build the canonical ``steps_in_use`` / ``steps_complete_solve``
143
+ frame (period, step, [complete_]step_duration).
144
+ """
145
+ header_dur = "complete_step_duration" if complete else "step_duration"
146
+ rows: list[tuple[Any, ...]] = []
147
+ for period_name, period in timeline.items():
148
+ for item in period:
149
+ rows.append((period_name, item.timestep, str(item.duration)))
150
+ return _to_utf8_frame(("period", "step", header_dur), rows)
151
+
152
+
153
+ def emit_active_timelines(
154
+ timeline: dict[str, list[tuple[str, ...]]],
155
+ filename: str,
156
+ complete: bool = False,
157
+ *, provider,
158
+ ) -> None:
159
+ """Emit ``active_timelines`` to the Provider."""
160
+ _emit_path(provider, filename,
161
+ derive_active_timelines(timeline, complete))
162
+
163
+
164
+ _STEP_JUMP_HEADERS = (
165
+ "period", "time", "previous", "previous_within_timeset",
166
+ "previous_period", "previous_within_solve", "jump",
167
+ )
168
+
169
+
170
+ def derive_step_jump(step_lengths: list[tuple[str, ...]]) -> pl.DataFrame:
171
+ """Build the canonical ``step_previous`` frame (7 Utf8 columns)."""
172
+ return _to_utf8_frame(_STEP_JUMP_HEADERS, list(step_lengths))
173
+
174
+
175
+ def emit_step_jump(
176
+ step_lengths: list[tuple[str, ...]],
177
+ *, provider,
178
+ ) -> None:
179
+ """Emit ``step_jump`` to the Provider."""
180
+ _emit(provider, "solve_data/step_previous.csv",
181
+ derive_step_jump(step_lengths))
182
+
183
+
184
+ def derive_period_block_time(
185
+ period_block_time: list[tuple],
186
+ ) -> pl.DataFrame:
187
+ """Build the ``period_block_time`` frame (period, block_first, step)."""
188
+ return _to_utf8_frame(
189
+ ("period", "block_first", "step"), list(period_block_time),
190
+ )
191
+
192
+
193
+ def derive_period_block_succ(
194
+ period_block_succ: list[tuple],
195
+ ) -> pl.DataFrame:
196
+ """Build the ``period_block_succ`` frame
197
+ (period, block_first, block_first_next).
198
+ """
199
+ return _to_utf8_frame(
200
+ ("period", "block_first", "block_first_next"),
201
+ list(period_block_succ),
202
+ )
203
+
204
+
205
+ def emit_period_block(
206
+ period_block_time: list[tuple],
207
+ period_block_succ: list[tuple],
208
+ *, provider,
209
+ ) -> None:
210
+ """Emit ``period_block`` to the Provider."""
211
+ _emit(provider, "solve_data/period_block_time.csv",
212
+ derive_period_block_time(period_block_time))
213
+ _emit(provider, "solve_data/period_block_succ.csv",
214
+ derive_period_block_succ(period_block_succ))
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Period writers
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def derive_years_represented(
223
+ period__branch: list[tuple[str, str]],
224
+ years_represented: list[tuple[str, str]],
225
+ ) -> pl.DataFrame:
226
+ """Build the canonical ``p_years_represented`` frame
227
+ (period, years_from_solve, p_years_from_solve, p_years_represented).
228
+ """
229
+ rows: list[tuple[Any, ...]] = []
230
+ year_count: float = 0
231
+ for period__years in years_represented:
232
+ total_represented = float(period__years[1])
233
+ if total_represented <= 0:
234
+ continue
235
+ n_rows = math.ceil(total_represented)
236
+ remaining = total_represented
237
+ for _ in range(n_rows):
238
+ years_to_cover_within_year = min(1.0, remaining)
239
+ rows.append((
240
+ period__years[0], str(year_count), str(year_count),
241
+ str(years_to_cover_within_year),
242
+ ))
243
+ for pd_pair in period__branch:
244
+ if pd_pair[0] in period__years[0] and pd_pair[0] != pd_pair[1]:
245
+ rows.append((
246
+ pd_pair[1], str(year_count), str(year_count),
247
+ str(years_to_cover_within_year),
248
+ ))
249
+ year_count += years_to_cover_within_year
250
+ remaining -= years_to_cover_within_year
251
+ return _to_utf8_frame(
252
+ ("period", "years_from_solve",
253
+ "p_years_from_solve", "p_years_represented"),
254
+ rows,
255
+ )
256
+
257
+
258
+ def emit_years_represented(
259
+ period__branch: list[tuple[str, str]],
260
+ years_represented: list[tuple[str, str]],
261
+ filename: str,
262
+ *, provider,
263
+ ) -> None:
264
+ """Emit ``years_represented`` to the Provider."""
265
+ _emit_path(provider, filename,
266
+ derive_years_represented(period__branch, years_represented))
267
+
268
+
269
+ def derive_period_years(
270
+ stochastic_branches: list[tuple[str, str]],
271
+ years_represented: list[tuple[str, str]],
272
+ ) -> pl.DataFrame:
273
+ """Build the canonical ``period_with_history`` / ``p_discount_years``
274
+ frame (period, param).
275
+ """
276
+ rows: list[tuple[Any, ...]] = []
277
+ year_count: float = 0
278
+ for period__year in years_represented:
279
+ rows.append((period__year[0], str(year_count)))
280
+ for pd_pair in stochastic_branches:
281
+ if pd_pair[0] in period__year[0] and pd_pair[0] != pd_pair[1]:
282
+ rows.append((pd_pair[1], str(year_count)))
283
+ year_count += float(period__year[1])
284
+ return _to_utf8_frame(("period", "param"), rows)
285
+
286
+
287
+ def emit_period_years(
288
+ stochastic_branches: list[tuple[str, str]],
289
+ years_represented: list[tuple[str, str]],
290
+ filename: str,
291
+ *, provider,
292
+ ) -> None:
293
+ """Emit ``period_years`` to the Provider."""
294
+ _emit_path(provider, filename,
295
+ derive_period_years(stochastic_branches, years_represented))
296
+
297
+
298
+ def derive_periods(
299
+ solve: str,
300
+ periods_dict: dict[str, list[tuple[str, str]]],
301
+ ) -> pl.DataFrame:
302
+ """Build a single-column ``period`` frame for the given solve key."""
303
+ rows: list[tuple[Any, ...]] = [
304
+ (period_tuple[1],) for period_tuple in periods_dict.get(solve, [])
305
+ ]
306
+ return _to_utf8_frame(("period",), rows)
307
+
308
+
309
+ def emit_periods(
310
+ solve: str,
311
+ periods_dict: dict[str, list[tuple[str, str]]],
312
+ filename: str,
313
+ *, provider,
314
+ ) -> None:
315
+ """Emit ``periods`` to the Provider."""
316
+ _emit_path(provider, filename, derive_periods(solve, periods_dict))
317
+
318
+
319
+ def _compute_first_and_last_periods(
320
+ active_time_list: dict[str, list[tuple[str, ...]]],
321
+ period__timesets_in_this_solve: list[tuple[str, str]],
322
+ period__branch_list: list[tuple[str, str]],
323
+ ) -> tuple[list[str], list[str], list[str]]:
324
+ """Shared compute for the three first/last period CSVs.
325
+
326
+ Returns ``(period_last, period_first_of_solve_list, period_first_list)``
327
+ each as a flat list of period names.
328
+ """
329
+ period_first_of_solve = list(active_time_list.keys())[0]
330
+ period_last = [list(active_time_list.keys())[-1]]
331
+ time_step_last = active_time_list[period_last[0]][-1].timestep
332
+ for period in active_time_list.keys():
333
+ if (
334
+ active_time_list[period][-1].timestep == time_step_last
335
+ and period != period_last[0]
336
+ ):
337
+ period_last.append(period)
338
+
339
+ period_first_of_solve_list = [
340
+ pb[1] for pb in period__branch_list if pb[0] == period_first_of_solve
341
+ ]
342
+ period_first = period__timesets_in_this_solve[0][0]
343
+ period_first_list = [
344
+ pb[1] for pb in period__branch_list if pb[0] == period_first
345
+ ]
346
+ return period_last, period_first_of_solve_list, period_first_list
347
+
348
+
349
+ def emit_first_and_last_periods(
350
+ active_time_list: dict[str, list[tuple[str, ...]]],
351
+ period__timesets_in_this_solve: list[tuple[str, str]],
352
+ period__branch_list: list[tuple[str, str]],
353
+ *, provider,
354
+ ) -> None:
355
+ """Emit ``first_and_last_periods`` to the Provider."""
356
+ period_last, period_first_of_solve_list, period_first_list = (
357
+ _compute_first_and_last_periods(
358
+ active_time_list,
359
+ period__timesets_in_this_solve,
360
+ period__branch_list,
361
+ )
362
+ )
363
+ _emit(provider, "solve_data/period_last.csv",
364
+ _to_utf8_frame(("period",), [(p,) for p in period_last]))
365
+ _emit(provider, "solve_data/period_first_of_solve.csv",
366
+ _to_utf8_frame(("period",),
367
+ [(p,) for p in period_first_of_solve_list]))
368
+ _emit(provider, "solve_data/period_first.csv",
369
+ _to_utf8_frame(("period",),
370
+ [(p,) for p in period_first_list]))
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # Solve status
375
+ # ---------------------------------------------------------------------------
376
+
377
+
378
+ def derive_solve_status(
379
+ first_state: bool,
380
+ last_state: bool,
381
+ nested: bool = False,
382
+ ) -> pl.DataFrame:
383
+ """Build the ``p_model`` / ``p_nested_model`` frame
384
+ (modelParam, p_model | p_nested_model).
385
+ """
386
+ param_col = "p_nested_model" if nested else "p_model"
387
+ rows = [
388
+ ("solveFirst", "1" if first_state else "0"),
389
+ ("solveLast", "1" if last_state else "0"),
390
+ ]
391
+ return _to_utf8_frame(("modelParam", param_col), rows)
392
+
393
+
394
+ def emit_solve_status(
395
+ first_state: bool,
396
+ last_state: bool,
397
+ nested: bool = False,
398
+ *, provider,
399
+ ) -> None:
400
+ """Emit ``solve_status`` to the Provider."""
401
+ key = ("solve_data/p_nested_model.csv" if nested
402
+ else "solve_data/p_model.csv")
403
+ _emit(provider, key,
404
+ derive_solve_status(first_state, last_state, nested))
405
+
406
+
407
+ def derive_current_solve(solve: str) -> pl.DataFrame:
408
+ """Build the ``solve_current`` frame (solve)."""
409
+ return _to_utf8_frame(("solve",), [(solve,)])
410
+
411
+
412
+ def emit_current_solve(solve: str, filename: str, *, provider) -> None:
413
+ """Emit ``current_solve`` to the Provider."""
414
+ _emit_path(provider, filename, derive_current_solve(solve))
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # Timestep boundary (S04 consolidation in legacy)
419
+ # ---------------------------------------------------------------------------
420
+
421
+
422
+ def derive_period_boundary_step(
423
+ timeline: dict[str, list[tuple[str, ...]]],
424
+ last: bool = False,
425
+ ) -> pl.DataFrame:
426
+ """Build the (period, step) boundary-step frame — first or last
427
+ timestep of each period.
428
+ """
429
+ rows: list[tuple[Any, ...]] = []
430
+ for period_name, period in timeline.items():
431
+ boundary = period[-1:] if last else period[:1]
432
+ for item in boundary:
433
+ rows.append((period_name, item.timestep))
434
+ return _to_utf8_frame(("period", "step"), rows)
435
+
436
+
437
+ def emit_period_boundary_step(
438
+ timeline: dict[str, list[tuple[str, ...]]],
439
+ filename: str,
440
+ *,
441
+ last: bool = False,
442
+ provider,
443
+ ) -> None:
444
+ """Emit ``period_boundary_step`` to the Provider."""
445
+ _emit_path(provider, filename,
446
+ derive_period_boundary_step(timeline, last=last))
447
+
448
+
449
+ def emit_first_steps(
450
+ timeline: dict[str, list[tuple[str, ...]]],
451
+ filename: str,
452
+ *, provider,
453
+ ) -> None:
454
+ """Emit ``first_steps`` to the Provider."""
455
+ emit_period_boundary_step(timeline, filename, last=False, provider=provider)
456
+
457
+
458
+ def emit_last_steps(
459
+ timeline: dict[str, list[tuple[str, ...]]],
460
+ filename: str,
461
+ *, provider,
462
+ ) -> None:
463
+ """Emit ``last_steps`` to the Provider."""
464
+ emit_period_boundary_step(timeline, filename, last=True, provider=provider)
465
+
466
+
467
+ def get_first_steps(
468
+ steplists: dict[str, list[Any]],
469
+ ) -> dict[str, tuple[Any, ...]]:
470
+ """Helper: pull the first step of the current solve (and the first
471
+ step of the next solve in execution order) from each solve's
472
+ steplist. Returns ``{solve: (own_first[, next_first])}``.
473
+
474
+ Pure data transform — no CSV side effects.
475
+ """
476
+ solve_names = list(steplists.keys())
477
+ starts: dict[str, tuple[Any, ...]] = dict()
478
+ for index, name in enumerate(solve_names):
479
+ if index == (len(solve_names) - 1):
480
+ starts[name] = (steplists[name][0],)
481
+ else:
482
+ starts[name] = (
483
+ steplists[solve_names[index]][0],
484
+ steplists[solve_names[index + 1]][0],
485
+ )
486
+ return starts
487
+
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # Realized / fix storage
491
+ # ---------------------------------------------------------------------------
492
+
493
+
494
+ def derive_realized_dispatch(
495
+ realized_time_list: dict[str, list[tuple[str, ...]]],
496
+ solve: str,
497
+ realized_periods: list[tuple[str, str]],
498
+ ) -> pl.DataFrame:
499
+ """Build the ``realized_dispatch`` frame (period, step)."""
500
+ rows: list[tuple[Any, ...]] = []
501
+ for period, realized_time in realized_time_list.items():
502
+ if any(t[1] == period for t in realized_periods):
503
+ for i in realized_time:
504
+ rows.append((period, i.timestep))
505
+ return _to_utf8_frame(("period", "step"), rows)
506
+
507
+
508
+ def emit_realized_dispatch(
509
+ realized_time_list: dict[str, list[tuple[str, ...]]],
510
+ solve: str,
511
+ realized_periods: list[tuple[str, str]],
512
+ *, provider,
513
+ ) -> None:
514
+ """Emit ``realized_dispatch`` to the Provider."""
515
+ _emit(
516
+ provider, "solve_data/realized_dispatch.csv",
517
+ derive_realized_dispatch(realized_time_list, solve, realized_periods),
518
+ )
519
+
520
+
521
+ def derive_fix_storage_timesteps(
522
+ active_time_list: dict[str, list[tuple[str, ...]]],
523
+ solve: str,
524
+ fix_storage_periods: list[tuple[str, str]],
525
+ ) -> pl.DataFrame:
526
+ """Build the ``fix_storage_timesteps`` frame (period, step)."""
527
+ rows: list[tuple[Any, ...]] = []
528
+ for period, active_time in active_time_list.items():
529
+ if any(t[1] == period for t in fix_storage_periods):
530
+ for i in active_time:
531
+ rows.append((period, i.timestep))
532
+ return _to_utf8_frame(("period", "step"), rows)
533
+
534
+
535
+ def emit_fix_storage_timesteps(
536
+ active_time_list: dict[str, list[tuple[str, ...]]],
537
+ solve: str,
538
+ fix_storage_periods: list[tuple[str, str]],
539
+ *, provider,
540
+ ) -> None:
541
+ """Emit ``fix_storage_timesteps`` to the Provider."""
542
+ _emit(
543
+ provider, "solve_data/fix_storage_timesteps.csv",
544
+ derive_fix_storage_timesteps(
545
+ active_time_list, solve, fix_storage_periods,
546
+ ),
547
+ )
548
+
549
+
550
+ # ---------------------------------------------------------------------------
551
+ # Group B — branch / empty / header writers
552
+ # ---------------------------------------------------------------------------
553
+
554
+
555
+ def derive_branch__period_relationship(
556
+ period__branch: list[tuple[str, str]],
557
+ ) -> pl.DataFrame:
558
+ """Build the ``period__branch`` frame (period, branch)."""
559
+ return _to_utf8_frame(
560
+ ("period", "branch"),
561
+ [(row[0], row[1]) for row in period__branch],
562
+ )
563
+
564
+
565
+ def emit_branch__period_relationship(
566
+ period__branch: list[tuple[str, str]],
567
+ filename: str,
568
+ *, provider,
569
+ ) -> None:
570
+ """Emit ``branch__period_relationship`` to the Provider."""
571
+ _emit_path(provider, filename,
572
+ derive_branch__period_relationship(period__branch))
573
+
574
+
575
+ def derive_solve_branch_weight(
576
+ complete_solve: str,
577
+ active_time_list: dict[str, list[tuple[str, ...]]],
578
+ solve_branch__time_branch_list: list[tuple[str, str]],
579
+ branch_start_time: tuple[str, str] | None,
580
+ period__branch_lists: list[tuple[str, str]],
581
+ stochastic_branches: dict[str, list[Any]],
582
+ ) -> pl.DataFrame:
583
+ """Build the ``solve_branch_weight`` frame
584
+ (branch, p_branch_weight_input).
585
+ """
586
+ time_branch_weight: dict[str, Any] = defaultdict()
587
+ if branch_start_time is not None:
588
+ for row in stochastic_branches[complete_solve]:
589
+ if (
590
+ branch_start_time[0] == row[0]
591
+ and branch_start_time[1] == row[2]
592
+ ):
593
+ time_branch_weight[row[1]] = row[4]
594
+
595
+ rows: list[tuple[Any, ...]] = []
596
+ for solve_branch__time_branch in solve_branch__time_branch_list:
597
+ if (
598
+ (solve_branch__time_branch[0], solve_branch__time_branch[0])
599
+ in period__branch_lists
600
+ ):
601
+ rows.append((solve_branch__time_branch[0], "1.0"))
602
+ elif (
603
+ solve_branch__time_branch[1] in time_branch_weight.keys()
604
+ and solve_branch__time_branch[0] in active_time_list.keys()
605
+ ):
606
+ rows.append((
607
+ solve_branch__time_branch[0],
608
+ str(time_branch_weight[solve_branch__time_branch[1]]),
609
+ ))
610
+ return _to_utf8_frame(("branch", "p_branch_weight_input"), rows)
611
+
612
+
613
+ def derive_solve_branch__time_branch(
614
+ solve_branch__time_branch_list: list[tuple[str, str]],
615
+ ) -> pl.DataFrame:
616
+ """Build the ``solve_branch__time_branch`` frame (period, branch)."""
617
+ return _to_utf8_frame(
618
+ ("period", "branch"),
619
+ [(s[0], s[1]) for s in solve_branch__time_branch_list],
620
+ )
621
+
622
+
623
+ def emit_branch_weights_and_map(
624
+ complete_solve: str,
625
+ active_time_list: dict[str, list[tuple[str, ...]]],
626
+ solve_branch__time_branch_list: list[tuple[str, str]],
627
+ branch_start_time: tuple[str, str] | None,
628
+ period__branch_lists: list[tuple[str, str]],
629
+ stochastic_branches: dict[str, list[Any]],
630
+ *, provider,
631
+ ) -> None:
632
+ """Emit ``branch_weights_and_map`` to the Provider."""
633
+ _emit(
634
+ provider, "solve_data/solve_branch_weight.csv",
635
+ derive_solve_branch_weight(
636
+ complete_solve, active_time_list, solve_branch__time_branch_list,
637
+ branch_start_time, period__branch_lists, stochastic_branches,
638
+ ),
639
+ )
640
+ _emit(
641
+ provider, "solve_data/solve_branch__time_branch.csv",
642
+ derive_solve_branch__time_branch(solve_branch__time_branch_list),
643
+ )
644
+
645
+
646
+ # ---------------------------------------------------------------------------
647
+ # Init / empty files
648
+ # ---------------------------------------------------------------------------
649
+
650
+
651
+ def _empty_frame(headers: tuple[str, ...]) -> pl.DataFrame:
652
+ """Build a header-only all-Utf8 polars frame."""
653
+ return pl.DataFrame(schema={h: pl.Utf8 for h in headers})
654
+
655
+
656
+ def emit_empty_investment_file(
657
+ *, provider) -> None:
658
+ """Emit ``empty_investment_file`` to the Provider."""
659
+ _emit(provider, "solve_data/p_entity_invested.csv",
660
+ _empty_frame(("entity", "p_entity_invested")))
661
+ _emit(provider, "solve_data/p_entity_divested.csv",
662
+ _empty_frame(("entity", "p_entity_divested")))
663
+ _emit(
664
+ provider, "solve_data/p_entity_period_existing_capacity.csv",
665
+ _empty_frame((
666
+ "entity", "period",
667
+ "p_entity_period_existing_capacity",
668
+ "p_entity_period_invested_capacity",
669
+ )),
670
+ )
671
+
672
+
673
+ def emit_empty_storage_fix_file(
674
+ *, provider) -> None:
675
+ """Emit ``empty_storage_fix_file`` to the Provider."""
676
+ _emit(provider, "solve_data/fix_storage_price.csv",
677
+ _empty_frame(
678
+ ("node", " period", " step", " ndt_fix_storage_price"),
679
+ ))
680
+ _emit(provider, "solve_data/fix_storage_quantity.csv",
681
+ _empty_frame(
682
+ ("node", " period", " step", " ndt_fix_storage_quantity"),
683
+ ))
684
+ _emit(provider, "solve_data/fix_storage_usage.csv",
685
+ _empty_frame(
686
+ ("node", " period", " step", " ndt_fix_storage_usage"),
687
+ ))
688
+ _emit(provider, "solve_data/p_roll_continue_state.csv",
689
+ _empty_frame(("node", " p_roll_continue_state")))
690
+
691
+
692
+ def emit_headers_for_empty_output_files(filename: str, header: str,
693
+ *, provider) -> None:
694
+ """Emit ``headers_for_empty_output_files`` to the Provider."""
695
+ _emit_path(provider, filename, _empty_frame(tuple(header.split(","))))
696
+
697
+
698
+ # ---------------------------------------------------------------------------
699
+ # Misc writers
700
+ # ---------------------------------------------------------------------------
701
+
702
+
703
+ def derive_timesets_in_use(
704
+ timesets_used_by_solves: dict[str, list[tuple[str, str]]],
705
+ ) -> pl.DataFrame:
706
+ """Build the ``timesets_in_use`` frame (solve, period, timesets)."""
707
+ rows: list[tuple[Any, ...]] = []
708
+ for solve, period_timeset_list in timesets_used_by_solves.items():
709
+ for period, timeset in period_timeset_list:
710
+ rows.append((solve, period, timeset))
711
+ return _to_utf8_frame(("solve", "period", "timesets"), rows)
712
+
713
+
714
+ def derive_timesets__timeline(
715
+ timeset__timeline: dict[str, str],
716
+ ) -> pl.DataFrame:
717
+ """Build the ``timesets__timeline`` frame (timesets, timeline)."""
718
+ rows = [(ts, tl) for ts, tl in timeset__timeline.items()]
719
+ return _to_utf8_frame(("timesets", "timeline"), rows)
720
+
721
+
722
+ def emit_timesets(
723
+ timesets_used_by_solves: dict[str, list[tuple[str, str]]],
724
+ timeset__timeline: dict[str, str],
725
+ *, provider,
726
+ ) -> None:
727
+ """Emit ``timesets`` to the Provider."""
728
+ _emit(provider, "input/timesets_in_use.csv",
729
+ derive_timesets_in_use(timesets_used_by_solves))
730
+ _emit(provider, "input/timesets__timeline.csv",
731
+ derive_timesets__timeline(timeset__timeline))
732
+
733
+
734
+ # ---------------------------------------------------------------------------
735
+ # Sub-dispatch 7 — scaling writers
736
+ # ---------------------------------------------------------------------------
737
+
738
+
739
+ def derive_p_use_row_scaling(
740
+ solve: str,
741
+ use_row_scaling: dict[str, str],
742
+ ) -> pl.DataFrame:
743
+ """Build the ``p_use_row_scaling`` frame (solve, p_use_row_scaling).
744
+
745
+ Honours the ``FLEXTOOL_FORCE_ROW_SCALING`` env-var test hook (Agent 9
746
+ Mode B un-scaling benchmark harness).
747
+ """
748
+ import os as _os
749
+
750
+ value_str = (
751
+ use_row_scaling.get(solve, "no")
752
+ if isinstance(use_row_scaling, dict)
753
+ else "no"
754
+ )
755
+ flag = 1 if str(value_str).strip().lower() == "yes" else 0
756
+ if _os.environ.get("FLEXTOOL_FORCE_ROW_SCALING", "").strip().lower() in (
757
+ "1", "yes", "true", "on",
758
+ ):
759
+ flag = 1
760
+ return _to_utf8_frame(
761
+ ("solve", "p_use_row_scaling"), [(solve, str(flag))],
762
+ )
763
+
764
+
765
+ def emit_p_use_row_scaling(
766
+ solve: str,
767
+ use_row_scaling: dict[str, str],
768
+ filename: str,
769
+ *, provider,
770
+ ) -> None:
771
+ """Emit ``p_use_row_scaling`` to the Provider."""
772
+ _emit_path(provider, filename,
773
+ derive_p_use_row_scaling(solve, use_row_scaling))
774
+
775
+
776
+ def derive_scale_the_objective(value: float) -> pl.DataFrame:
777
+ """Build the ``scale_the_objective`` keyed-value frame
778
+ (key=``v``, value=``%.17g``).
779
+ """
780
+ return _to_utf8_frame(
781
+ ("key", "value"), [("v", f"{float(value):.17g}")],
782
+ )
783
+
784
+
785
+ def derive_scale_the_objective_header_only() -> pl.DataFrame:
786
+ """Build the header-only ``scale_the_objective`` frame."""
787
+ return _empty_frame(("key", "value"))
788
+
789
+
790
+ def emit_scale_the_objective(
791
+ solve_data_dir: Path | str,
792
+ value: float,
793
+ *, provider,
794
+ ) -> Path:
795
+ """Emit ``scale_the_objective`` to the Provider."""
796
+ path = Path(solve_data_dir) / "scale_the_objective.csv"
797
+ _emit(provider, "solve_data/scale_the_objective.csv",
798
+ derive_scale_the_objective(value))
799
+ return path
800
+
801
+
802
+ def emit_scale_the_objective_header_only(
803
+ solve_data_dir: Path | str,
804
+ *, provider,
805
+ ) -> Path:
806
+ """Emit ``scale_the_objective_header_only`` to the Provider."""
807
+ path = Path(solve_data_dir) / "scale_the_objective.csv"
808
+ _emit(provider, "solve_data/scale_the_objective.csv",
809
+ derive_scale_the_objective_header_only())
810
+ return path
811
+
812
+
813
+ # ---------------------------------------------------------------------------
814
+ # Sub-dispatch 7 — delay durations
815
+ # ---------------------------------------------------------------------------
816
+
817
+
818
+ def _compute_delay_duration_set(
819
+ delay_durations: dict[str, Any],
820
+ ) -> set[str]:
821
+ """Shared compute for the two delayed-duration CSVs."""
822
+ delay_duration_set: set[str] = set()
823
+ for entity, dur in delay_durations.items():
824
+ if isinstance(dur, list):
825
+ for delay_duration in dur:
826
+ delay_duration_set.add(str(delay_duration[0]))
827
+ else:
828
+ delay_duration_set.add(str(dur))
829
+ return delay_duration_set
830
+
831
+
832
+ def derive_delay_duration(
833
+ delay_durations: dict[str, Any],
834
+ ) -> pl.DataFrame:
835
+ """Build the ``delay_duration`` frame (delay_duration)."""
836
+ rows = [(str(d),) for d in _compute_delay_duration_set(delay_durations)]
837
+ return _to_utf8_frame(("delay_duration",), rows)
838
+
839
+
840
+ def derive_dtt__delay_duration(
841
+ active_time_list: dict[str, list[tuple[str, ...]]],
842
+ delay_durations: dict[str, Any],
843
+ ) -> pl.DataFrame:
844
+ """Build the ``dtt__delay_duration`` frame
845
+ (period, time_source, time_sink, delay_duration) with cyclic
846
+ end-of-period wrap-around.
847
+ """
848
+ delay_duration_set = _compute_delay_duration_set(delay_durations)
849
+ rows: list[tuple[Any, ...]] = []
850
+ for period_name, time_steps in active_time_list.items():
851
+ for k, time_step in enumerate(time_steps):
852
+ for delay_duration in delay_duration_set:
853
+ offset = int(float(delay_duration))
854
+ if k + offset < len(time_steps):
855
+ sink_step = time_steps[k + offset].timestep
856
+ else:
857
+ sink_step = time_steps[
858
+ k - len(time_steps) + offset
859
+ ].timestep
860
+ rows.append((
861
+ period_name, time_step.timestep,
862
+ sink_step, str(delay_duration),
863
+ ))
864
+ return _to_utf8_frame(
865
+ ("period", "time_source", "time_sink", "delay_duration"), rows,
866
+ )
867
+
868
+
869
+ def emit_delayed_durations(
870
+ active_time_list: dict[str, list[tuple[str, ...]]],
871
+ solve: str,
872
+ delay_durations: dict[str, Any],
873
+ *, provider,
874
+ ) -> None:
875
+ """Emit ``delayed_durations`` to the Provider."""
876
+ _emit(provider, "solve_data/delay_duration.csv",
877
+ derive_delay_duration(delay_durations))
878
+ _emit(provider, "solve_data/dtt__delay_duration.csv",
879
+ derive_dtt__delay_duration(active_time_list,
880
+ delay_durations))
881
+
882
+
883
+ # ---------------------------------------------------------------------------
884
+ # Sub-dispatch 7 — representative period writers
885
+ # ---------------------------------------------------------------------------
886
+
887
+
888
+ def _compute_rp_frames(
889
+ rp_weights: dict[str, dict[str, float]],
890
+ timeset_duration_entries: list[tuple[str, float]],
891
+ period_name: str,
892
+ timeline_steps: list[str],
893
+ *,
894
+ variant: str = "within_solve",
895
+ per_period_inputs: (
896
+ "list[tuple[str, dict[str, dict[str, float]], "
897
+ "list[tuple[str, float]], list[str]]] | None"
898
+ ) = None,
899
+ ) -> dict[str, pl.DataFrame]:
900
+ """Shared compute for the eight representative-period CSVs.
901
+
902
+ Returns a dict keyed by output basename — used by both the
903
+ ``derive_*`` accessors and the wrapper writer.
904
+
905
+ Output basenames: ``rp_weights.csv``, ``rp_base_chain.csv``,
906
+ ``rp_base_first.csv``, ``rp_base_last.csv``, ``rp_block_first.csv``,
907
+ ``rp_block_last.csv``, ``rp_block_start_last.csv``,
908
+ ``rp_base_period_set.csv``, ``rp_rep_period_set.csv``,
909
+ ``timestep_weight.csv``.
910
+
911
+ ``timeline_steps`` is the ordered list of step IDs for the timeline
912
+ this timeset belongs to; it anchors ``(start, count)`` block entries
913
+ to real step labels (e.g. ``2050-01-01T00:00:00``) rather than the
914
+ synthetic ``t####`` form.
915
+
916
+ Phase E — ``variant`` toggles the RP chain TOPOLOGY:
917
+
918
+ * ``"within_solve"`` (default, back-compat): one base-period chain
919
+ spanning every base period in the solve. ``rp_base_first`` /
920
+ ``rp_base_last`` are singletons (head/tail of the single chain).
921
+ Used by ``bind_within_solve_blended_weights`` (cyclic closure
922
+ across the entire chain) and ``bind_forward_only_blended_weights``
923
+ (no closure).
924
+ * ``"within_period"``: each FlexTool period has its own independent
925
+ chain that closes within itself. ``per_period_inputs`` carries
926
+ one entry per RP-bearing FlexTool period;
927
+ ``rp_base_chain`` accumulates only WITHIN-period predecessor
928
+ edges, and ``rp_base_first`` / ``rp_base_last`` carry one row per
929
+ period plus a ``period`` column so the downstream cyclic-closure
930
+ constraint (``model.py``'s ``rp_inter_period_cyclic``) can pair
931
+ each period's first base against its OWN period's last base.
932
+
933
+ ``per_period_inputs`` is required when ``variant == "within_period"``
934
+ and ignored otherwise; the legacy single-period args
935
+ (``rp_weights`` / ``timeset_duration_entries`` / ``period_name`` /
936
+ ``timeline_steps``) drive the within_solve path.
937
+ """
938
+ if variant not in ("within_solve", "within_period"):
939
+ raise ValueError(
940
+ f"_compute_rp_frames: variant must be 'within_solve' or "
941
+ f"'within_period', got {variant!r}."
942
+ )
943
+ if variant == "within_period":
944
+ if not per_period_inputs:
945
+ raise ValueError(
946
+ "_compute_rp_frames: variant='within_period' requires a "
947
+ "non-empty per_period_inputs list (one entry per "
948
+ "RP-bearing FlexTool period)."
949
+ )
950
+ # Per-period assembly: each entry yields a chain segment fully
951
+ # contained within its FlexTool period. Cross-period edges are
952
+ # omitted by construction (we never concatenate cross-segment
953
+ # predecessor edges).
954
+ wp_chain_rows: list[tuple[Any, ...]] = []
955
+ wp_first_rows: list[tuple[Any, ...]] = []
956
+ wp_last_rows: list[tuple[Any, ...]] = []
957
+ wp_block_first_rows: list[tuple[Any, ...]] = []
958
+ wp_block_last_rows: list[tuple[Any, ...]] = []
959
+ wp_block_sl_rows: list[tuple[Any, ...]] = []
960
+ wp_base_period_rows: list[tuple[Any, ...]] = []
961
+ wp_rep_period_rows: list[tuple[Any, ...]] = []
962
+ wp_weights_rows: list[tuple[Any, ...]] = []
963
+ wp_cost_rows: list[tuple[Any, ...]] = []
964
+ for (p_name, p_rp_w, p_ts_dur, p_tl_steps) in per_period_inputs:
965
+ p_sub = _compute_rp_frames(
966
+ p_rp_w, p_ts_dur, p_name, p_tl_steps,
967
+ )
968
+ # rp_base_chain: rename to add the owning period column.
969
+ sub_chain = p_sub["rp_base_chain.csv"]
970
+ for row in sub_chain.iter_rows():
971
+ wp_chain_rows.append((row[0], row[1], p_name))
972
+ # rp_base_first / rp_base_last: tag with period.
973
+ sub_first = p_sub["rp_base_first.csv"]
974
+ for row in sub_first.iter_rows():
975
+ wp_first_rows.append((row[0], p_name))
976
+ sub_last = p_sub["rp_base_last.csv"]
977
+ for row in sub_last.iter_rows():
978
+ wp_last_rows.append((row[0], p_name))
979
+ # Pass through unchanged.
980
+ wp_block_first_rows.extend(p_sub["rp_block_first.csv"].iter_rows())
981
+ wp_block_last_rows.extend(p_sub["rp_block_last.csv"].iter_rows())
982
+ wp_block_sl_rows.extend(
983
+ p_sub["rp_block_start_last.csv"].iter_rows())
984
+ wp_base_period_rows.extend(
985
+ p_sub["rp_base_period_set.csv"].iter_rows())
986
+ wp_rep_period_rows.extend(
987
+ p_sub["rp_rep_period_set.csv"].iter_rows())
988
+ wp_weights_rows.extend(p_sub["rp_weights.csv"].iter_rows())
989
+ wp_cost_rows.extend(p_sub["timestep_weight.csv"].iter_rows())
990
+ out: dict[str, pl.DataFrame] = {}
991
+ out["rp_weights.csv"] = _to_utf8_frame(
992
+ ("base_start", "rep_start", "weight"), wp_weights_rows,
993
+ )
994
+ # ``period`` column is the period that owns this chain edge /
995
+ # endpoint; consumed by the FlexData loader and propagated as
996
+ # ``d`` so ``model.py``'s ``rp_inter_period_cyclic`` can pair
997
+ # endpoints per period.
998
+ out["rp_base_chain.csv"] = _to_utf8_frame(
999
+ ("base_start", "prev_base_start", "period"), wp_chain_rows,
1000
+ )
1001
+ out["rp_base_first.csv"] = _to_utf8_frame(
1002
+ ("base_start", "period"), wp_first_rows,
1003
+ )
1004
+ out["rp_base_last.csv"] = _to_utf8_frame(
1005
+ ("base_start", "period"), wp_last_rows,
1006
+ )
1007
+ out["rp_block_first.csv"] = _to_utf8_frame(
1008
+ ("period", "step"), wp_block_first_rows,
1009
+ )
1010
+ out["rp_block_last.csv"] = _to_utf8_frame(
1011
+ ("period", "step"), wp_block_last_rows,
1012
+ )
1013
+ out["rp_block_start_last.csv"] = _to_utf8_frame(
1014
+ ("rep_start", "last_step"), wp_block_sl_rows,
1015
+ )
1016
+ out["rp_base_period_set.csv"] = _to_utf8_frame(
1017
+ ("period",), wp_base_period_rows,
1018
+ )
1019
+ out["rp_rep_period_set.csv"] = _to_utf8_frame(
1020
+ ("period",), wp_rep_period_rows,
1021
+ )
1022
+ out["timestep_weight.csv"] = _to_utf8_frame(
1023
+ ("period", "time", "weight"), wp_cost_rows,
1024
+ )
1025
+ return out
1026
+
1027
+ step_to_idx: dict[str, int] = {s: i for i, s in enumerate(timeline_steps)}
1028
+
1029
+ # RP block boundaries from the timeset_duration entries.
1030
+ rp_starts: list[str] = []
1031
+ rp_lasts: list[str] = []
1032
+ rp_ranges: list[tuple[int, int]] = [] # inclusive (start_idx, last_idx)
1033
+ for start_step, count in timeset_duration_entries:
1034
+ start_step = str(start_step)
1035
+ if start_step not in step_to_idx:
1036
+ raise KeyError(
1037
+ f"Representative-period block start {start_step!r} not "
1038
+ f"found in timeline (period={period_name!r}). Check that "
1039
+ "timeset_duration entries reference real timeline steps."
1040
+ )
1041
+ start_idx = step_to_idx[start_step]
1042
+ last_idx = start_idx + int(float(count)) - 1
1043
+ if last_idx >= len(timeline_steps):
1044
+ raise IndexError(
1045
+ f"Representative-period block {start_step!r} + "
1046
+ f"{int(float(count))} steps overruns timeline length "
1047
+ f"({len(timeline_steps)}) for period={period_name!r}."
1048
+ )
1049
+ rp_starts.append(start_step)
1050
+ rp_lasts.append(timeline_steps[last_idx])
1051
+ rp_ranges.append((start_idx, last_idx))
1052
+
1053
+ base_starts = sorted(
1054
+ rp_weights.keys(),
1055
+ key=lambda s: step_to_idx.get(s, len(timeline_steps)),
1056
+ )
1057
+ n_base = len(base_starts)
1058
+ n_rp = len(rp_starts)
1059
+
1060
+ out: dict[str, pl.DataFrame] = {}
1061
+
1062
+ # 1. rp_weights.csv — drop near-zero entries.
1063
+ rp_weights_rows: list[tuple[Any, ...]] = []
1064
+ for base in base_starts:
1065
+ for rep, weight in rp_weights[base].items():
1066
+ if weight > 1e-10:
1067
+ rp_weights_rows.append((base, rep, str(weight)))
1068
+ out["rp_weights.csv"] = _to_utf8_frame(
1069
+ ("base_start", "rep_start", "weight"), rp_weights_rows,
1070
+ )
1071
+
1072
+ # 2. rp_base_chain.csv — predecessor chain (excludes first).
1073
+ out["rp_base_chain.csv"] = _to_utf8_frame(
1074
+ ("base_start", "prev_base_start"),
1075
+ [(base_starts[i], base_starts[i - 1]) for i in range(1, n_base)],
1076
+ )
1077
+
1078
+ # 3. rp_base_first.csv / rp_base_last.csv
1079
+ out["rp_base_first.csv"] = _to_utf8_frame(
1080
+ ("base_start",), [(base_starts[0],)] if n_base > 0 else [],
1081
+ )
1082
+ out["rp_base_last.csv"] = _to_utf8_frame(
1083
+ ("base_start",), [(base_starts[-1],)] if n_base > 0 else [],
1084
+ )
1085
+
1086
+ # 4. rp_block_first.csv / rp_block_last.csv
1087
+ out["rp_block_first.csv"] = _to_utf8_frame(
1088
+ ("period", "step"), [(period_name, s) for s in rp_starts],
1089
+ )
1090
+ out["rp_block_last.csv"] = _to_utf8_frame(
1091
+ ("period", "step"), [(period_name, lst) for lst in rp_lasts],
1092
+ )
1093
+
1094
+ # 5. rp_block_start_last.csv
1095
+ out["rp_block_start_last.csv"] = _to_utf8_frame(
1096
+ ("rep_start", "last_step"),
1097
+ [(s, lst) for s, lst in zip(rp_starts, rp_lasts)],
1098
+ )
1099
+
1100
+ # 5b. rp_base_period_set.csv / rp_rep_period_set.csv —
1101
+ # ordered-unique projections of the rp_weights base/rep columns.
1102
+ # Owned by _compute_rp_frames because emit_per_solve_sets runs
1103
+ # BEFORE emit_rp_data in the cascade, so the per-solve emit can't
1104
+ # see ``rp_weights`` yet. Column header ``period`` matches the
1105
+ # convention used by ``_emit_singles``-style set frames; the
1106
+ # FlexData loader renames to its axis name on load.
1107
+ out["rp_base_period_set.csv"] = _to_utf8_frame(
1108
+ ("period",), [(b,) for b in base_starts],
1109
+ )
1110
+ out["rp_rep_period_set.csv"] = _to_utf8_frame(
1111
+ ("period",), [(r,) for r in rp_starts],
1112
+ )
1113
+
1114
+ # 6. timestep_weight.csv — normalised per-timestep weight.
1115
+ w_r: dict[str, float] = {r: 0.0 for r in rp_starts}
1116
+ for base_weights in rp_weights.values():
1117
+ for rep, weight in base_weights.items():
1118
+ if rep in w_r:
1119
+ w_r[rep] += weight
1120
+ for rep in w_r:
1121
+ w_r[rep] = w_r[rep] * n_rp / n_base if n_base > 0 else 1.0
1122
+ cost_rows: list[tuple[Any, ...]] = []
1123
+ for (start_idx, last_idx), start in zip(rp_ranges, rp_starts):
1124
+ weight = w_r[start]
1125
+ for t_idx in range(start_idx, last_idx + 1):
1126
+ cost_rows.append(
1127
+ (period_name, timeline_steps[t_idx], str(weight))
1128
+ )
1129
+ out["timestep_weight.csv"] = _to_utf8_frame(
1130
+ ("period", "time", "weight"), cost_rows,
1131
+ )
1132
+
1133
+ return out
1134
+
1135
+
1136
+ # Map from the basename keys produced by :func:`_compute_rp_frames`
1137
+ # to the canonical Provider keys (no ``.csv`` suffix, per the Phase 3b
1138
+ # convention in :mod:`flextool.engine_polars._provider_keys`). Used by
1139
+ # both :func:`emit_rp_data` and :func:`emit_empty_rp_data` so the six
1140
+ # restored RP basenames + the two pre-existing ones land under the
1141
+ # named ``K.SOLVE_DATA_*`` constants.
1142
+ _RP_BASENAME_TO_PROVIDER_KEY: dict[str, str] = {
1143
+ "rp_weights.csv": K.SOLVE_DATA_RP_WEIGHTS,
1144
+ "rp_base_chain.csv": K.SOLVE_DATA_RP_BASE_CHAIN,
1145
+ "rp_base_first.csv": K.SOLVE_DATA_RP_BASE_FIRST,
1146
+ "rp_base_last.csv": K.SOLVE_DATA_RP_BASE_LAST,
1147
+ "rp_block_first.csv": K.SOLVE_DATA_RP_BLOCK_FIRST,
1148
+ "rp_block_last.csv": K.SOLVE_DATA_RP_BLOCK_LAST,
1149
+ "rp_block_start_last.csv": K.SOLVE_DATA_RP_BLOCK_START_LAST,
1150
+ "rp_base_period_set.csv": K.SOLVE_DATA_RP_BASE_PERIOD_SET,
1151
+ "rp_rep_period_set.csv": K.SOLVE_DATA_RP_REP_PERIOD_SET,
1152
+ "timestep_weight.csv": "solve_data/timestep_weight",
1153
+ }
1154
+
1155
+
1156
+ def emit_rp_data(
1157
+ rp_weights: dict[str, dict[str, float]],
1158
+ timeset_duration_entries: list[tuple[str, float]],
1159
+ period_name: str,
1160
+ timeline_steps: list[str],
1161
+ *, provider,
1162
+ variant: str = "within_solve",
1163
+ per_period_inputs: (
1164
+ "list[tuple[str, dict[str, dict[str, float]], "
1165
+ "list[tuple[str, float]], list[str]]] | None"
1166
+ ) = None,
1167
+ ) -> None:
1168
+ """Emit ``rp_data`` to the Provider.
1169
+
1170
+ Phase E added ``variant`` + ``per_period_inputs`` (see
1171
+ :func:`_compute_rp_frames` for semantics). The caller in
1172
+ :mod:`flextool.engine_polars._native_run_model` selects the variant
1173
+ based on the binding-method mix of nodes in this solve; mixed
1174
+ within_solve/within_period in a single solve is rejected upstream
1175
+ with ``FlexToolConfigError`` because the per-solve RP frame family
1176
+ can only carry one chain topology.
1177
+ """
1178
+ frames = _compute_rp_frames(
1179
+ rp_weights, timeset_duration_entries, period_name, timeline_steps,
1180
+ variant=variant, per_period_inputs=per_period_inputs,
1181
+ )
1182
+ for basename, df in frames.items():
1183
+ _emit(provider, _RP_BASENAME_TO_PROVIDER_KEY[basename], df)
1184
+
1185
+
1186
+ def _compute_timeset_cost_weight_rows(
1187
+ active_time_list: dict[str, list],
1188
+ timesets_used_by_solve: list[tuple[str, str]],
1189
+ timeset_weights: dict[str, dict[str, float]],
1190
+ ) -> tuple[list[tuple[Any, ...]], bool]:
1191
+ """Shared compute for :func:`emit_timeset_cost_weight`.
1192
+ Returns ``(rows, any_written)``.
1193
+ """
1194
+ rows: list[tuple[Any, ...]] = []
1195
+ any_written = False
1196
+ for period, timeset in timesets_used_by_solve:
1197
+ weights = timeset_weights.get(timeset)
1198
+ active_steps = active_time_list.get(period, [])
1199
+ if weights is None or not active_steps:
1200
+ continue
1201
+ raw = [float(weights.get(step.timestep, 0.0)) for step in active_steps]
1202
+ total = sum(raw)
1203
+ n = len(raw)
1204
+ if total <= 0 or n == 0:
1205
+ continue
1206
+ scale = n / total
1207
+ for step, w in zip(active_steps, raw):
1208
+ rows.append((period, step.timestep, f"{w * scale:.10g}"))
1209
+ any_written = True
1210
+ return rows, any_written
1211
+
1212
+
1213
+ def emit_timeset_cost_weight(
1214
+ active_time_list: dict[str, list],
1215
+ timesets_used_by_solve: list[tuple[str, str]],
1216
+ timeset_weights: dict[str, dict[str, float]],
1217
+ *, provider,
1218
+ ) -> bool:
1219
+ """Emit ``timeset_cost_weight`` to the Provider."""
1220
+ rows, any_written = _compute_timeset_cost_weight_rows(
1221
+ active_time_list, timesets_used_by_solve, timeset_weights,
1222
+ )
1223
+ if not any_written:
1224
+ return False
1225
+ _emit(provider, "solve_data/timestep_weight.csv",
1226
+ _to_utf8_frame(("period", "time", "weight"), rows))
1227
+ return True
1228
+
1229
+
1230
+ _EMPTY_RP_HEADERS: dict[str, tuple[str, ...]] = {
1231
+ "rp_weights.csv": ("base_start", "rep_start", "weight"),
1232
+ "rp_base_chain.csv": ("base_start", "prev_base_start"),
1233
+ "rp_base_first.csv": ("base_start",),
1234
+ "rp_base_last.csv": ("base_start",),
1235
+ "rp_block_first.csv": ("period", "step"),
1236
+ "rp_block_last.csv": ("period", "step"),
1237
+ "rp_block_start_last.csv": ("rep_start", "last_step"),
1238
+ "rp_base_period_set.csv": ("period",),
1239
+ "rp_rep_period_set.csv": ("period",),
1240
+ "timestep_weight.csv": ("period", "time", "weight"),
1241
+ }
1242
+
1243
+
1244
+ def emit_empty_rp_data(
1245
+ *, provider) -> None:
1246
+ """Emit ``empty_rp_data`` to the Provider."""
1247
+ for filename, headers in _EMPTY_RP_HEADERS.items():
1248
+ _emit(provider, _RP_BASENAME_TO_PROVIDER_KEY[filename],
1249
+ _empty_frame(headers))