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,1009 @@
1
+ """``FlexData.dump_csvs(workdir)`` — debug oracle that writes a
2
+ materialised ``FlexData`` back to flextool's ``input/`` + ``solve_data/``
3
+ CSV layout.
4
+
5
+ **Use case.** When the DB-direct loader's output for some Param
6
+ diverges from what flextool's preprocessing would have produced, this
7
+ helper lets a developer dump both views to disk and ``diff`` them.
8
+ The DB-loaded ``FlexData`` is the in-memory canonical;
9
+ ``dump_csvs(tmpdir)`` materialises that canonical to CSV so a
10
+ side-by-side ``diff -r tmpdir tests/data/work_<fixture>`` surfaces the
11
+ divergence at file granularity.
12
+
13
+ **Round-trip contract.** After::
14
+
15
+ data = load_flextool(workdir, db_reader=reader)
16
+ data.dump_csvs(tmp)
17
+ redo = load_flextool(tmp)
18
+
19
+ every ``Param`` / ``DataFrame`` field on ``data`` and ``redo`` should
20
+ compare frame-equal up to row order. This is the basis of
21
+ :func:`tests.test_flex_dump_csvs_roundtrip.test_dump_csvs_roundtrip`.
22
+
23
+ **Scope.** The mapping covers every FlexData field that
24
+ ``flextool/input.py`` reads from a single canonical CSV via a
25
+ direct-rename (the bulk of the surface). A handful of Params today
26
+ read from sliced wide-by-param files (``pdtNode.csv``,
27
+ ``pdtCommodity.csv``, ``pdtGroup.csv``, ``pdtProcess.csv``,
28
+ ``pd_group.csv``, ``p_commodity.csv``, ``p_node.csv``, ``p_process.csv``,
29
+ ``p_group.csv``) — those slice files we reconstruct by writing the
30
+ sliced rows back into a wide-by-param file (one row per (entity,
31
+ param, …)). Where flextool emits both an ``input/`` and a
32
+ ``solve_data/`` copy of the same logical CSV, ``dump_csvs`` writes
33
+ to ``solve_data/`` (the canonical post-preprocessing location) and
34
+ also to ``input/`` when ``input.py`` reads from there for that field.
35
+
36
+ **Not in scope.** Per-solve metadata (``solve_current.csv``,
37
+ ``period_first_of_solve.csv``, ``solve_mode.csv``, etc.) — these are
38
+ flextool-runner state, not FlexData fields, and the round-trip path
39
+ doesn't need them when the source ``workdir`` is also handed to
40
+ ``load_flextool`` (we can copy them in advance). ``dump_csvs`` will
41
+ copy them through if a ``copy_meta_from`` argument is given (used by
42
+ the round-trip test).
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import os
47
+ import shutil
48
+ from pathlib import Path
49
+ from typing import TYPE_CHECKING, Any
50
+
51
+ import polars as pl
52
+
53
+ from polar_high import Param
54
+
55
+ from ._axis_enums import rename_to_axis, schema_dtype
56
+ from ._param_shapes import promote_param_to_dt
57
+
58
+ if TYPE_CHECKING:
59
+ from flextool.engine_polars.input import FlexData
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Heavy CSVs — gigabyte-scale on large fixtures (e.g. y2050 H2 supply
64
+ # curves: ~3.8 GB total per cascade iteration). These files are NOT
65
+ # consumed by ``flextool/process_outputs/`` or ``flextool/plot_outputs/``
66
+ # post-processing; they exist as a debug oracle for diff-roundtripping
67
+ # the in-memory FlexData against the on-disk CSV layout. The cascade's
68
+ # downstream consumers all read from ``flex_data.<field>.frame``
69
+ # in-memory.
70
+ #
71
+ # Set ``FLEXTOOL_DUMP_CSVS=1`` to restore the debug-oracle behaviour
72
+ # (write the full ~50 CSVs). When the env var is unset (default),
73
+ # these seven files are skipped — every other file in ``DIRECT_WRITES``
74
+ # and every sliced / metadata file continues to be emitted.
75
+ _HEAVY_CSV_FILES: frozenset[str] = frozenset({
76
+ "p_flow_max.csv",
77
+ "pdtProcess__source__sink__dt_varCost.csv",
78
+ "pdtProcess_slope.csv",
79
+ "pdtProcess.csv",
80
+ "pdtNode.csv",
81
+ "pdtNodeInflow.csv",
82
+ "ptNode_inflow.csv",
83
+ })
84
+
85
+
86
+ def _heavy_dumps_enabled() -> bool:
87
+ """Return True iff the heavy CSV writes are gated on (debug oracle).
88
+
89
+ The default is OFF: the seven gigabyte-scale CSVs in
90
+ :data:`_HEAVY_CSV_FILES` are not written by ``dump_csvs`` unless
91
+ ``FLEXTOOL_DUMP_CSVS=1`` is set in the environment.
92
+ """
93
+ return os.environ.get("FLEXTOOL_DUMP_CSVS") == "1"
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Field → CSV mapping.
98
+ #
99
+ # One entry per FlexData field we know how to write. Each entry is one of:
100
+ #
101
+ # * ``("solve_data", "<file>.csv", {flex_col: csv_col, ...})`` — direct
102
+ # write of the field's frame, applying the rename (renaming the
103
+ # short names back to flextool's long names).
104
+ #
105
+ # * ``("input", "<file>.csv", {...})`` — same, but to ``input/``.
106
+ #
107
+ # * ``("slice", kind, file, entity_col, param_value)`` — write into a
108
+ # wide-by-param file (e.g. ``pdtNode.csv`` rows with
109
+ # ``param=penalty_up``). The dump aggregates all slice rows for a
110
+ # file across multiple FlexData fields in a single pass.
111
+ #
112
+ # Unknown fields (None on the FlexData) are skipped silently — the
113
+ # round-trip semantics are "every populated field round-trips"; we
114
+ # never fabricate empty CSVs.
115
+
116
+ # Direct frame → CSV writes (set & param fields). Format:
117
+ # field_name -> (kind, csv_filename, rename_to_csv)
118
+ DIRECT_WRITES: dict[str, tuple[str, str, dict[str, str]]] = {
119
+ # ─── Time / weighting ─────────────────────────────────────────────
120
+ "p_step_duration": ("solve_data", "steps_in_use.csv",
121
+ {"d": "period", "t": "step", "value": "step_duration"}),
122
+ "p_timestep_weight": ("solve_data", "timestep_weight.csv",
123
+ {"d": "period", "t": "time", "value": "weight"}),
124
+ "p_inflation_op": ("solve_data", "p_inflation_factor_operations_yearly.csv",
125
+ {"d": "period", "value": "value"}),
126
+ "p_period_share": ("solve_data", "complete_period_share_of_year_calc.csv",
127
+ {"d": "period", "value": "value"}),
128
+ # ─── Nodes ────────────────────────────────────────────────────────
129
+ "nodeBalance": ("solve_data", "nodeBalance.csv", {"n": "node"}),
130
+ "p_inflow": ("solve_data", "pdtNodeInflow.csv",
131
+ {"n": "node", "d": "period", "t": "time", "value": "value"}),
132
+ # ─── Process topology ────────────────────────────────────────────
133
+ "process_source_sink": ("solve_data", "process_source_sink.csv",
134
+ {"p": "process"}),
135
+ "process_source_sink_eff": ("solve_data", "process_source_sink_eff.csv",
136
+ {"p": "process"}),
137
+ "process_source_sink_noEff": ("solve_data", "process_source_sink_noEff.csv",
138
+ {"p": "process"}),
139
+ "p_unitsize": ("solve_data", "p_entity_unitsize.csv",
140
+ {"p": "entity", "value": "value"}),
141
+ # p_flow_upper -> p_flow_max (long form: process, source, sink, period, time, value)
142
+ "p_flow_upper": ("solve_data", "p_flow_max.csv",
143
+ {"p": "process", "d": "period", "t": "time"}),
144
+ "p_slope": ("solve_data", "pdtProcess_slope.csv",
145
+ {"p": "process", "d": "period", "t": "time"}),
146
+ # ─── CO2 cap / price ─────────────────────────────────────────────
147
+ "group_co2_max_period": ("solve_data", "group_co2_max_period.csv",
148
+ {"g": "group"}),
149
+ "group_co2_max_total": ("solve_data", "group_co2_max_total.csv",
150
+ {"g": "group"}),
151
+ # ─── User-defined flow constraints ───────────────────────────────
152
+ # ─── Profiles ────────────────────────────────────────────────────
153
+ # ─── Invest / divest sets ────────────────────────────────────────
154
+ "ed_invest_set": ("solve_data", "ed_invest.csv",
155
+ {"e": "entity", "d": "period"}),
156
+ "ed_divest_set": ("solve_data", "ed_divest.csv",
157
+ {"e": "entity", "d": "period"}),
158
+ "pd_invest_set": ("solve_data", "pd_invest.csv",
159
+ {"p": "process", "d": "period"}),
160
+ "pd_divest_set": ("solve_data", "pd_divest.csv",
161
+ {"p": "process", "d": "period"}),
162
+ "nd_invest_set": ("solve_data", "nd_invest.csv",
163
+ {"n": "node", "d": "period"}),
164
+ "nd_divest_set": ("solve_data", "nd_divest.csv",
165
+ {"n": "node", "d": "period"}),
166
+ "edd_invest_set": ("solve_data", "edd_invest.csv",
167
+ {"e": "entity", "d_invest": "d_invest", "d": "period"}),
168
+ "e_invest_total": ("solve_data", "e_invest_total.csv",
169
+ {"e": "entity"}),
170
+ "e_divest_total": ("solve_data", "e_divest_total.csv",
171
+ {"e": "entity"}),
172
+ # ─── Online (UC) sets ────────────────────────────────────────────
173
+ "process_online": ("solve_data", "process_online.csv", {"p": "process"}),
174
+ "process_online_linear": ("solve_data", "process_online_linear.csv",
175
+ {"p": "process"}),
176
+ "process_online_integer": ("solve_data", "process_online_integer.csv",
177
+ {"p": "process"}),
178
+ "process_minload": ("solve_data", "process_minload.csv", {"p": "process"}),
179
+ # ─── Storage ──────────────────────────────────────────────────────
180
+ "nodeState": ("solve_data", "nodeState.csv", {"n": "node"}),
181
+ "nodeStateBlock": ("solve_data", "nodeStateBlock.csv", {"n": "node"}),
182
+ # ─── Variable cost ────────────────────────────────────────────────
183
+ "p_pssdt_varCost": ("solve_data", "pdtProcess__source__sink__dt_varCost.csv",
184
+ {"p": "process", "d": "period", "t": "time",
185
+ "value": "value"}),
186
+ # ─── Fixed cost / scaling ─────────────────────────────────────────
187
+ "p_ed_fixed_cost": ("solve_data", "ed_fixed_cost.csv",
188
+ {"e": "entity", "d": "period"}),
189
+ "p_entity_all_existing": ("solve_data", "p_entity_all_existing.csv",
190
+ {"e": "entity", "d": "period"}),
191
+ "p_node_capacity_for_scaling": ("solve_data", "node_capacity_for_scaling.csv",
192
+ {"n": "node", "d": "period"}),
193
+ # ─── DC power flow ────────────────────────────────────────────────
194
+ "node_dc_power_flow": ("input", "node_dc_power_flow.csv", {"n": "node"}),
195
+ "connection_dc_power_flow": ("input", "connection_dc_power_flow.csv",
196
+ {"p": "connection"}),
197
+ "node_reference_angle": ("input", "node_reference_angle.csv", {"n": "node"}),
198
+ # ─── Stochastics ──────────────────────────────────────────────────
199
+ "pdt_branch_weight": ("solve_data", "pdt_branch_weight.csv",
200
+ {"d": "period", "t": "time", "value": "value"}),
201
+ "pd_branch_weight": ("solve_data", "pd_branch_weight.csv",
202
+ {"d": "period", "value": "value"}),
203
+ "period_in_use_set": ("solve_data", "period_in_use_set.csv",
204
+ {"d": "period"}),
205
+ }
206
+
207
+
208
+ def _frame_of(value: Any) -> pl.DataFrame | None:
209
+ """Return the eager polars frame for a Param or DataFrame value.
210
+ Returns None when the value is None or empty.
211
+ """
212
+ if value is None:
213
+ return None
214
+ if isinstance(value, Param):
215
+ f = value.frame
216
+ elif isinstance(value, pl.DataFrame):
217
+ f = value
218
+ elif isinstance(value, pl.LazyFrame):
219
+ f = value.collect()
220
+ else:
221
+ return None
222
+ if f.height == 0:
223
+ return None
224
+ return f
225
+
226
+
227
+ def _frame_of_pdt(value: Any, dt: pl.DataFrame | None) -> pl.DataFrame | None:
228
+ """Return an eager (entity, d, t, value)-shaped frame for a Param.
229
+
230
+ Phase E.1: flex_data Params keep their authored dims (``(entity,)``
231
+ / ``(entity, d)`` / ``(entity, t)`` / ``(entity, d, t)``). The CSV
232
+ dump path needs the fully-expanded (entity, d, t) frame so the
233
+ output CSVs match the pre-Phase-E layout that downstream consumers
234
+ (GUI inspection, parity tests) expect. Promote via lazy joins on
235
+ ``dt`` when the Param is narrower than (entity, d, t).
236
+ """
237
+ if value is None or dt is None or dt.height == 0:
238
+ return _frame_of(value)
239
+ if isinstance(value, Param):
240
+ f = promote_param_to_dt(value, dt).collect()
241
+ elif isinstance(value, pl.DataFrame):
242
+ f = value
243
+ elif isinstance(value, pl.LazyFrame):
244
+ f = value.collect()
245
+ else:
246
+ return None
247
+ if f.height == 0:
248
+ return None
249
+ return f
250
+
251
+
252
+ def _write_frame(frame: pl.DataFrame, path: Path,
253
+ rename: dict[str, str]) -> None:
254
+ """Apply ``rename`` (flex columns → CSV columns), reorder columns
255
+ so the rename-target order matches the original ``rename`` dict
256
+ (insertion order = canonical column order), and write CSV.
257
+ Columns NOT in the rename map stay in their relative position
258
+ after the renamed ones.
259
+ """
260
+ df = frame
261
+ # Apply the rename only for present columns.
262
+ eff_rename = {k: v for k, v in rename.items() if k in df.columns}
263
+ if eff_rename:
264
+ df = df.pipe(rename_to_axis, eff_rename)
265
+ # Order: rename targets first (in dict order), then any remaining cols.
266
+ target_order = [v for v in rename.values() if v in df.columns]
267
+ rest = [c for c in df.columns if c not in target_order]
268
+ final_cols = target_order + rest
269
+ df = df.select(final_cols)
270
+ path.parent.mkdir(parents=True, exist_ok=True)
271
+ df.write_csv(path)
272
+
273
+
274
+ def dump_csvs(data: "FlexData", workdir: Path | str,
275
+ *, copy_meta_from: Path | str | None = None,
276
+ include_heavy: bool | None = None) -> Path:
277
+ """Materialise ``data`` to ``workdir/input/`` + ``workdir/solve_data/``
278
+ in flextool's CSV layout.
279
+
280
+ Parameters
281
+ ----------
282
+ data : FlexData
283
+ The in-memory FlexData to dump.
284
+ workdir : Path | str
285
+ Target directory. Created if missing.
286
+ copy_meta_from : Path | str, optional
287
+ When supplied, copies per-solve metadata files
288
+ (``solve_current.csv``, ``period_first_of_solve.csv``,
289
+ ``solve_mode.csv``, etc. — see :data:`_META_FILES_TO_COPY`)
290
+ from this source workdir into the target. This is the
291
+ round-trip use case where the CSV reader needs metadata
292
+ (timeline, solve list) that isn't on FlexData but is in the
293
+ original workdir.
294
+ include_heavy : bool, optional
295
+ When ``True``, force-write the seven gigabyte-scale CSVs in
296
+ :data:`_HEAVY_CSV_FILES` regardless of the
297
+ ``FLEXTOOL_DUMP_CSVS`` env var. When ``False``, force-skip
298
+ them. When ``None`` (default), consult the env var — set to
299
+ ``"1"`` to enable. The round-trip regression test
300
+ (``tests/engine_polars/test_dump_csvs_roundtrip.py``) passes
301
+ ``include_heavy=True`` to exercise the full debug-oracle
302
+ round-trip contract.
303
+
304
+ Returns
305
+ -------
306
+ Path
307
+ The ``workdir`` (as a :class:`pathlib.Path`).
308
+ """
309
+ work = Path(workdir)
310
+ inp_dir = work / "input"
311
+ sd_dir = work / "solve_data"
312
+ inp_dir.mkdir(parents=True, exist_ok=True)
313
+ sd_dir.mkdir(parents=True, exist_ok=True)
314
+
315
+ # ─── Direct field → CSV writes ───────────────────────────────────
316
+ heavy_on = _heavy_dumps_enabled() if include_heavy is None else include_heavy
317
+ for field, (kind, csv_name, rename) in DIRECT_WRITES.items():
318
+ if not hasattr(data, field):
319
+ continue
320
+ if not heavy_on and csv_name in _HEAVY_CSV_FILES:
321
+ # Skip gigabyte-scale CSVs that no post-processing reads
322
+ # (see :data:`_HEAVY_CSV_FILES` for the list).
323
+ continue
324
+ value = getattr(data, field)
325
+ f = _frame_of(value)
326
+ if f is None:
327
+ continue
328
+ target_dir = sd_dir if kind == "solve_data" else inp_dir
329
+ _write_frame(f, target_dir / csv_name, rename)
330
+
331
+ # ─── dt set (special: drop value column from p_step_duration) ─
332
+ # ``data.dt`` is the (d, t) set; flextool writes it implicitly via
333
+ # steps_in_use.csv (handled by the p_step_duration write above).
334
+ # If steps_in_use.csv was NOT written (no p_step_duration), but dt
335
+ # exists, fall back to writing a header-only steps_in_use.csv.
336
+ if (data.p_step_duration is None
337
+ and getattr(data, "dt", None) is not None):
338
+ f = data.dt.with_columns(value=pl.lit(1.0)) if data.dt.height > 0 else None
339
+ if f is not None:
340
+ _write_frame(f, sd_dir / "steps_in_use.csv",
341
+ {"d": "period", "t": "step", "value": "step_duration"})
342
+
343
+ # ─── flow_to_n / flow_from_n: derived sets used by the loader ──
344
+ # ``flow_to_n`` is just pss with sink renamed to n; we don't need to
345
+ # write a separate CSV — the loader reconstructs from
346
+ # process_source_sink.csv.
347
+
348
+ # ─── Sliced (wide-by-param) files ─────────────────────────────────
349
+ # pdtNode.csv carries (node, param, period, time, value) rows for
350
+ # multiple FlexData Params: penalty_up, penalty_down, availability,
351
+ # storage_state_reference_value. Same pattern for pdtProcess.csv,
352
+ # pdtCommodity.csv, pdtGroup.csv (period+time keys), and
353
+ # pdProcess.csv / pdGroup.csv (period only).
354
+ _write_pdt_sliced(data, sd_dir, heavy_on=heavy_on)
355
+ _write_pd_sliced(data, sd_dir)
356
+ _write_p_input_sliced(data, inp_dir)
357
+
358
+ # ─── Capacity / unitsize composites ───────────────────────────────
359
+ # p_entity_period_existing_capacity.csv has TWO value columns
360
+ # (existing + invested). Our p_entity_all_existing carries
361
+ # the 'all_existing' column; for round-trip we recompute existing =
362
+ # all_existing - 0 (no prior invest) and invested = 0. The reader
363
+ # prefers p_entity_all_existing.csv when it exists, so this is a
364
+ # stub for compatibility.
365
+ if data.p_entity_all_existing is not None:
366
+ f = _frame_of(data.p_entity_all_existing)
367
+ if f is not None:
368
+ stub = (f.pipe(rename_to_axis, {"e": "entity", "d": "period"})
369
+ .with_columns(
370
+ p_entity_period_existing_capacity=pl.col("value"),
371
+ p_entity_period_invested_capacity=pl.lit(0.0),
372
+ )
373
+ .select("entity", "period",
374
+ "p_entity_period_existing_capacity",
375
+ "p_entity_period_invested_capacity"))
376
+ stub.write_csv(sd_dir / "p_entity_period_existing_capacity.csv")
377
+
378
+ # ─── input/ entity-class set + wide-format unitsize ────────
379
+ # flextool's handoff_writers (process_outputs/handoff_writers.py) read
380
+ # ``input/p_entity_unitsize.csv`` (wide-transposed: ``entity,<e1>,<e2>,…``
381
+ # / ``value,<v1>,<v2>,…``) plus the entity-class set CSVs
382
+ # ``input/{entity,process_unit,process_connection,node}.csv``
383
+ # (single-column lists). These are produced by flextool's
384
+ # preprocessing pipeline on the slow path; the fast path skips
385
+ # preprocessing, so without these the handoff writers fail with
386
+ # ``[Errno 2]`` and the wide-format output writers downstream see
387
+ # an incomplete workdir. We synthesise them from FlexData here.
388
+ _write_input_entity_set_csvs(data, inp_dir)
389
+ _write_input_p_entity_unitsize_wide(data, inp_dir)
390
+
391
+ # ─── solve_data/ handoff stub CSVs ─────────────────────────
392
+ # ``write_p_entity_period_existing_capacity`` reads
393
+ # ``solve_data/solve__p_entity_pre_existing.csv`` (wide-by-entity,
394
+ # indexed by (solve, period)). In single-solve mode there's no
395
+ # prior solve so no pre-existing capacity has been carried over;
396
+ # write a header-only stub so ``pd.read_csv(... index_col=[0, 1])``
397
+ # returns an empty frame and the writer's ``if df.empty: return {}``
398
+ # branch fires. Idempotent — overwrites only when missing.
399
+ pre_existing_path = sd_dir / "solve__p_entity_pre_existing.csv"
400
+ if not pre_existing_path.exists():
401
+ pre_existing_path.write_text("solve,period\n")
402
+
403
+ # ─── Optional metadata copy-through ───────────────────────────────
404
+ if copy_meta_from is not None:
405
+ _copy_meta(Path(copy_meta_from), work)
406
+
407
+ return work
408
+
409
+
410
+ def _write_input_entity_set_csvs(data: "FlexData", inp_dir: Path) -> None:
411
+ """write the four single-column entity-class set CSVs that
412
+ handoff_writers' ``_load_entity_class_set`` reads.
413
+
414
+ ``input/node.csv`` — every row in ``data.nodeBalance`` (column ``n``).
415
+ ``input/process_unit.csv`` — every row in ``data.process_unit``
416
+ (column ``p``). Header-only when the field is None / empty.
417
+ ``input/process_connection.csv`` — every process in
418
+ ``data.process_source_sink`` whose ``p`` is NOT in
419
+ ``data.process_unit``. Header-only when no connection processes
420
+ exist (the common case for unit-only fixtures).
421
+ ``input/entity.csv`` — union of nodes + processes.
422
+
423
+ All four files MUST exist for handoff_writers to walk them; an
424
+ empty (header-only) file is fine.
425
+ """
426
+ # node.csv
427
+ nb = _frame_of(data.nodeBalance) if data.nodeBalance is not None else None
428
+ if nb is not None:
429
+ node_lf = nb.pipe(rename_to_axis, {"n": "node"}).select("node")
430
+ else:
431
+ _enums = getattr(data, "_axis_enums", None)
432
+ node_lf = pl.DataFrame({"node": []},
433
+ schema={"node": schema_dtype(_enums, "node")})
434
+ node_lf.write_csv(inp_dir / "node.csv")
435
+
436
+ # process_unit.csv
437
+ pu = _frame_of(data.process_unit) if data.process_unit is not None else None
438
+ if pu is not None:
439
+ pu_out = pu.rename({"p": "process_unit"}).select("process_unit")
440
+ else:
441
+ _enums = getattr(data, "_axis_enums", None)
442
+ pu_out = pl.DataFrame({"process_unit": []},
443
+ schema={"process_unit": schema_dtype(_enums, "process_unit")})
444
+ pu_out.write_csv(inp_dir / "process_unit.csv")
445
+
446
+ # process_connection.csv = (every process in pss) - process_unit
447
+ pss = (
448
+ _frame_of(data.process_source_sink)
449
+ if data.process_source_sink is not None
450
+ else None
451
+ )
452
+ pu_set: set[str] = (
453
+ set(pu["p"].cast(pl.Utf8).to_list()) if pu is not None else set()
454
+ )
455
+ if pss is not None and "p" in pss.columns:
456
+ all_p = set(pss["p"].cast(pl.Utf8).to_list())
457
+ conn_processes = sorted(all_p - pu_set)
458
+ else:
459
+ conn_processes = []
460
+ pl.DataFrame({"process_connection": conn_processes}).write_csv(
461
+ inp_dir / "process_connection.csv"
462
+ )
463
+
464
+ # entity.csv = nodes + processes (sorted, unique).
465
+ nodes = (
466
+ nb["n"].cast(pl.Utf8).to_list() if nb is not None else []
467
+ )
468
+ processes = (
469
+ list(set(pss["p"].cast(pl.Utf8).to_list())) if pss is not None and "p" in pss.columns else []
470
+ )
471
+ entities = sorted(set(nodes) | set(processes))
472
+ pl.DataFrame(
473
+ {"entity": entities},
474
+ schema={"entity": schema_dtype(None, "entity")},
475
+ ).write_csv(inp_dir / "entity.csv")
476
+
477
+
478
+ def _write_input_p_entity_unitsize_wide(data: "FlexData",
479
+ inp_dir: Path) -> None:
480
+ """write the wide-transposed ``input/p_entity_unitsize.csv``
481
+ that ``handoff_writers._load_unitsize`` consumes.
482
+
483
+ Layout (read with ``pd.read_csv(path, index_col=0)``):
484
+
485
+ entity,e1,e2,e3,...
486
+ value,v1,v2,v3,...
487
+
488
+ Source: union of ``data.p_unitsize`` (process unitsize, column ``p``)
489
+ and ``data.p_state_unitsize`` (node-state unitsize, column ``n``).
490
+ Entities not present in either Param fall back to 1000.0.
491
+ """
492
+ # Build the (entity → value) map.
493
+ entity_value: dict[str, float] = {}
494
+ pu_param = data.p_unitsize
495
+ pu_frame = _frame_of(pu_param) if pu_param is not None else None
496
+ if pu_frame is not None and "p" in pu_frame.columns and "value" in pu_frame.columns:
497
+ for row in pu_frame.iter_rows(named=True):
498
+ entity_value[str(row["p"])] = float(row["value"])
499
+ psu_param = getattr(data, "p_state_unitsize", None)
500
+ psu_frame = _frame_of(psu_param) if psu_param is not None else None
501
+ if psu_frame is not None and "n" in psu_frame.columns and "value" in psu_frame.columns:
502
+ for row in psu_frame.iter_rows(named=True):
503
+ entity_value[str(row["n"])] = float(row["value"])
504
+
505
+ # Discover the full entity universe so every node / process appears
506
+ # in the wide CSV — even when the Params didn't carry it. Default
507
+ # 1000.0 mirrors flextool's preprocessing default.
508
+ nb = _frame_of(data.nodeBalance) if data.nodeBalance is not None else None
509
+ pss = (
510
+ _frame_of(data.process_source_sink)
511
+ if data.process_source_sink is not None
512
+ else None
513
+ )
514
+ nodes = (
515
+ nb["n"].cast(pl.Utf8).to_list() if nb is not None else []
516
+ )
517
+ processes = (
518
+ list(set(pss["p"].cast(pl.Utf8).to_list())) if pss is not None and "p" in pss.columns else []
519
+ )
520
+ universe = sorted(set(nodes) | set(processes))
521
+ rows: list[tuple[str, float]] = [
522
+ (e, entity_value.get(e, 1000.0)) for e in universe
523
+ ]
524
+
525
+ # Wide-transposed: header row "entity,e1,e2,...", value row "value,v1,v2,...".
526
+ if not rows:
527
+ # Header-only file — handoff_writers' _load_unitsize would fail,
528
+ # but _load_unitsize_map (the forgiving variant) tolerates a
529
+ # missing 'value' index. Emit a single-column header so
530
+ # ``pd.read_csv(... index_col=0)`` returns an empty frame.
531
+ (inp_dir / "p_entity_unitsize.csv").write_text("entity\nvalue\n")
532
+ return
533
+ header = "entity," + ",".join(e for e, _ in rows)
534
+ values = "value," + ",".join(repr(v) for _, v in rows)
535
+ (inp_dir / "p_entity_unitsize.csv").write_text(header + "\n" + values + "\n")
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # Sliced-by-param writers
540
+
541
+ # Each entry: {param_value: (FlexData_field, dim_for_value)}. The
542
+ # key is the literal `param=` value in the wide-by-param CSV.
543
+ _PDT_NODE_SLICES = {
544
+ "penalty_up": "p_penalty_up",
545
+ "penalty_down": "p_penalty_down",
546
+ "availability": "p_node_availability",
547
+ "storage_state_reference_value": "p_storage_state_reference_value",
548
+ }
549
+
550
+ _PDT_PROCESS_SLICES = {
551
+ "availability": "p_process_availability",
552
+ }
553
+
554
+ _PDT_COMMODITY_SLICES = {
555
+ "price": "p_commodity_price",
556
+ }
557
+
558
+ _PDT_GROUP_SLICES = {
559
+ "co2_price": "p_co2_price",
560
+ }
561
+
562
+
563
+ def _write_pdt_sliced(data: "FlexData", sd_dir: Path,
564
+ *, heavy_on: bool | None = None) -> None:
565
+ """Write the wide-by-param ``pdt*.csv`` files (period + time keyed)."""
566
+ if heavy_on is None:
567
+ heavy_on = _heavy_dumps_enabled()
568
+ # pdtNode.csv columns: node, param, period, time, value
569
+ if heavy_on or "pdtNode.csv" not in _HEAVY_CSV_FILES:
570
+ rows: list[pl.DataFrame] = []
571
+ for param_value, field in _PDT_NODE_SLICES.items():
572
+ f = _frame_of_pdt(getattr(data, field, None), getattr(data, "dt", None))
573
+ if f is None:
574
+ continue
575
+ # Expected schema: (n, d, t, value) — rename to canonical and add
576
+ # the literal ``param=`` column.
577
+ ren = {"n": "node", "d": "period", "t": "time"}
578
+ rows.append((f.pipe(rename_to_axis,
579
+ {k: v for k, v in ren.items() if k in f.columns})
580
+ .with_columns(param=pl.lit(param_value))
581
+ .select("node", "param", "period", "time", "value")))
582
+ if rows:
583
+ out = pl.concat(rows, how="vertical_relaxed")
584
+ out.write_csv(sd_dir / "pdtNode.csv")
585
+
586
+ # pdtProcess.csv columns: process, param, period, time, value
587
+ if heavy_on or "pdtProcess.csv" not in _HEAVY_CSV_FILES:
588
+ rows = []
589
+ for param_value, field in _PDT_PROCESS_SLICES.items():
590
+ f = _frame_of_pdt(getattr(data, field, None), getattr(data, "dt", None))
591
+ if f is None:
592
+ continue
593
+ ren = {"p": "process", "d": "period", "t": "time"}
594
+ rows.append((f.pipe(rename_to_axis,
595
+ {k: v for k, v in ren.items() if k in f.columns})
596
+ .with_columns(param=pl.lit(param_value))
597
+ .select("process", "param", "period", "time", "value")))
598
+ if rows:
599
+ out = pl.concat(rows, how="vertical_relaxed")
600
+ out.write_csv(sd_dir / "pdtProcess.csv")
601
+
602
+ # pdtCommodity.csv columns: commodity, param, period, time, value
603
+ rows = []
604
+ for param_value, field in _PDT_COMMODITY_SLICES.items():
605
+ f = _frame_of_pdt(getattr(data, field, None), getattr(data, "dt", None))
606
+ if f is None:
607
+ continue
608
+ ren = {"c": "commodity", "d": "period", "t": "time"}
609
+ rows.append((f.pipe(rename_to_axis,
610
+ {k: v for k, v in ren.items() if k in f.columns})
611
+ .with_columns(param=pl.lit(param_value))
612
+ .select("commodity", "param", "period", "time", "value")))
613
+ if rows:
614
+ out = pl.concat(rows, how="vertical_relaxed")
615
+ out.write_csv(sd_dir / "pdtCommodity.csv")
616
+
617
+ # pdtGroup.csv columns: group, param, period, time, value
618
+ rows = []
619
+ for param_value, field in _PDT_GROUP_SLICES.items():
620
+ f = _frame_of_pdt(getattr(data, field, None), getattr(data, "dt", None))
621
+ if f is None:
622
+ continue
623
+ ren = {"g": "group", "d": "period", "t": "time"}
624
+ rows.append((f.pipe(rename_to_axis,
625
+ {k: v for k, v in ren.items() if k in f.columns})
626
+ .with_columns(param=pl.lit(param_value))
627
+ .select("group", "param", "period", "time", "value")))
628
+ if rows:
629
+ out = pl.concat(rows, how="vertical_relaxed")
630
+ out.write_csv(sd_dir / "pdtGroup.csv")
631
+
632
+
633
+ # pdProcess.csv columns: process, param, period, value
634
+ _PD_PROCESS_SLICES = {
635
+ "startup_cost": "p_startup_cost",
636
+ }
637
+
638
+ # pdGroup.csv columns: group, param, period, value (1d_map period)
639
+ _PD_GROUP_SLICES: dict[str, str] = {}
640
+
641
+
642
+ def _write_pd_sliced(data: "FlexData", sd_dir: Path) -> None:
643
+ """Write wide-by-param ``pd*.csv`` files (period only, no time)."""
644
+ rows: list[pl.DataFrame] = []
645
+ for param_value, field in _PD_PROCESS_SLICES.items():
646
+ f = _frame_of(getattr(data, field, None))
647
+ if f is None:
648
+ continue
649
+ ren = {"p": "process", "d": "period"}
650
+ rows.append((f.pipe(rename_to_axis,
651
+ {k: v for k, v in ren.items() if k in f.columns})
652
+ .with_columns(param=pl.lit(param_value))
653
+ .select("process", "param", "period", "value")))
654
+ if rows:
655
+ out = pl.concat(rows, how="vertical_relaxed")
656
+ out.write_csv(sd_dir / "pdProcess.csv")
657
+
658
+
659
+ # p_commodity.csv columns: commodity, commodityParam, p_commodity (long-by-param)
660
+ _P_COMMODITY_SLICES = {
661
+ "co2_content": "p_co2_content",
662
+ "unitsize": "p_commodity_unitsize",
663
+ }
664
+
665
+ # p_node.csv columns: node, nodeParam, p_node
666
+ _P_NODE_SLICES = {
667
+ "self_discharge_loss": "p_state_self_discharge",
668
+ "storage_state_start": "p_state_start",
669
+ }
670
+
671
+ # p_process.csv columns: process, processParam, p_process
672
+ _P_PROCESS_SLICES = {
673
+ "min_load": "p_min_load",
674
+ }
675
+
676
+
677
+ def _merge_param_slice(path: Path, new_rows: pl.DataFrame,
678
+ entity_col: str, param_col: str) -> pl.DataFrame:
679
+ """Merge a freshly-built wide-by-param slice with the existing file.
680
+
681
+ The legacy ``input_writer.write_parameter`` populates
682
+ ``input/p_<class>.csv`` with the full entity-level scalar surface
683
+ (e.g. for nodes: ``penalty_up``, ``existing``, ``lifetime``,
684
+ ``self_discharge_loss``, …). The override-chain-driven dump in
685
+ :func:`_write_p_input_sliced` knows the canonical value for only a
686
+ handful of slices (``_P_NODE_SLICES`` etc.), so a plain
687
+ ``write_csv`` would wipe the rest of the entity-level surface.
688
+
689
+ Read existing rows, drop those whose ``param_col`` is replaced by
690
+ *new_rows*, and concatenate. Both halves are normalised to Utf8
691
+ so ``vertical_relaxed`` does not have to promote across numeric /
692
+ string mismatches; ``write_csv`` produces text anyway.
693
+ """
694
+ if not path.exists():
695
+ return new_rows
696
+ existing = _read_csv_keep_columns(path)
697
+ if existing is None or existing.height == 0:
698
+ return new_rows
699
+ cols = new_rows.columns
700
+ keep = [c for c in cols if c in existing.columns]
701
+ if len(keep) != len(cols):
702
+ return new_rows
703
+ existing = existing.select(keep)
704
+ if param_col not in existing.columns:
705
+ return new_rows
706
+ replaced_params = new_rows.select(param_col).unique()
707
+ survivors = (existing.lazy()
708
+ .join(replaced_params.lazy().with_columns(_drop=pl.lit(True)),
709
+ on=param_col, how="left")
710
+ .filter(pl.col("_drop").is_null())
711
+ .drop("_drop")
712
+ .collect())
713
+ if survivors.height == 0:
714
+ return new_rows
715
+ new_rows_str = new_rows.with_columns(
716
+ [pl.col(c).cast(pl.Utf8) for c in new_rows.columns]
717
+ )
718
+ return pl.concat([survivors, new_rows_str], how="vertical_relaxed")
719
+
720
+
721
+ def _read_csv_keep_columns(path: Path) -> pl.DataFrame | None:
722
+ """Read a CSV with all columns as Utf8 to preserve original formatting.
723
+
724
+ Used by :func:`_merge_param_slice` to avoid coercing existing
725
+ numeric / string entity-level rows to a different dtype during the
726
+ merge. Returns ``None`` if the file is empty / unreadable.
727
+ """
728
+ try:
729
+ return pl.read_csv(path, infer_schema_length=0)
730
+ except Exception: # noqa: BLE001 — defensive
731
+ return None
732
+
733
+
734
+ def _write_p_input_sliced(data: "FlexData", inp_dir: Path) -> None:
735
+ """Write wide-by-param ``input/p_*.csv`` files (scalar slices).
736
+
737
+ These files carry the full entity-level scalar surface (e.g. for
738
+ nodes: ``penalty_up``, ``existing``, ``lifetime``, …). The
739
+ override chain only feeds FlexData for a subset (``_P_*_SLICES``),
740
+ so when a legacy ``input/p_<class>.csv`` already exists on disk
741
+ we merge our slices INTO it rather than overwriting — otherwise
742
+ legacy entity-level params would be silently dropped.
743
+ """
744
+ # p_commodity.csv
745
+ rows: list[pl.DataFrame] = []
746
+ for param_value, field in _P_COMMODITY_SLICES.items():
747
+ f = _frame_of(getattr(data, field, None))
748
+ if f is None:
749
+ continue
750
+ rec = (f.pipe(rename_to_axis, {"c": "commodity", "value": "p_commodity"})
751
+ .with_columns(commodityParam=pl.lit(param_value))
752
+ .select("commodity", "commodityParam", "p_commodity"))
753
+ rows.append(rec)
754
+ if rows:
755
+ new_rows = pl.concat(rows, how="vertical_relaxed")
756
+ merged = _merge_param_slice(inp_dir / "p_commodity.csv", new_rows,
757
+ entity_col="commodity",
758
+ param_col="commodityParam")
759
+ merged.write_csv(inp_dir / "p_commodity.csv")
760
+
761
+ rows = []
762
+ for param_value, field in _P_NODE_SLICES.items():
763
+ f = _frame_of(getattr(data, field, None))
764
+ if f is None:
765
+ continue
766
+ rec = (f.pipe(rename_to_axis, {"n": "node", "value": "p_node"})
767
+ .with_columns(nodeParam=pl.lit(param_value))
768
+ .select("node", "nodeParam", "p_node"))
769
+ rows.append(rec)
770
+ if rows:
771
+ new_rows = pl.concat(rows, how="vertical_relaxed")
772
+ merged = _merge_param_slice(inp_dir / "p_node.csv", new_rows,
773
+ entity_col="node",
774
+ param_col="nodeParam")
775
+ merged.write_csv(inp_dir / "p_node.csv")
776
+
777
+ rows = []
778
+ for param_value, field in _P_PROCESS_SLICES.items():
779
+ f = _frame_of(getattr(data, field, None))
780
+ if f is None:
781
+ continue
782
+ rec = (f.pipe(rename_to_axis, {"p": "process", "value": "p_process"})
783
+ .with_columns(processParam=pl.lit(param_value))
784
+ .select("process", "processParam", "p_process"))
785
+ rows.append(rec)
786
+ if rows:
787
+ new_rows = pl.concat(rows, how="vertical_relaxed")
788
+ merged = _merge_param_slice(inp_dir / "p_process.csv", new_rows,
789
+ entity_col="process",
790
+ param_col="processParam")
791
+ merged.write_csv(inp_dir / "p_process.csv")
792
+
793
+
794
+ # ---------------------------------------------------------------------------
795
+ # Metadata copy-through
796
+
797
+ # These files are read by ``load_flextool`` but are NOT FlexData fields.
798
+ # When a round-trip test wants the dumped workdir to reload via the CSV
799
+ # path, it copies these from the original workdir verbatim.
800
+ _META_FILES_TO_COPY: tuple[tuple[str, str], ...] = (
801
+ # (kind, filename)
802
+ ("solve_data", "solve_current.csv"),
803
+ ("solve_data", "period_first_of_solve.csv"),
804
+ ("solve_data", "period_first.csv"),
805
+ ("solve_data", "period_last.csv"),
806
+ ("solve_data", "period__time_last.csv"),
807
+ ("solve_data", "block_period_time_last.csv"),
808
+ ("solve_data", "step_previous.csv"),
809
+ ("solve_data", "p_years_d.csv"),
810
+ ("solve_data", "p_nested_model.csv"),
811
+ ("solve_data", "fix_storage_timesteps.csv"),
812
+ ("solve_data", "realized_invest_periods_of_current_solve.csv"),
813
+ ("solve_data", "realized_dispatch.csv"),
814
+ ("solve_data", "p_entity_pre_existing.csv"),
815
+ ("solve_data", "entity.csv"),
816
+ ("solve_data", "entityDivest.csv"),
817
+ ("solve_data", "process_side_block.csv"),
818
+ ("solve_data", "entity_block.csv"),
819
+ ("solve_data", "overlap_set.csv"),
820
+ ("solve_data", "block_step_duration.csv"),
821
+ ("solve_data", "period_block_set.csv"),
822
+ ("solve_data", "period_block_succ.csv"),
823
+ ("solve_data", "period_block_time.csv"),
824
+ ("solve_data", "period__branch.csv"),
825
+ ("solve_data", "first_timesteps.csv"),
826
+ ("solve_data", "solve_branch_weight.csv"),
827
+ ("input", "solve_mode.csv"),
828
+ ("input", "p_model.csv"),
829
+ ("input", "p_group.csv"),
830
+ ("input", "pd_group.csv"),
831
+ ("input", "p_flowgroup.csv"),
832
+ ("input", "pdt_flowgroup.csv"),
833
+ ("input", "process__ct_method.csv"),
834
+ ("input", "groupIncludeStochastics.csv"),
835
+ ("input", "commodity__node.csv"),
836
+ ("input", "group__node.csv"),
837
+ ("input", "group__entity.csv"),
838
+ ("input", "flowGroup__process__node.csv"),
839
+ ("input", "constraint__sense.csv"),
840
+ ("input", "p_constraint_constant.csv"),
841
+ ("input", "p_node_constraint_invested_capacity_coeff.csv"),
842
+ ("input", "p_process_constraint_invested_capacity_coeff.csv"),
843
+ ("input", "p_node_constraint_state_coeff.csv"),
844
+ ("input", "p_node_constraint_cumulative_pre_built_capacity_coeff.csv"),
845
+ ("input", "p_process_constraint_cumulative_pre_built_capacity_coeff.csv"),
846
+ ("input", "p_process_node_constraint_flow_coeff.csv"),
847
+ ("input", "p_process_source_conversion_flow_coeff.csv"),
848
+ ("input", "p_process_sink_conversion_flow_coeff.csv"),
849
+ ("input", "p_entity_unitsize.csv"),
850
+ ("input", "process__source__sink__profile__profile_method.csv"),
851
+ ("input", "node__profile__profile_method.csv"),
852
+ ("input", "node__storage_start_end_method.csv"),
853
+ ("input", "node__storage_solve_horizon_method.csv"),
854
+ ("input", "node__storage_binding_method.csv"),
855
+ ("input", "node__storage_nested_fix_method.csv"),
856
+ ("input", "p_reserve__upDown__group.csv"),
857
+ ("input", "p_process__reserve__upDown__node.csv"),
858
+ ("input", "groupNonSync.csv"),
859
+ ("input", "p_process_sink.csv"),
860
+ ("input", "p_process_source.csv"),
861
+ ("input", "node_dc_power_flow.csv"),
862
+ ("input", "connection_dc_power_flow.csv"),
863
+ ("input", "node_reference_angle.csv"),
864
+ ("input", "p_connection_susceptance.csv"),
865
+ ("input", "commodity_ladder_annual.csv"),
866
+ ("input", "commodity_ladder_cumulative.csv"),
867
+ ("input", "p_commodity_unitsize.csv"),
868
+ # solve_data — additional preprocessed files
869
+ ("solve_data", "process__method_indirect.csv"),
870
+ ("solve_data", "commodity_node_co2.csv"),
871
+ ("solve_data", "group_co2_price.csv"),
872
+ ("solve_data", "p_online_dt_set.csv"),
873
+ ("solve_data", "process_source_sink_ramp_limit_sink_up.csv"),
874
+ ("solve_data", "process_source_sink_ramp_limit_sink_down.csv"),
875
+ ("solve_data", "process_source_sink_ramp_limit_source_up.csv"),
876
+ ("solve_data", "process_source_sink_ramp_limit_source_down.csv"),
877
+ ("solve_data", "process_minload.csv"),
878
+ ("solve_data", "process_online_linear.csv"),
879
+ ("solve_data", "process_online_integer.csv"),
880
+ ("solve_data", "process_online.csv"),
881
+ ("solve_data", "ed_invest.csv"),
882
+ ("solve_data", "ed_divest.csv"),
883
+ ("solve_data", "edd_invest.csv"),
884
+ ("solve_data", "ed_invest_period.csv"),
885
+ ("solve_data", "ed_divest_period.csv"),
886
+ ("solve_data", "ed_invest_max_period.csv"),
887
+ ("solve_data", "ed_divest_max_period.csv"),
888
+ ("solve_data", "ed_invest_min_period.csv"),
889
+ ("solve_data", "ed_divest_min_period.csv"),
890
+ ("solve_data", "e_invest_total.csv"),
891
+ ("solve_data", "e_divest_total.csv"),
892
+ ("solve_data", "e_invest_max_total.csv"),
893
+ ("solve_data", "e_divest_max_total.csv"),
894
+ ("solve_data", "e_invest_min_total.csv"),
895
+ ("solve_data", "e_divest_min_total.csv"),
896
+ ("solve_data", "ed_lifetime_fixed_cost.csv"),
897
+ ("solve_data", "ed_lifetime_fixed_cost_divest.csv"),
898
+ ("solve_data", "ed_entity_annual_discounted.csv"),
899
+ ("solve_data", "ed_entity_annual_divest_discounted.csv"),
900
+ ("solve_data", "p_entity_max_units.csv"),
901
+ ("solve_data", "p_entity_period_existing_capacity.csv"),
902
+ ("solve_data", "p_entity_previously_invested_capacity.csv"),
903
+ ("solve_data", "p_entity_invested.csv"),
904
+ ("solve_data", "p_entity_divested.csv"),
905
+ ("solve_data", "ed_invest_forbidden_no_investment.csv"),
906
+ ("solve_data", "ed_invest_cumulative.csv"),
907
+ ("solve_data", "ed_cumulative_max_capacity.csv"),
908
+ ("solve_data", "ed_cumulative_min_capacity.csv"),
909
+ ("solve_data", "g_invest_total.csv"),
910
+ ("solve_data", "g_divest_total.csv"),
911
+ ("solve_data", "g_invest_cumulative.csv"),
912
+ ("solve_data", "gd_invest_period.csv"),
913
+ ("solve_data", "gd_divest_period.csv"),
914
+ ("solve_data", "n_fix_storage_quantity_set.csv"),
915
+ ("solve_data", "fix_storage_quantity.csv"),
916
+ ("solve_data", "p_roll_continue_state.csv"),
917
+ ("solve_data", "process_indirect.csv"),
918
+ ("solve_data", "process_input_flows.csv"),
919
+ ("solve_data", "process_output_flows.csv"),
920
+ ("solve_data", "ed_fixed_cost.csv"),
921
+ ("solve_data", "p_node.csv"),
922
+ ("solve_data", "p_process.csv"),
923
+ ("solve_data", "process_source_delayed.csv"),
924
+ ("solve_data", "process_source_undelayed.csv"),
925
+ ("solve_data", "process_source_sink_delayed.csv"),
926
+ ("solve_data", "process_source_sink_undelayed.csv"),
927
+ ("solve_data", "process_delayed.csv"),
928
+ ("solve_data", "process_delayed__duration.csv"),
929
+ ("solve_data", "p_process_delay_weight.csv"),
930
+ ("solve_data", "dtt__delay_duration.csv"),
931
+ ("solve_data", "commodity_with_ladder.csv"),
932
+ ("solve_data", "commodity_with_ladder_annual.csv"),
933
+ ("solve_data", "commodity_with_ladder_cumulative.csv"),
934
+ ("solve_data", "cnd_ladder_set.csv"),
935
+ ("solve_data", "cndi_ladder_set.csv"),
936
+ ("solve_data", "cndi_ladder_ann_set.csv"),
937
+ ("solve_data", "cndi_ladder_cum_set.csv"),
938
+ ("solve_data", "ci_ladder_cumulative.csv"),
939
+ ("solve_data", "commodity__tier_ann.csv"),
940
+ ("solve_data", "f_d_k.csv"),
941
+ ("solve_data", "ladder_cum_realized_mwh.csv"),
942
+ ("solve_data", "node_capacity_for_scaling.csv"),
943
+ ("solve_data", "group_capacity_for_scaling.csv"),
944
+ ("solve_data", "inv_group_cap.csv"),
945
+ ("solve_data", "group_node.csv"),
946
+ ("solve_data", "group_entity.csv"),
947
+ ("solve_data", "group_process_node.csv"),
948
+ ("solve_data", "process_unit.csv"),
949
+ ("solve_data", "process_sink_inertia.csv"),
950
+ ("solve_data", "process_source_inertia.csv"),
951
+ ("solve_data", "process__sink_nonSync.csv"),
952
+ ("solve_data", "process__group_inside_group_nonSync.csv"),
953
+ ("solve_data", "p_positive_inflow.csv"),
954
+ ("solve_data", "p_negative_inflow.csv"),
955
+ ("solve_data", "pdGroup.csv"),
956
+ ("solve_data", "pdGroup_capacity_margin.csv"),
957
+ ("solve_data", "pdGroup_inertia_limit.csv"),
958
+ ("solve_data", "pdGroup_penalty_capacity_margin.csv"),
959
+ ("solve_data", "pdGroup_penalty_inertia.csv"),
960
+ ("solve_data", "pdGroup_penalty_non_synchronous.csv"),
961
+ ("solve_data", "reserve__upDown__group.csv"),
962
+ ("solve_data", "reserve__upDown__group__method_timeseries.csv"),
963
+ ("solve_data", "reserve__upDown__group__method_dynamic.csv"),
964
+ ("solve_data", "reserve__upDown__group__method_n_1.csv"),
965
+ ("solve_data", "prundt.csv"),
966
+ ("solve_data", "process_reserve_upDown_node_active.csv"),
967
+ ("solve_data", "process_reserve_upDown_node_increase_reserve_ratio.csv"),
968
+ ("solve_data", "process_reserve_upDown_node_large_failure_ratio.csv"),
969
+ ("solve_data", "p_process_reserve_upDown_node_reliability.csv"),
970
+ ("solve_data", "pdtReserve_upDown_group.csv"),
971
+ ("solve_data", "dt_non_anticipativity_set.csv"),
972
+ ("solve_data", "timeline_matching_map.csv"),
973
+ ("solve_data", "fix_storage_quantity.csv"),
974
+ ("solve_data", "edd_history.csv"),
975
+ ("solve_data", "edd_history_invest.csv"),
976
+ ("solve_data", "pssdt_varCost_noEff.csv"),
977
+ ("solve_data", "pssdt_varCost_eff_unit_source.csv"),
978
+ ("solve_data", "pssdt_varCost_eff_unit_sink.csv"),
979
+ ("solve_data", "pssdt_varCost_eff_connection.csv"),
980
+ ("solve_data", "pdtProcess_source.csv"),
981
+ ("solve_data", "pdtProcess_sink.csv"),
982
+ )
983
+
984
+
985
+ def _copy_meta(src_workdir: Path, dst_workdir: Path) -> None:
986
+ """Copy metadata files from ``src`` to ``dst``, preserving directory.
987
+
988
+ Strategy: copy every ``.csv`` file from ``src/{input,solve_data}/``
989
+ that does NOT already exist in ``dst/{input,solve_data}/``. The
990
+ direct-write pass has already populated the FlexData-derived files;
991
+ this pass fills in metadata + structural files (timeline, period
992
+ markers, method discriminators, header-only filter sets, etc.) that
993
+ the CSV reader needs but that aren't FlexData fields.
994
+
995
+ The intent is that the dumped tree is a *complete* workdir that
996
+ ``load_flextool`` can consume — the CSV reader's many file-existence
997
+ probes don't crash because every CSV in the source is mirrored.
998
+ """
999
+ for kind in ("input", "solve_data"):
1000
+ src_dir = src_workdir / kind
1001
+ dst_dir = dst_workdir / kind
1002
+ if not src_dir.is_dir():
1003
+ continue
1004
+ dst_dir.mkdir(parents=True, exist_ok=True)
1005
+ for src in src_dir.glob("*.csv"):
1006
+ dst = dst_dir / src.name
1007
+ if dst.exists():
1008
+ continue # direct-write or earlier copy already populated
1009
+ shutil.copy(src, dst)