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,1799 @@
1
+ """In-memory ``read_parameters`` — polars ``FlexData`` → pandas wide.
2
+
3
+ Translates the polars :class:`FlexData` and
4
+ :class:`polar_high.Solution` objects directly into the pandas wide
5
+ format the downstream ``out_*`` modules consume. Every attribute on
6
+ the returned :class:`SimpleNamespace` maps to a FlexData field (or,
7
+ for ``entity_all_capacity``, a post-solve derivation from FlexData +
8
+ ``solution.value("v_invest")`` + ``solution.value("v_divest")``).
9
+
10
+ Properties:
11
+
12
+ * No CSV round-trip — empty multi-header CSVs no longer silently drop
13
+ column dim names.
14
+ * Post-solve derived attributes (``entity_all_capacity`` and the three
15
+ sister capacity tables) use the live ``solution`` directly.
16
+ * Dim names are set via the central
17
+ :data:`flextool.process_outputs._inmemory_helpers.DIM_NAMES`
18
+ translation table (no per-CSV ``.columns.name = '...'`` patchwork).
19
+
20
+ Failure mode: every helper raises loudly when a FlexData field is
21
+ absent or has an unexpected shape — authoring bugs surface at the
22
+ call-site instead of producing silently-empty outputs.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from types import SimpleNamespace
27
+ from typing import TYPE_CHECKING
28
+
29
+ import pandas as pd
30
+ import polars as pl
31
+
32
+ from flextool.process_outputs._inmemory_helpers import (
33
+ series_with_index,
34
+ series_with_multi_index,
35
+ wide_multi_col,
36
+ wide_per_entity,
37
+ with_solve_column,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from polar_high import Solution
42
+
43
+ from flextool.engine_polars.input import FlexData
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Per-attribute helpers
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def _empty_pdtX_per_entity(
52
+ *,
53
+ flex_data: "FlexData | None",
54
+ solve_name: str,
55
+ col_name: str,
56
+ dtype=float,
57
+ ) -> pd.DataFrame:
58
+ """Return a ``(solve, period, time)``-indexed DataFrame with empty
59
+ named columns.
60
+
61
+ Used for the per-(solve, period, time) family when the underlying
62
+ FlexData field is None / empty. Critically, when ``flex_data``
63
+ is supplied, the row index is materialised over the active solve's
64
+ full ``(d, t)`` axis — matching the legacy CSV path which wrote
65
+ ``solve,period,time`` rows for every timestep even when no data
66
+ columns were present. Without this, downstream broadcasts like
67
+ ``v.state.mul(par.node_self_discharge_loss, axis=1, level=0)``
68
+ raise ``TypeError: Join on level between two MultiIndex objects
69
+ is ambiguous`` because pandas can't reconcile two empty MultiIndex
70
+ on join.
71
+ """
72
+ if flex_data is not None and flex_data.dt is not None and flex_data.dt.height > 0:
73
+ dt_pdf = flex_data.dt.to_pandas()
74
+ idx = pd.MultiIndex.from_arrays(
75
+ [[solve_name] * len(dt_pdf), dt_pdf["d"].tolist(), dt_pdf["t"].tolist()],
76
+ names=["solve", "period", "time"],
77
+ )
78
+ else:
79
+ idx = pd.MultiIndex.from_arrays(
80
+ [[], [], []], names=["solve", "period", "time"],
81
+ )
82
+ out = pd.DataFrame(index=idx, dtype=dtype)
83
+ out.columns = pd.Index([], dtype="object", name=col_name)
84
+ return out
85
+
86
+
87
+ def _pdtX_per_entity(
88
+ param,
89
+ *,
90
+ solve_name: str,
91
+ entity_dim: str,
92
+ col_name: str | None = None,
93
+ flex_data: "FlexData | None" = None,
94
+ ) -> pd.DataFrame:
95
+ """Pivot a Param with dims ``(<entity>, d, t)`` to pandas wide-format
96
+ indexed by ``(solve, period, time)`` × entity.
97
+
98
+ ``param`` may be ``None`` / empty — returns an empty DataFrame
99
+ indexed over the full ``(solve, d, t)`` axis (taken from
100
+ ``flex_data.dt`` when supplied) so downstream broadcast-multiply
101
+ ops align cleanly.
102
+ """
103
+ if param is None or param.frame.height == 0:
104
+ return _empty_pdtX_per_entity(
105
+ flex_data=flex_data,
106
+ solve_name=solve_name,
107
+ col_name=col_name or entity_dim,
108
+ )
109
+ # Phase E.1: Param dims may be narrower than (entity, d, t) when
110
+ # the source was authored as scalar / 1d_map. The pandas pivot
111
+ # below requires explicit "d" and "t" columns, so promote via
112
+ # flex_data.dt when needed.
113
+ if "d" not in param.dims or "t" not in param.dims:
114
+ from flextool.engine_polars._param_shapes import promote_param_to_dt
115
+ if flex_data is None or flex_data.dt is None:
116
+ return _empty_pdtX_per_entity(
117
+ flex_data=flex_data,
118
+ solve_name=solve_name,
119
+ col_name=col_name or entity_dim,
120
+ )
121
+ pdt_frame = promote_param_to_dt(param, flex_data.dt).collect()
122
+ else:
123
+ pdt_frame = param.frame
124
+ pl_df = with_solve_column(pdt_frame, solve_name)
125
+ return wide_per_entity(
126
+ pl_df,
127
+ row_dims=("solve", "d", "t"),
128
+ col_dim=entity_dim,
129
+ row_names=("solve", "period", "time"),
130
+ col_name=col_name or entity_dim,
131
+ )
132
+
133
+
134
+ def _pdX_per_entity(
135
+ param,
136
+ *,
137
+ solve_name: str,
138
+ entity_dim: str,
139
+ col_name: str | None = None,
140
+ flex_data: "FlexData | None" = None,
141
+ densify_entities: "list[str] | None" = None,
142
+ ) -> pd.DataFrame:
143
+ """Pivot a Param with dims ``(<entity>, d)`` to pandas wide-format
144
+ indexed by ``(solve, period)`` × entity.
145
+
146
+ ``param`` may be ``None`` / empty — returns an empty DataFrame
147
+ indexed over the active solve's full ``(d,)`` axis (taken from
148
+ ``flex_data.dt`` when supplied).
149
+
150
+ ``densify_entities`` (optional) — when supplied, the result is
151
+ reindexed to include every entity in the list (zero-filling any
152
+ entity that doesn't appear in ``param``). The legacy CSV path
153
+ (``ed_fixed_cost.csv`` etc.) did this for the entity universe;
154
+ downstream consumers like
155
+ ``calc_costs.py:cost_entity_fixed_invested`` index the result
156
+ by ``v.invest.columns`` and expect every invest-entity column to
157
+ be present.
158
+ """
159
+ if flex_data is not None and flex_data.dt is not None and flex_data.dt.height > 0:
160
+ d_pdf = flex_data.dt.select("d").unique().to_pandas()
161
+ periods_full = d_pdf["d"].tolist()
162
+ else:
163
+ periods_full = []
164
+
165
+ if param is None or param.frame.height == 0:
166
+ if periods_full:
167
+ idx = pd.MultiIndex.from_arrays(
168
+ [[solve_name] * len(periods_full), periods_full],
169
+ names=["solve", "period"],
170
+ )
171
+ else:
172
+ idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
173
+ out = pd.DataFrame(index=idx, dtype=float)
174
+ out.columns = pd.Index([], dtype="object", name=col_name or entity_dim)
175
+ else:
176
+ pl_df = with_solve_column(param.frame, solve_name)
177
+ out = wide_per_entity(
178
+ pl_df,
179
+ row_dims=("solve", "d"),
180
+ col_dim=entity_dim,
181
+ row_names=("solve", "period"),
182
+ col_name=col_name or entity_dim,
183
+ )
184
+
185
+ if densify_entities:
186
+ # Add missing entities (zero-filled) and apply the stable column
187
+ # ordering in a single reindex. Inserting columns one at a time
188
+ # (``out[e] = 0.0`` in a loop) fragments the frame and triggers a
189
+ # PerformanceWarning once per insert — a single reindex avoids it.
190
+ ordered = list(densify_entities) + [
191
+ c for c in out.columns if c not in densify_entities
192
+ ]
193
+ out = out.reindex(columns=ordered, fill_value=0.0)
194
+ out.columns.name = col_name or entity_dim
195
+ return out
196
+
197
+
198
+ def _pX_per_entity(
199
+ param,
200
+ *,
201
+ entity_dim: str,
202
+ col_name: str | None = None,
203
+ ) -> pd.Series:
204
+ """Per-entity scalar Param ``(<entity>,)`` → pandas Series with
205
+ the entity as the index name.
206
+ """
207
+ if param is None or param.frame.height == 0:
208
+ return pd.Series(dtype=float, name="value", index=pd.Index([], name=col_name or entity_dim))
209
+ return series_with_index(
210
+ param.frame, dim=entity_dim, name=col_name or entity_dim,
211
+ )
212
+
213
+
214
+ def _pd_series_solve_period(
215
+ param,
216
+ *,
217
+ solve_name: str,
218
+ ) -> pd.Series:
219
+ """Per-period Param ``(d,)`` → pandas Series indexed by
220
+ ``(solve, period)``.
221
+ """
222
+ if param is None or param.frame.height == 0:
223
+ idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
224
+ return pd.Series(dtype=float, name="value", index=idx)
225
+ pl_df = with_solve_column(param.frame, solve_name)
226
+ pdf = pl_df.to_pandas()
227
+ s = pdf.set_index(["solve", "d"])["value"].astype(float)
228
+ s.index.names = ["solve", "period"]
229
+ s.name = "value"
230
+ return s
231
+
232
+
233
+ def _pdt_series_solve_period_time(
234
+ param,
235
+ *,
236
+ solve_name: str,
237
+ ) -> pd.Series:
238
+ """Param ``(d, t)`` → pandas Series indexed by
239
+ ``(solve, period, time)``.
240
+ """
241
+ if param is None or param.frame.height == 0:
242
+ idx = pd.MultiIndex.from_arrays([[], [], []], names=["solve", "period", "time"])
243
+ return pd.Series(dtype=float, name="value", index=idx)
244
+ pl_df = with_solve_column(param.frame, solve_name)
245
+ pdf = pl_df.to_pandas()
246
+ s = pdf.set_index(["solve", "d", "t"])["value"].astype(float)
247
+ s.index.names = ["solve", "period", "time"]
248
+ s.name = "value"
249
+ return s
250
+
251
+
252
+ def _empty_pdtX_multi_col(
253
+ *, col_names: tuple[str, str, str],
254
+ ) -> pd.DataFrame:
255
+ """Empty DataFrame with ``(solve, period, time)`` row index and a
256
+ 3-level empty column MultiIndex.
257
+ """
258
+ idx = pd.MultiIndex.from_arrays(
259
+ [[], [], []], names=["solve", "period", "time"]
260
+ )
261
+ out = pd.DataFrame(index=idx, dtype=float)
262
+ out.columns = pd.MultiIndex.from_arrays([[], [], []], names=list(col_names))
263
+ return out
264
+
265
+
266
+ def _pdtX_multi_col(
267
+ param,
268
+ *,
269
+ solve_name: str,
270
+ col_dims: tuple[str, str, str],
271
+ col_names: tuple[str, str, str],
272
+ densify_col_tuples: "pl.DataFrame | None" = None,
273
+ flex_data: "FlexData | None" = None,
274
+ ) -> pd.DataFrame:
275
+ """Param ``(*col_dims, d, t)`` → wide pandas with row
276
+ ``(solve, period, time)`` × column MultiIndex over col_dims.
277
+
278
+ ``densify_col_tuples`` (optional) — a polars frame whose first three
279
+ columns enumerate every ``(col_dims)`` tuple that must appear in the
280
+ result. Missing tuples are added as zero-valued columns; downstream
281
+ consumers (e.g. ``calc_slacks.q_reserves_dt``) broadcast-multiply
282
+ against ``v.q_reserve.columns`` which carries the full LP domain even
283
+ when the source ``reservation`` parameter is sparse.
284
+ """
285
+ if param is None or param.frame.height == 0:
286
+ out = _empty_pdtX_multi_col(col_names=col_names)
287
+ else:
288
+ # Phase E.1: Param dims may be narrower than (*col_dims, d, t)
289
+ # when the source was authored as scalar / 1d_map. Promote via
290
+ # flex_data.dt so the pivot below sees explicit d/t columns.
291
+ if "d" not in param.dims or "t" not in param.dims:
292
+ from flextool.engine_polars._param_shapes import promote_param_to_dt
293
+ if flex_data is None or flex_data.dt is None:
294
+ out = _empty_pdtX_multi_col(col_names=col_names)
295
+ if densify_col_tuples is None:
296
+ return out
297
+ # Fall through to densify path.
298
+ pdt_frame = None
299
+ else:
300
+ pdt_frame = promote_param_to_dt(param, flex_data.dt).collect()
301
+ else:
302
+ pdt_frame = param.frame
303
+ if pdt_frame is not None:
304
+ pl_df = with_solve_column(pdt_frame, solve_name)
305
+ out = wide_multi_col(
306
+ pl_df,
307
+ row_dims=("solve", "d", "t"),
308
+ col_dims=col_dims,
309
+ row_names=("solve", "period", "time"),
310
+ col_names=col_names,
311
+ )
312
+
313
+ if densify_col_tuples is None:
314
+ return out
315
+
316
+ gate_cols = densify_col_tuples.columns[: len(col_dims)]
317
+ gate_tuples = [
318
+ tuple(row)
319
+ for row in densify_col_tuples.select(gate_cols)
320
+ .sort(gate_cols)
321
+ .iter_rows()
322
+ ]
323
+ existing = list(out.columns) if isinstance(out.columns, pd.MultiIndex) else []
324
+ ordered = list(gate_tuples) + [c for c in existing if c not in gate_tuples]
325
+ full_cols = pd.MultiIndex.from_tuples(ordered, names=list(col_names))
326
+ out = out.reindex(columns=full_cols, fill_value=0.0)
327
+ return out
328
+
329
+
330
+ def _build_pssdt_varCost_alwaysProcess(
331
+ flex_data: "FlexData",
332
+ *,
333
+ solve_name: str,
334
+ ) -> pd.DataFrame:
335
+ """Build the ``_alwaysProcess``-keyed varCost frame for post-processing.
336
+
337
+ The .mod's objective splits ``other_operational_cost`` into four
338
+ sums (``pssdt_varCost_noEff``, ``..._eff_unit_source``,
339
+ ``..._eff_unit_sink``, ``..._eff_connection``). Python
340
+ post-processing collapses them into a single ``(p, source, sink)``
341
+ multiplication against ``r.flow_dt``; because ``r.flow_dt`` is
342
+ indexed by ``process_source_sink_alwaysProcess`` tuples, varCost
343
+ needs the same keying.
344
+
345
+ Reference: ``_emit_period_params._derive_varCost_pair`` (used to
346
+ write ``pdtProcess__source__sink__dt_varCost_alwaysProcess.csv``)
347
+ for the exact algebra. Replicated here directly on FlexData
348
+ Params (``p_pdt_varCost_source``, ``p_pdt_varCost_sink``,
349
+ ``p_pdt_varCost_process``) so we don't need to round-trip via the
350
+ on-disk CSV.
351
+
352
+ Returns a wide ``DataFrame`` with row index ``(solve, period, time)``
353
+ and column MultiIndex ``(process, source, sink)``. Tuples where
354
+ every dt-row would be zero are dropped (matches the CSV writer's
355
+ ``filter(value != 0.0)``).
356
+ """
357
+ pss_frame = flex_data.process_source_sink
358
+ if pss_frame is None or pss_frame.height == 0:
359
+ return _empty_pdtX_multi_col(
360
+ col_names=("process", "source", "sink"),
361
+ )
362
+
363
+ # Build alwaysProcess pss: each direct-method arc (src != p, snk != p)
364
+ # contributes BOTH ``(p, src, p)`` and ``(p, p, snk)``; arcs where p
365
+ # already appears as endpoint pass through unchanged. This mirrors
366
+ # the cascade-side derivation in
367
+ # ``flextool.process_outputs.read_sets`` ~ L380-390.
368
+ pss_pdf = pss_frame.select("p", "source", "sink").to_pandas()
369
+ always_rows: list[tuple[str, str, str]] = []
370
+ for p, src, snk in zip(
371
+ pss_pdf["p"], pss_pdf["source"], pss_pdf["sink"],
372
+ ):
373
+ if src == p or snk == p:
374
+ always_rows.append((p, src, snk))
375
+ else:
376
+ always_rows.append((p, src, p))
377
+ always_rows.append((p, p, snk))
378
+ # Dedup preserving order.
379
+ always_rows = list(dict.fromkeys(always_rows))
380
+
381
+ # Membership sets used to gate per-side OOC contributions. Use the
382
+ # canonical (process, node) sets (input-arc nodes for source,
383
+ # output-arc nodes for sink); ``process_source_sink`` itself
384
+ # contains intermediate (p, p) entries for indirect units and so
385
+ # cannot be projected directly.
386
+ proc_src_pairs: set[tuple[str, str]] = set()
387
+ if (flex_data.process_source_canonical is not None
388
+ and flex_data.process_source_canonical.height > 0):
389
+ for p, n in flex_data.process_source_canonical.select(
390
+ "p", "source",
391
+ ).iter_rows():
392
+ proc_src_pairs.add((str(p), str(n)))
393
+ proc_snk_pairs: set[tuple[str, str]] = set()
394
+ if (flex_data.process_sink_canonical is not None
395
+ and flex_data.process_sink_canonical.height > 0):
396
+ for p, n in flex_data.process_sink_canonical.select(
397
+ "p", "sink",
398
+ ).iter_rows():
399
+ proc_snk_pairs.add((str(p), str(n)))
400
+
401
+ def _as_lookup_dt(
402
+ param, key_cols: tuple[str, ...],
403
+ ):
404
+ """Materialise a FlexData Param into a lookup ``f(*full_key) → value``.
405
+
406
+ Phase E.1: ``broadcast_to_period_time`` returns Params whose dims
407
+ depend on the authored Spine shape (SCALAR → ``(p, sink)``,
408
+ MAP_PERIOD → ``(p, sink, d)``, MAP_PERIOD_TIME → ``(p, sink, d, t)``).
409
+ Polar_high broadcasts the lower-dim Params lazily at constraint
410
+ emission, but the output writers consume frames directly — so we
411
+ key the dict by the Param's actual dim subset of ``key_cols`` and
412
+ project the requested ``full_key`` onto that subset on lookup.
413
+ """
414
+ if param is None or param.frame.height == 0:
415
+ return lambda *_args: 0.0
416
+ present = tuple(c for c in key_cols if c in param.dims)
417
+ proj_idx = [key_cols.index(c) for c in present]
418
+ out: dict[tuple[str, ...], float] = {}
419
+ cols = [*present, "value"] if present else ["value"]
420
+ for row in param.frame.select(*cols).iter_rows():
421
+ if present:
422
+ *k, v = row
423
+ out[tuple(str(x) for x in k)] = float(v)
424
+ else:
425
+ # Pure scalar — single global value.
426
+ out[()] = float(row[0])
427
+ def _lookup(*full_key, _out=out, _idx=proj_idx):
428
+ return _out.get(tuple(full_key[i] for i in _idx), 0.0)
429
+ return _lookup
430
+
431
+ src_ooc = _as_lookup_dt(
432
+ flex_data.p_pdt_varCost_source, ("p", "source", "d", "t"),
433
+ )
434
+ snk_ooc = _as_lookup_dt(
435
+ flex_data.p_pdt_varCost_sink, ("p", "sink", "d", "t"),
436
+ )
437
+ proc_ooc = _as_lookup_dt(
438
+ flex_data.p_pdt_varCost_process, ("p", "d", "t"),
439
+ )
440
+
441
+ # dt grid for the broadcast.
442
+ dt_frame = flex_data.dt
443
+ if dt_frame is None or dt_frame.height == 0:
444
+ return _empty_pdtX_multi_col(
445
+ col_names=("process", "source", "sink"),
446
+ )
447
+ dt_pairs = [
448
+ (str(d), str(t))
449
+ for d, t in dt_frame.select("d", "t").iter_rows()
450
+ ]
451
+
452
+ # Build (solve, period, time, process, source, sink, value) long frame.
453
+ # For each alwaysProcess tuple + each (d, t), sum gated contributions.
454
+ rec_solve: list[str] = []
455
+ rec_d: list[str] = []
456
+ rec_t: list[str] = []
457
+ rec_p: list[str] = []
458
+ rec_src: list[str] = []
459
+ rec_snk: list[str] = []
460
+ rec_v: list[float] = []
461
+ for (p, src, snk) in always_rows:
462
+ # Track whether this tuple ever has a non-zero value — drop if
463
+ # universally zero (matches the CSV writer's filter).
464
+ any_nonzero = False
465
+ local_pairs: list[tuple[str, str, float]] = []
466
+ for (d, t) in dt_pairs:
467
+ v = 0.0
468
+ if (p, src) in proc_src_pairs:
469
+ v += src_ooc(p, src, d, t)
470
+ if (p, snk) in proc_snk_pairs:
471
+ v += snk_ooc(p, snk, d, t)
472
+ # alwaysProcess gating for the process-level OOC term:
473
+ # include only when one of the (always-process) endpoints is
474
+ # in process_source ∪ process_sink (matches
475
+ # ``_derive_varCost_pair`` ``if always: ... if (p, snk) in
476
+ # proc_snk or (p, snk) in proc_src``).
477
+ if (p, snk) in proc_snk_pairs or (p, snk) in proc_src_pairs:
478
+ v += proc_ooc(p, d, t)
479
+ if v != 0.0:
480
+ any_nonzero = True
481
+ local_pairs.append((d, t, v))
482
+ if not any_nonzero:
483
+ continue
484
+ for (d, t, v) in local_pairs:
485
+ rec_solve.append(solve_name)
486
+ rec_d.append(d)
487
+ rec_t.append(t)
488
+ rec_p.append(p)
489
+ rec_src.append(src)
490
+ rec_snk.append(snk)
491
+ rec_v.append(v)
492
+
493
+ if not rec_solve:
494
+ return _empty_pdtX_multi_col(
495
+ col_names=("process", "source", "sink"),
496
+ )
497
+
498
+ long_pl = pl.DataFrame({
499
+ "solve": rec_solve,
500
+ "d": rec_d,
501
+ "t": rec_t,
502
+ "p": rec_p,
503
+ "source": rec_src,
504
+ "sink": rec_snk,
505
+ "value": rec_v,
506
+ })
507
+ return wide_multi_col(
508
+ long_pl,
509
+ row_dims=("solve", "d", "t"),
510
+ col_dims=("p", "source", "sink"),
511
+ row_names=("solve", "period", "time"),
512
+ col_names=("process", "source", "sink"),
513
+ )
514
+
515
+
516
+ def _entity_all_capacity(
517
+ flex_data: "FlexData",
518
+ solution: "Solution",
519
+ *,
520
+ solve_name: str,
521
+ ) -> pd.DataFrame:
522
+ """Compute ``entity_all_capacity[(solve, period), entity]`` from
523
+ FlexData + the live solution.
524
+
525
+ This is the in-memory ``total``-capacity parameter consumed by the
526
+ rendering layer (``out_capacity`` etc.). It is independent of the
527
+ removed legacy ``handoff_writers`` capacity CSVs, using polars
528
+ long-form frames end-to-end.
529
+
530
+ Formula::
531
+
532
+ entity_all_capacity[e, d] =
533
+ existing[e, d]
534
+ + sum over (e, d_inv, d) in edd_invest of
535
+ v_invest[e, d_inv] * unitsize[e]
536
+ - sum over (e, d_dv) in ed_divest with years[d_dv] <= years[d] of
537
+ v_divest[e, d_dv] * unitsize[e]
538
+ """
539
+ # Collect base existing — start from p_entity_all_existing if
540
+ # available; else fall back to a (entity × period) zero frame
541
+ # spanning the active periods.
542
+ if (flex_data.p_entity_all_existing is not None
543
+ and flex_data.p_entity_all_existing.frame.height > 0):
544
+ existing_lf = flex_data.p_entity_all_existing.frame.lazy().select(
545
+ pl.col("e").cast(pl.Utf8),
546
+ pl.col("d").cast(pl.Utf8),
547
+ pl.col("value").alias("existing"),
548
+ )
549
+ else:
550
+ existing_lf = pl.DataFrame(
551
+ schema={"e": pl.Utf8, "d": pl.Utf8, "existing": pl.Float64},
552
+ ).lazy()
553
+
554
+ # Unitsize map: process p_unitsize ∪ node p_state_unitsize.
555
+ unitsize_pieces: list[pl.LazyFrame] = []
556
+ if (flex_data.p_unitsize is not None
557
+ and flex_data.p_unitsize.frame.height > 0):
558
+ unitsize_pieces.append(
559
+ flex_data.p_unitsize.frame.lazy().select(
560
+ pl.col("p").cast(pl.Utf8).alias("e"),
561
+ pl.col("value").alias("unitsize"),
562
+ )
563
+ )
564
+ if (flex_data.p_state_unitsize is not None
565
+ and flex_data.p_state_unitsize.frame.height > 0):
566
+ unitsize_pieces.append(
567
+ flex_data.p_state_unitsize.frame.lazy().select(
568
+ pl.col("n").cast(pl.Utf8).alias("e"),
569
+ pl.col("value").alias("unitsize"),
570
+ )
571
+ )
572
+ if unitsize_pieces:
573
+ unitsize_lf = pl.concat(unitsize_pieces, how="vertical")
574
+ else:
575
+ unitsize_lf = pl.DataFrame(
576
+ schema={"e": pl.Utf8, "unitsize": pl.Float64},
577
+ ).lazy()
578
+
579
+ # Active solution slices. ``solution.value`` returns long form
580
+ # ``(*dims, value)``. The polars LP splits invest/divest into
581
+ # process-side (``v_invest_p`` / ``v_divest_p``) and node-side
582
+ # (``v_invest_n`` / ``v_divest_n``) variables; we union them.
583
+ def _try_value(name: str) -> "pl.DataFrame":
584
+ if name not in solution._vars:
585
+ return pl.DataFrame(
586
+ schema={"e": pl.Utf8, "d": pl.Utf8, "value": pl.Float64},
587
+ )
588
+ df = solution.value(name)
589
+ # The polars LP uses ``p`` / ``n`` for the entity dim; rename
590
+ # to the canonical ``e``. Also accept ``entity`` / ``period``.
591
+ rename = {}
592
+ for src, dst in (("p", "e"), ("n", "e"),
593
+ ("entity", "e"), ("period", "d")):
594
+ if src in df.columns and dst not in df.columns:
595
+ rename[src] = dst
596
+ if rename:
597
+ df = df.rename(rename)
598
+ return df.select(
599
+ pl.col("e").cast(pl.Utf8),
600
+ pl.col("d").cast(pl.Utf8),
601
+ "value",
602
+ )
603
+
604
+ invest_pieces = []
605
+ divest_pieces = []
606
+ for nm in ("v_invest", "v_invest_p", "v_invest_n"):
607
+ f = _try_value(nm)
608
+ if f.height > 0:
609
+ invest_pieces.append(f)
610
+ for nm in ("v_divest", "v_divest_p", "v_divest_n"):
611
+ f = _try_value(nm)
612
+ if f.height > 0:
613
+ divest_pieces.append(f)
614
+ # FlexData / solution dim columns may be Enum dtype (from
615
+ # ``cast_flexdata_axes`` at the end of ``load_flextool``); empty
616
+ # fallback frames declare Utf8. ``_try_value`` casts the
617
+ # populated side to Utf8 (see above) so every concat input shares
618
+ # the empty-fallback dtype — no per-input cast needed here.
619
+ invest = (pl.concat(invest_pieces, how="vertical") if invest_pieces
620
+ else pl.DataFrame(schema={"e": pl.Utf8, "d": pl.Utf8, "value": pl.Float64}))
621
+ divest = (pl.concat(divest_pieces, how="vertical") if divest_pieces
622
+ else pl.DataFrame(schema={"e": pl.Utf8, "d": pl.Utf8, "value": pl.Float64}))
623
+
624
+ # invest contribution per (e, d) — by edd_invest indirection
625
+ if (flex_data.edd_invest_set is not None
626
+ and flex_data.edd_invest_set.height > 0):
627
+ edd = flex_data.edd_invest_set.lazy()
628
+ cols = flex_data.edd_invest_set.columns
629
+ # canonical: (e, d_invest, d) — rename if dim names differ
630
+ rename = {}
631
+ if cols[0] not in ("e", "entity"):
632
+ rename[cols[0]] = "e"
633
+ elif cols[0] == "entity":
634
+ rename["entity"] = "e"
635
+ if len(cols) > 1 and cols[1] != "d_invest":
636
+ rename[cols[1]] = "d_invest"
637
+ if len(cols) > 2 and cols[2] != "d":
638
+ rename[cols[2]] = "d"
639
+ if rename:
640
+ edd = edd.rename(rename)
641
+ edd = edd.with_columns(
642
+ pl.col("e").cast(pl.Utf8),
643
+ pl.col("d_invest").cast(pl.Utf8),
644
+ pl.col("d").cast(pl.Utf8),
645
+ )
646
+ invest_contrib = (
647
+ edd.join(invest.lazy().rename({"d": "d_invest", "value": "v_inv"}),
648
+ on=["e", "d_invest"], how="inner")
649
+ .join(unitsize_lf, on="e", how="inner")
650
+ .select(
651
+ "e", "d",
652
+ invested=pl.col("v_inv") * pl.col("unitsize"),
653
+ )
654
+ .group_by(["e", "d"]).agg(pl.col("invested").sum())
655
+ )
656
+ else:
657
+ invest_contrib = pl.DataFrame(
658
+ schema={"e": pl.Utf8, "d": pl.Utf8, "invested": pl.Float64},
659
+ ).lazy()
660
+
661
+ # divest contribution per (e, d) — for each (e, d_dv) in ed_divest_set,
662
+ # apply v_divest[e, d_dv] * unitsize[e] to every period d with
663
+ # years[d_dv] <= years[d].
664
+ if (flex_data.p_period_share is not None
665
+ and flex_data.p_period_share.frame.height > 0):
666
+ # We don't have years_from_start_d directly, but the divest
667
+ # carry-forward is monotone over period order; we approximate
668
+ # by using period name ordering (lexicographic). For
669
+ # production fidelity we want years_from_start_d — get it from
670
+ # the timeline. Instead rely on edd_divest_active which
671
+ # already gives the active (d_divest, d) pairs.
672
+ pass
673
+
674
+ if (flex_data.edd_divest_active is not None
675
+ and flex_data.edd_divest_active.height > 0):
676
+ edd_dv = flex_data.edd_divest_active.lazy()
677
+ cols = flex_data.edd_divest_active.columns
678
+ # canonical: (e, d_divest, d). edd_divest_active uses ``p`` for
679
+ # the entity dim — rename to ``e``.
680
+ rename = {}
681
+ if cols[0] not in ("e", "entity"):
682
+ rename[cols[0]] = "e"
683
+ elif cols[0] == "entity":
684
+ rename["entity"] = "e"
685
+ if len(cols) > 1 and cols[1] != "d_divest":
686
+ rename[cols[1]] = "d_divest"
687
+ if len(cols) > 2 and cols[2] != "d":
688
+ rename[cols[2]] = "d"
689
+ if rename:
690
+ edd_dv = edd_dv.rename(rename)
691
+ edd_dv = edd_dv.with_columns(
692
+ pl.col("e").cast(pl.Utf8),
693
+ pl.col("d_divest").cast(pl.Utf8),
694
+ pl.col("d").cast(pl.Utf8),
695
+ )
696
+ divest_contrib = (
697
+ edd_dv.join(divest.lazy().rename({"d": "d_divest", "value": "v_dv"}),
698
+ on=["e", "d_divest"], how="inner")
699
+ .join(unitsize_lf, on="e", how="inner")
700
+ .select(
701
+ "e", "d",
702
+ divested=pl.col("v_dv") * pl.col("unitsize"),
703
+ )
704
+ .group_by(["e", "d"]).agg(pl.col("divested").sum())
705
+ )
706
+ elif (flex_data.ed_divest_set is not None
707
+ and flex_data.ed_divest_set.height > 0):
708
+ # Fallback: each (e, d_dv) divests carry-over from d_dv onward
709
+ # within the same period set. Without years map, take only
710
+ # the divestment in the same period.
711
+ edd_dv = flex_data.ed_divest_set.lazy()
712
+ cols = flex_data.ed_divest_set.columns
713
+ rename = {}
714
+ if cols[0] not in ("e", "entity"):
715
+ rename[cols[0]] = "e"
716
+ elif cols[0] == "entity":
717
+ rename["entity"] = "e"
718
+ if len(cols) > 1 and cols[1] != "d":
719
+ rename[cols[1]] = "d"
720
+ if rename:
721
+ edd_dv = edd_dv.rename(rename)
722
+ edd_dv = edd_dv.with_columns(
723
+ pl.col("e").cast(pl.Utf8),
724
+ pl.col("d").cast(pl.Utf8),
725
+ )
726
+ divest_contrib = (
727
+ edd_dv.join(divest.lazy().rename({"value": "v_dv"}),
728
+ on=["e", "d"], how="inner")
729
+ .join(unitsize_lf, on="e", how="inner")
730
+ .select(
731
+ "e", "d",
732
+ divested=pl.col("v_dv") * pl.col("unitsize"),
733
+ )
734
+ )
735
+ else:
736
+ divest_contrib = pl.DataFrame(
737
+ schema={"e": pl.Utf8, "d": pl.Utf8, "divested": pl.Float64},
738
+ ).lazy()
739
+
740
+ # Combine: outer-join on (e, d) and sum. ``how="full"`` requires
741
+ # explicit coalesce on the join keys. Every input has already
742
+ # been normalised to Utf8 ``e`` / ``d`` above (so Enum-typed
743
+ # populated frames don't clash with the Utf8 empty fallbacks).
744
+ j1 = existing_lf.join(
745
+ invest_contrib, on=["e", "d"], how="full", coalesce=True,
746
+ )
747
+ j2 = j1.join(divest_contrib, on=["e", "d"], how="full", coalesce=True)
748
+ combined = (
749
+ j2.with_columns(
750
+ existing=pl.col("existing").fill_null(0.0),
751
+ invested=pl.col("invested").fill_null(0.0),
752
+ divested=pl.col("divested").fill_null(0.0),
753
+ )
754
+ .with_columns(
755
+ total=pl.col("existing") + pl.col("invested") - pl.col("divested"),
756
+ )
757
+ .filter(pl.col("e").is_not_null() & pl.col("d").is_not_null())
758
+ .select("e", "d", "total")
759
+ .collect()
760
+ )
761
+ if combined.height == 0:
762
+ idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
763
+ out = pd.DataFrame(index=idx, dtype=float)
764
+ out.columns.name = "entity"
765
+ return out
766
+ pl_df = combined.with_columns(pl.lit(solve_name).alias("solve"))
767
+ return wide_per_entity(
768
+ pl_df.rename({"total": "value"}),
769
+ row_dims=("solve", "d"),
770
+ col_dim="e",
771
+ row_names=("solve", "period"),
772
+ col_name="entity",
773
+ )
774
+
775
+
776
+ def _ensure_value_zero(df: pd.DataFrame, columns) -> pd.DataFrame:
777
+ """Ensure ``df`` has columns with zero values. Used to create the
778
+ ``p_node`` / ``p_process_source`` / ``p_process_sink`` row-by-param
779
+ legacy frames for which we no longer have a single source FlexData
780
+ field; downstream consumers test ``loc['inertia_constant']`` etc.
781
+ """
782
+ if df is None:
783
+ return None
784
+ for c in columns:
785
+ if c not in df.columns:
786
+ df[c] = 0.0
787
+ return df
788
+
789
+
790
+ # ---------------------------------------------------------------------------
791
+ # Composite legacy frames
792
+ # ---------------------------------------------------------------------------
793
+
794
+
795
+ def _build_p_node(flex_data: "FlexData") -> pd.DataFrame:
796
+ """Construct the legacy ``p_node`` wide table.
797
+
798
+ Legacy CSV layout (``solve_data/p_node.csv``)::
799
+
800
+ param,west
801
+ annual_flow,0
802
+ peak_inflow,0
803
+
804
+ Rows are parameter names; columns are nodes. The only consumer
805
+ today is :func:`out_node.node_summary` indirectly via
806
+ ``r.node_inflow_d`` / ``s.node_balance``; the table is otherwise
807
+ unread. We build it from the available FlexData fields and fall
808
+ back to zeros for missing parameters.
809
+ """
810
+ nodes = []
811
+ if (flex_data.nodeBalance is not None
812
+ and flex_data.nodeBalance.height > 0):
813
+ nodes = flex_data.nodeBalance["n"].to_list()
814
+ if not nodes:
815
+ out = pd.DataFrame(index=pd.Index([], name="param"), dtype=float)
816
+ out.columns.name = "node"
817
+ return out
818
+ # Known scalar-per-node params we can fill from FlexData.
819
+ rows: dict[str, dict[str, float]] = {
820
+ "annual_flow": {n: 0.0 for n in nodes},
821
+ "peak_inflow": {n: 0.0 for n in nodes},
822
+ }
823
+ out = pd.DataFrame(rows).T.astype(float)
824
+ out.index.name = "param"
825
+ out.columns.name = "node"
826
+ out = out.reindex(columns=nodes)
827
+ return out
828
+
829
+
830
+ def _build_p_process_per_arc(
831
+ flex_data: "FlexData",
832
+ *,
833
+ side: str, # "source" or "sink"
834
+ ) -> pd.DataFrame:
835
+ """Build the legacy ``p_process_<side>`` wide table.
836
+
837
+ Legacy CSV layout (``solve_data/p_process_source.csv``)::
838
+
839
+ process,p1,p2,...
840
+ source,s1,s2,...
841
+ efficiency,...,...
842
+ ...
843
+ inertia_constant,...,...
844
+
845
+ Rows are parameter names (``efficiency``, ``ramp_speed_up``,
846
+ ``inertia_constant``, …); columns are a MultiIndex of ``(process,
847
+ <side>)``. The downstream consumer (``out_ancillary``) reads
848
+ ``loc['inertia_constant']`` only — we populate that row from the
849
+ relevant FlexData field and zero-fill the rest.
850
+ """
851
+ if side == "source":
852
+ pss = flex_data.process_source_sink
853
+ ramp_up = flex_data.p_ramp_speed_up_source
854
+ ramp_down = flex_data.p_ramp_speed_down_source
855
+ inertia = flex_data.p_process_source_inertia_constant
856
+ side_dim = "source"
857
+ elif side == "sink":
858
+ pss = flex_data.process_source_sink
859
+ ramp_up = flex_data.p_ramp_speed_up_sink
860
+ ramp_down = flex_data.p_ramp_speed_down_sink
861
+ inertia = flex_data.p_process_sink_inertia_constant
862
+ side_dim = "sink"
863
+ else:
864
+ raise ValueError(f"side must be 'source' or 'sink', got {side!r}")
865
+
866
+ # Build the canonical row-by-param row list — same order, same names
867
+ # as the legacy CSV so downstream ``loc['inertia_constant']`` etc.
868
+ # work whether columns are empty or not.
869
+ row_names = [
870
+ "efficiency",
871
+ "efficiency_at_min_load",
872
+ "min_load",
873
+ "coefficient",
874
+ "flow_unitsize",
875
+ "other_operational_cost",
876
+ "ramp_cost",
877
+ "ramp_speed_up",
878
+ "ramp_speed_down",
879
+ "inertia_constant",
880
+ ]
881
+
882
+ if pss is None or pss.height == 0:
883
+ cols = pd.MultiIndex.from_arrays(
884
+ [[], []], names=["process", side_dim]
885
+ )
886
+ out = pd.DataFrame(
887
+ index=pd.Index(row_names, name="param"), columns=cols, dtype=float,
888
+ )
889
+ return out
890
+
891
+ # Distinct (process, <side>) pairs. ``process_source_sink`` carries
892
+ # ``(p, source, sink)``; we want unique (process, source) for source
893
+ # and unique (process, sink) for sink.
894
+ pdf = pss.select("p", side_dim).unique().to_pandas()
895
+ pairs = list(zip(pdf["p"], pdf[side_dim]))
896
+ cols = pd.MultiIndex.from_tuples(
897
+ pairs, names=["process", side_dim]
898
+ )
899
+
900
+ # Build rows for known parameters. All zeros except where FlexData
901
+ # gives an explicit value. Order matches the legacy CSV.
902
+ out = pd.DataFrame(0.0, index=row_names, columns=cols, dtype=float)
903
+ out.index.name = "param"
904
+
905
+ def _fill_row(row: str, param) -> None:
906
+ if param is None or param.frame.height == 0:
907
+ return
908
+ # param.frame columns: (p, <side>, value)
909
+ pf = param.frame.to_pandas()
910
+ # Resolve the entity dim name: source | sink
911
+ ent_col = side_dim if side_dim in pf.columns else (
912
+ "name" if "name" in pf.columns else side_dim
913
+ )
914
+ for _, r in pf.iterrows():
915
+ key = (r["p"], r[ent_col])
916
+ if key in cols:
917
+ out.loc[row, key] = float(r["value"])
918
+
919
+ _fill_row("ramp_speed_up", ramp_up)
920
+ _fill_row("ramp_speed_down", ramp_down)
921
+ _fill_row("inertia_constant", inertia)
922
+ return out
923
+
924
+
925
+ # ---------------------------------------------------------------------------
926
+ # Main entry point
927
+ # ---------------------------------------------------------------------------
928
+
929
+
930
+ def _build_entity_universe(flex_data: "FlexData") -> list[str]:
931
+ """Build the densify entity universe for the (entity, period) wide
932
+ frames (``entity_fixed_cost`` / ``entity_lifetime_fixed_cost*``).
933
+
934
+ ``calc_costs.py:cost_entity_fixed_invested`` indexes those frames by
935
+ ``v.invest.columns`` / ``v.divest.columns`` — the cross-solve column
936
+ union — so every column they reach must be present. The returned
937
+ list is the order-preserving, de-duplicated union of:
938
+
939
+ * ``nodeBalance`` nodes,
940
+ * ``process_source_sink`` processes + their source / sink nodes,
941
+ * every invest / divest variable entity (``ed_invest_set`` /
942
+ ``ed_divest_set``), and
943
+ * the solve-invariant ``p_all_entity_unitsize`` carrier (unit ∪ node
944
+ ∪ connection over the WHOLE-model cascade — the same superset that
945
+ backs ``entity_unitsize`` after 896263be).
946
+
947
+ The carrier is the crucial last source: the per-solve sets above only
948
+ see the entities active in THIS sub-solve, but ``v.invest`` /
949
+ ``v.divest`` carry the cross-solve union. An entity that is
950
+ invest-eligible in some other roll (e.g. ``CaboVerde_wind``) is
951
+ otherwise absent from every step's universe and so missing from the
952
+ concatenated ``entity_lifetime_fixed_cost`` columns, raising
953
+ ``KeyError`` when ``calc_costs`` indexes by ``v.invest.columns``
954
+ (calc_costs.py:168). Densifying over the carrier's full entity list
955
+ zero-fills those cells, matching the legacy CSV path's full-universe
956
+ coverage.
957
+ """
958
+ universe: list[str] = []
959
+ if (flex_data.nodeBalance is not None
960
+ and flex_data.nodeBalance.height > 0):
961
+ universe.extend(flex_data.nodeBalance["n"].to_list())
962
+ if (flex_data.process_source_sink is not None
963
+ and flex_data.process_source_sink.height > 0):
964
+ universe.extend(
965
+ flex_data.process_source_sink.select("p").unique()
966
+ .to_pandas()["p"].tolist()
967
+ )
968
+ # Also include any commodity nodes that show up in process_source_sink
969
+ # (sources / sinks) — these can be invest entities too.
970
+ if (flex_data.process_source_sink is not None
971
+ and flex_data.process_source_sink.height > 0):
972
+ for col in ("source", "sink"):
973
+ extra = flex_data.process_source_sink.select(col).unique().to_pandas()[col].tolist()
974
+ universe.extend(extra)
975
+ # Every invest / divest variable entity. ``ed_invest_set`` /
976
+ # ``ed_divest_set`` carry the (e, d) variable index that becomes the
977
+ # ``v_invest`` / ``v_divest`` column set (read_sets.py:267-280 builds
978
+ # ``s.entityInvest`` / ``s.entityDivest`` from the same frames). An
979
+ # entity can be invest-eligible (``invest_method`` set) without
980
+ # appearing in ``process_source_sink`` — e.g. a connection that has
981
+ # invest parameters but no ``connection__node__node`` endpoints, so it
982
+ # carries no source/sink rows. It still produces a (degenerate,
983
+ # always-zero) ``v_invest`` column.
984
+ for invest_set in (flex_data.ed_invest_set, flex_data.ed_divest_set):
985
+ if invest_set is not None and invest_set.height > 0:
986
+ universe.extend(
987
+ invest_set.select("e").unique().to_pandas()["e"].tolist()
988
+ )
989
+ # Solve-invariant whole-model carrier (covers cross-solve invest
990
+ # candidates absent from this sub-solve's sets — see docstring).
991
+ carrier = getattr(flex_data, "p_all_entity_unitsize", None)
992
+ if (carrier is not None
993
+ and getattr(carrier, "frame", None) is not None
994
+ and carrier.frame.height > 0):
995
+ universe.extend(
996
+ carrier.frame.select("e").unique().to_pandas()["e"].tolist()
997
+ )
998
+ # Deduplicate while keeping insertion order.
999
+ return list(dict.fromkeys(universe))
1000
+
1001
+
1002
+ def read_parameters(
1003
+ flex_data: "FlexData",
1004
+ solution: "Solution",
1005
+ *,
1006
+ solve_name: str = "solve",
1007
+ ) -> SimpleNamespace:
1008
+ """Translate ``FlexData`` + a polars-LP :class:`polar_high.Solution`
1009
+ into the legacy ``par`` namespace consumed by the
1010
+ :mod:`flextool.process_outputs.out_*` modules.
1011
+
1012
+ Parameters
1013
+ ----------
1014
+ flex_data : FlexData
1015
+ The polars input bundle the LP was built from.
1016
+ solution : polar_high.Solution
1017
+ The solved LP — used for the post-solve derived attributes
1018
+ (``entity_all_capacity`` and the three sister capacity tables).
1019
+ solve_name : str, optional
1020
+ The active solve identifier. Used to inject the leading
1021
+ ``solve`` index level the downstream wide-format consumers
1022
+ expect. Defaults to ``"solve"`` for tests; production callers
1023
+ in :mod:`flextool.engine_polars._orchestration` pass the real
1024
+ ``complete_solve_name``.
1025
+
1026
+ Returns
1027
+ -------
1028
+ SimpleNamespace
1029
+ With every attribute populated to match the legacy CSV-path
1030
+ signature. Failure to populate any required attribute raises
1031
+ :class:`KeyError` / :class:`ValueError` loudly.
1032
+ """
1033
+ p = SimpleNamespace()
1034
+
1035
+ # Entity universe — used to densify the (entity, period) wide frames
1036
+ # so downstream consumers like
1037
+ # ``calc_costs.py:cost_entity_fixed_invested`` find every invest
1038
+ # entity in ``par.entity_lifetime_fixed_cost.columns`` (the legacy
1039
+ # CSV path emitted zero-filled rows for every entity).
1040
+ _entity_universe = _build_entity_universe(flex_data)
1041
+
1042
+ # ─── Write-once static params (entity-level scalars / per-arc tables) ──
1043
+ p.node = _build_p_node(flex_data)
1044
+ p.entity_unitsize = _build_entity_unitsize_series(
1045
+ flex_data, entity_universe=_entity_universe,
1046
+ )
1047
+
1048
+ # commodity_co2_content — Series indexed by commodity (legacy used
1049
+ # to read this from input/p_commodity_co2_content.csv; in FlexData
1050
+ # it's ``p_co2_content`` keyed on ``c``).
1051
+ if (flex_data.p_co2_content is not None
1052
+ and flex_data.p_co2_content.frame.height > 0):
1053
+ p.commodity_co2_content = series_with_index(
1054
+ flex_data.p_co2_content.frame, dim="c", name="commodity",
1055
+ )
1056
+ else:
1057
+ p.commodity_co2_content = pd.Series(
1058
+ dtype=float,
1059
+ index=pd.Index([], name="commodity"),
1060
+ )
1061
+
1062
+ # process_sink_conversion_flow_coeff /
1063
+ # process_source_conversion_flow_coeff — Series with MultiIndex
1064
+ # (process, sink|source). FlexData carries these as
1065
+ # ``p_process_sink_conversion_flow_coeff`` /
1066
+ # ``p_process_source_conversion_flow_coeff`` (dims ``(p, sink)`` /
1067
+ # ``(p, source)``).
1068
+ if (flex_data.p_process_sink_conversion_flow_coeff is not None
1069
+ and flex_data.p_process_sink_conversion_flow_coeff.frame.height > 0):
1070
+ p.process_sink_conversion_flow_coeff = series_with_multi_index(
1071
+ flex_data.p_process_sink_conversion_flow_coeff.frame,
1072
+ dims=("p", "sink"),
1073
+ names=["process", "sink"],
1074
+ )
1075
+ else:
1076
+ p.process_sink_conversion_flow_coeff = pd.Series(
1077
+ dtype=float,
1078
+ index=pd.MultiIndex.from_arrays(
1079
+ [[], []], names=["process", "sink"]
1080
+ ),
1081
+ )
1082
+
1083
+ if (flex_data.p_process_source_conversion_flow_coeff is not None
1084
+ and flex_data.p_process_source_conversion_flow_coeff.frame.height > 0):
1085
+ p.process_source_conversion_flow_coeff = series_with_multi_index(
1086
+ flex_data.p_process_source_conversion_flow_coeff.frame,
1087
+ dims=("p", "source"),
1088
+ names=["process", "source"],
1089
+ )
1090
+ else:
1091
+ p.process_source_conversion_flow_coeff = pd.Series(
1092
+ dtype=float,
1093
+ index=pd.MultiIndex.from_arrays(
1094
+ [[], []], names=["process", "source"]
1095
+ ),
1096
+ )
1097
+
1098
+ # reserve_upDown_group_penalty — Series with MultiIndex (reserve,
1099
+ # upDown, node_group).
1100
+ if (flex_data.p_reserve_upDown_group_penalty_reserve is not None
1101
+ and flex_data.p_reserve_upDown_group_penalty_reserve.frame.height > 0):
1102
+ p.reserve_upDown_group_penalty = series_with_multi_index(
1103
+ flex_data.p_reserve_upDown_group_penalty_reserve.frame,
1104
+ dims=("r", "ud", "g"),
1105
+ names=["reserve", "upDown", "node_group"],
1106
+ )
1107
+ else:
1108
+ p.reserve_upDown_group_penalty = pd.Series(
1109
+ dtype=float,
1110
+ index=pd.MultiIndex.from_arrays(
1111
+ [[], [], []], names=["reserve", "upDown", "node_group"]
1112
+ ),
1113
+ )
1114
+
1115
+ # ─── Per-(solve, period, time) parameters ───────────────────────────────
1116
+ p.step_duration = _pdt_series_solve_period_time(
1117
+ flex_data.p_step_duration, solve_name=solve_name,
1118
+ )
1119
+ p.timestep_weight = _pdt_series_solve_period_time(
1120
+ flex_data.p_timestep_weight, solve_name=solve_name,
1121
+ )
1122
+
1123
+ # flow_min / flow_max — multi-column DataFrames. FlexData has
1124
+ # ``p_flow_upper`` ((p, source, sink, d, t)). The legacy CSV's
1125
+ # ``flow_min`` is structurally distinct; populate as empty
1126
+ # (consumers tolerate empty), and fill flow_max from p_flow_upper.
1127
+ p.flow_min = _empty_pdtX_multi_col(
1128
+ col_names=("process", "source", "sink"),
1129
+ )
1130
+ p.flow_max = _pdtX_multi_col(
1131
+ flex_data.p_flow_upper,
1132
+ solve_name=solve_name,
1133
+ col_dims=("p", "source", "sink"),
1134
+ col_names=("process", "source", "sink"),
1135
+ flex_data=flex_data,
1136
+ )
1137
+
1138
+ # process_source / process_sink — wide rows-by-param frames.
1139
+ p.process_source = _build_p_process_per_arc(flex_data, side="source")
1140
+ p.process_sink = _build_p_process_per_arc(flex_data, side="sink")
1141
+
1142
+ # process_slope / process_section / process_availability —
1143
+ # (solve, period, time) × process.
1144
+ p.process_slope = _pdtX_per_entity(
1145
+ flex_data.p_slope, solve_name=solve_name,
1146
+ entity_dim="p", col_name="process", flex_data=flex_data,
1147
+ )
1148
+ p.process_section = _pdtX_per_entity(
1149
+ flex_data.p_section, solve_name=solve_name,
1150
+ entity_dim="p", col_name="process", flex_data=flex_data,
1151
+ )
1152
+ p.process_availability = _pdtX_per_entity(
1153
+ flex_data.p_process_availability, solve_name=solve_name,
1154
+ entity_dim="p", col_name="process", flex_data=flex_data,
1155
+ )
1156
+
1157
+ # process_source_sink_varCost — (solve, period, time) × (process, source, sink).
1158
+ #
1159
+ # The post-processing in calc_costs.py multiplies this against
1160
+ # ``r.flow_dt`` whose columns are keyed by
1161
+ # ``process_source_sink_alwaysProcess`` tuples (each direct-method
1162
+ # unit contributes one ``(p, source, p)`` source-side column and one
1163
+ # ``(p, p, sink)`` sink-side column). Therefore the varCost frame
1164
+ # must use the same ``_alwaysProcess`` keying — NOT the LP's
1165
+ # pss-keyed ``p_pssdt_varCost`` Param (which the .mod uses only for
1166
+ # the ``pssdt_varCost_noEff`` objective term). For
1167
+ # ``min_load_efficiency`` (``eff_unit_source``) units the source-side
1168
+ # cost lives on the ``(p, source, p)`` tuple alongside the
1169
+ # slope+section fuel flow in ``r.flow_dt``; without the rekey, the
1170
+ # column intersection is empty and the bucket is silently zero
1171
+ # (regression guard:
1172
+ # ``tests/test_cost_aggregation_semantics.TestMinLoadEfficiencySectionTerm``).
1173
+ p.process_source_sink_varCost = _build_pssdt_varCost_alwaysProcess(
1174
+ flex_data, solve_name=solve_name,
1175
+ )
1176
+
1177
+ # node_self_discharge_loss / node_penalty_up / node_penalty_down /
1178
+ # node_inflow / commodity_price / group_co2_price / profile.
1179
+ if (flex_data.p_state_self_discharge is not None
1180
+ and flex_data.p_state_self_discharge.frame.height > 0):
1181
+ # p_state_self_discharge has dims (n,). Broadcast to (solve, d, t)
1182
+ # via dt — matching the legacy ``pdtNode_self_discharge_loss.csv``
1183
+ # which had per-(solve, period, time) rows × node columns even
1184
+ # though the value is constant per node.
1185
+ n_frame = flex_data.p_state_self_discharge.frame
1186
+ dt_frame = flex_data.dt
1187
+ broadcast = (
1188
+ n_frame.lazy()
1189
+ .join(dt_frame.lazy(), how="cross")
1190
+ .with_columns(pl.lit(solve_name).alias("solve"))
1191
+ .select("solve", "d", "t", pl.col("n"), pl.col("value"))
1192
+ .collect()
1193
+ )
1194
+ p.node_self_discharge_loss = wide_per_entity(
1195
+ broadcast, row_dims=("solve", "d", "t"), col_dim="n",
1196
+ row_names=("solve", "period", "time"), col_name="node",
1197
+ )
1198
+ else:
1199
+ p.node_self_discharge_loss = _empty_pdtX_per_entity(
1200
+ flex_data=flex_data, solve_name=solve_name, col_name="node",
1201
+ )
1202
+
1203
+ p.node_penalty_up = _pdtX_per_entity(
1204
+ flex_data.p_penalty_up, solve_name=solve_name,
1205
+ entity_dim="n", col_name="node", flex_data=flex_data,
1206
+ )
1207
+ p.node_penalty_down = _pdtX_per_entity(
1208
+ flex_data.p_penalty_down, solve_name=solve_name,
1209
+ entity_dim="n", col_name="node", flex_data=flex_data,
1210
+ )
1211
+ p.node_inflow = _pdtX_per_entity(
1212
+ flex_data.p_inflow, solve_name=solve_name,
1213
+ entity_dim="n", col_name="node", flex_data=flex_data,
1214
+ )
1215
+ p.commodity_price = _pdtX_per_entity(
1216
+ flex_data.p_commodity_price, solve_name=solve_name,
1217
+ entity_dim="c", col_name="commodity", flex_data=flex_data,
1218
+ )
1219
+ p.group_co2_price = _pdtX_per_entity(
1220
+ flex_data.p_co2_price, solve_name=solve_name,
1221
+ entity_dim="g", col_name="group", flex_data=flex_data,
1222
+ )
1223
+
1224
+ # reserve_upDown_group_reservation — multi-column (r, ud, g).
1225
+ p.reserve_upDown_group_reservation = _pdtX_multi_col(
1226
+ flex_data.pdtReserve_upDown_group_reservation, solve_name=solve_name,
1227
+ col_dims=("r", "ud", "g"),
1228
+ col_names=("reserve", "upDown", "node_group"),
1229
+ densify_col_tuples=getattr(flex_data, "reserve_upDown_group", None),
1230
+ flex_data=flex_data,
1231
+ )
1232
+
1233
+ # profile — (solve, period, time) × profile.
1234
+ p.profile = _pdtX_per_entity(
1235
+ flex_data.p_profile_value, solve_name=solve_name,
1236
+ entity_dim="f", col_name="profile", flex_data=flex_data,
1237
+ )
1238
+
1239
+ # ─── Per-(solve, period) scalar params ──────────────────────────────────
1240
+ # years_from_start_d / years_represented_d — Series. FlexData
1241
+ # carries ``p_years_represented_d`` (Param, (d,) → R width sum;
1242
+ # populated by ``_derived_params.apply_derived_a`` from
1243
+ # ``solve.years_represented``). ``years_from_start_d`` is still
1244
+ # defaulted to 0.0 (used only for divest carry-forward; the active
1245
+ # divest cascade uses ``edd_divest_active`` directly).
1246
+ if (flex_data.p_period_share is not None
1247
+ and flex_data.p_period_share.frame.height > 0):
1248
+ # p_period_share is (d,) → use d set as the period axis.
1249
+ periods = flex_data.p_period_share.frame.select("d").to_pandas()["d"].tolist()
1250
+ else:
1251
+ periods = []
1252
+ if periods:
1253
+ idx = pd.MultiIndex.from_arrays(
1254
+ [[solve_name] * len(periods), periods], names=["solve", "period"],
1255
+ )
1256
+ p.years_from_start_d = pd.Series([0.0] * len(periods), index=idx, name="value")
1257
+ # Per-period R width sum from FlexData when populated; fall back
1258
+ # to 1.0 per period when the source carries no
1259
+ # ``solve.years_represented`` rows (single-year fixtures).
1260
+ yr_widths = [1.0] * len(periods)
1261
+ yr_param = flex_data.p_years_represented_d
1262
+ if yr_param is not None and yr_param.frame.height > 0:
1263
+ yr_map = {
1264
+ str(d): float(v)
1265
+ for d, v in zip(
1266
+ yr_param.frame["d"].to_list(),
1267
+ yr_param.frame["value"].to_list(),
1268
+ )
1269
+ }
1270
+ yr_widths = [yr_map.get(str(d), 1.0) for d in periods]
1271
+ p.years_represented_d = pd.Series(yr_widths, index=idx, name="value")
1272
+ else:
1273
+ empty_idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
1274
+ p.years_from_start_d = pd.Series(dtype=float, index=empty_idx, name="value")
1275
+ p.years_represented_d = pd.Series(dtype=float, index=empty_idx, name="value")
1276
+
1277
+ # entity_max_units / entity_all_existing / entity_pre_existing —
1278
+ # (solve, period) × entity. Densify across the entity universe
1279
+ # so downstream lookups by ``v.invest.columns`` find every entity.
1280
+ p.entity_max_units = _pdX_per_entity(
1281
+ flex_data.p_entity_max_units, solve_name=solve_name,
1282
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1283
+ densify_entities=_entity_universe,
1284
+ )
1285
+ p.entity_all_existing = _pdX_per_entity(
1286
+ flex_data.p_entity_all_existing, solve_name=solve_name,
1287
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1288
+ densify_entities=_entity_universe,
1289
+ )
1290
+
1291
+ # entity_pre_existing — distinct from entity_all_existing on
1292
+ # multi-solve runs (``pre_existing`` is the same as ``all_existing``
1293
+ # on first solve; differs only when prior solves invested). We
1294
+ # use ``p_entity_previously_invested_capacity`` if available, else
1295
+ # fall back to ``entity_all_existing`` minus the previously
1296
+ # invested. Single-solve: identical to entity_all_existing.
1297
+ if (flex_data.p_entity_previously_invested_capacity is not None
1298
+ and flex_data.p_entity_previously_invested_capacity.frame.height > 0):
1299
+ # Subtract from p_entity_all_existing if both populated.
1300
+ all_lf = flex_data.p_entity_all_existing.frame.lazy() if (
1301
+ flex_data.p_entity_all_existing is not None
1302
+ and flex_data.p_entity_all_existing.frame.height > 0
1303
+ ) else None
1304
+ prev_lf = flex_data.p_entity_previously_invested_capacity.frame.lazy()
1305
+ if all_lf is not None:
1306
+ pre_existing = (
1307
+ all_lf.join(
1308
+ prev_lf.rename({"value": "prev"}), on=["e", "d"], how="left",
1309
+ )
1310
+ .with_columns(
1311
+ value=pl.col("value") - pl.col("prev").fill_null(0.0),
1312
+ )
1313
+ .select("e", "d", "value")
1314
+ .collect()
1315
+ )
1316
+ else:
1317
+ pre_existing = prev_lf.select("e", "d", "value").collect()
1318
+ from polar_high import Param # local import — Param is heavy
1319
+ p.entity_pre_existing = _pdX_per_entity(
1320
+ Param(("e", "d"), pre_existing), solve_name=solve_name,
1321
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1322
+ densify_entities=_entity_universe,
1323
+ )
1324
+ else:
1325
+ p.entity_pre_existing = _pdX_per_entity(
1326
+ flex_data.p_entity_all_existing, solve_name=solve_name,
1327
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1328
+ densify_entities=_entity_universe,
1329
+ )
1330
+
1331
+ # entity_all_capacity — post-solve derived.
1332
+ p.entity_all_capacity = _entity_all_capacity(
1333
+ flex_data, solution, solve_name=solve_name,
1334
+ )
1335
+
1336
+ # process_startup_cost — (solve, period) × process.
1337
+ p.process_startup_cost = _pdX_per_entity(
1338
+ flex_data.p_startup_cost, solve_name=solve_name,
1339
+ entity_dim="p", col_name="process", flex_data=flex_data,
1340
+ )
1341
+
1342
+ # entity_fixed_cost / entity_lifetime_fixed_cost / entity_lifetime_fixed_cost_divest.
1343
+ # Densify across the full entity universe — calc_costs.py:154-155
1344
+ # indexes these by ``v.invest.columns`` / ``v.divest.columns``,
1345
+ # which can include any entity.
1346
+ p.entity_fixed_cost = _pdX_per_entity(
1347
+ flex_data.p_ed_fixed_cost, solve_name=solve_name,
1348
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1349
+ densify_entities=_entity_universe,
1350
+ )
1351
+ p.entity_lifetime_fixed_cost = _pdX_per_entity(
1352
+ flex_data.ed_lifetime_fixed_cost, solve_name=solve_name,
1353
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1354
+ densify_entities=_entity_universe,
1355
+ )
1356
+ p.entity_lifetime_fixed_cost_divest = _pdX_per_entity(
1357
+ flex_data.ed_lifetime_fixed_cost_divest, solve_name=solve_name,
1358
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1359
+ densify_entities=_entity_universe,
1360
+ )
1361
+
1362
+ # node_annual_flow — Series((solve, period), node).
1363
+ p.node_annual_flow = _empty_solve_period_per_entity(
1364
+ col_name="node",
1365
+ )
1366
+
1367
+ # group_penalty_inertia / group_penalty_non_synchronous /
1368
+ # group_penalty_capacity_margin / group_inertia_limit / group_capacity_margin.
1369
+ #
1370
+ # SCEN-6: ``pdGroup_penalty_capacity_margin`` is derived via
1371
+ # ``_entity_period_scalar`` which drops null rows. When a group has
1372
+ # ``capacity_margin > 0`` but ``penalty_capacity_margin`` left blank
1373
+ # (a common template pattern — e.g. xlsx ``Commodity_nodes``), the
1374
+ # group lands in ``groupCapacityMargin`` (built from non-empty
1375
+ # ``capacity_margin`` rows) but is absent from the penalty frame.
1376
+ # ``calc_slacks.py`` indexes ``par.group_penalty_capacity_margin
1377
+ # [s.groupCapacityMargin]`` and raises ``KeyError`` on the gap.
1378
+ # Densify here with the same zero-fill convention used elsewhere for
1379
+ # parity emitters: missing penalty ⇒ 0 (no slack-cost contribution,
1380
+ # matching the LP: the slack term in
1381
+ # ``_group_slack.compute_slack_costs`` is itself gated on a
1382
+ # non-null ``pdGroup_penalty_capacity_margin``).
1383
+ if (flex_data.groupCapacityMargin is not None
1384
+ and flex_data.groupCapacityMargin.height > 0):
1385
+ _group_cap_margin_universe = (
1386
+ flex_data.groupCapacityMargin.select("g").unique()
1387
+ .to_pandas()["g"].tolist()
1388
+ )
1389
+ else:
1390
+ _group_cap_margin_universe = []
1391
+ p.group_penalty_inertia = _pdX_per_entity(
1392
+ flex_data.pdGroup_penalty_inertia, solve_name=solve_name,
1393
+ entity_dim="g", col_name="group", flex_data=flex_data,
1394
+ )
1395
+ p.group_penalty_non_synchronous = _pdX_per_entity(
1396
+ flex_data.pdGroup_penalty_non_synchronous, solve_name=solve_name,
1397
+ entity_dim="g", col_name="group", flex_data=flex_data,
1398
+ )
1399
+ p.group_penalty_capacity_margin = _pdX_per_entity(
1400
+ flex_data.pdGroup_penalty_capacity_margin, solve_name=solve_name,
1401
+ entity_dim="g", col_name="group", flex_data=flex_data,
1402
+ densify_entities=_group_cap_margin_universe,
1403
+ )
1404
+ p.group_inertia_limit = _pdX_per_entity(
1405
+ flex_data.pdGroup_inertia_limit, solve_name=solve_name,
1406
+ entity_dim="g", col_name="group", flex_data=flex_data,
1407
+ )
1408
+ p.group_capacity_margin = _pdX_per_entity(
1409
+ flex_data.pdGroup_capacity_margin, solve_name=solve_name,
1410
+ entity_dim="g", col_name="group", flex_data=flex_data,
1411
+ )
1412
+
1413
+ # entity_annuity / entity_annual_discounted / entity_annual_divest_discounted.
1414
+ # entity_annuity is undiscounted; we use discounted as a stand-in
1415
+ # (only consumed in debug mode; the legacy distinction is preserved
1416
+ # for compatibility).
1417
+ p.entity_annuity = _pdX_per_entity(
1418
+ flex_data.ed_entity_annual_discounted, solve_name=solve_name,
1419
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1420
+ densify_entities=_entity_universe,
1421
+ )
1422
+ p.entity_annual_discounted = _pdX_per_entity(
1423
+ flex_data.ed_entity_annual_discounted, solve_name=solve_name,
1424
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1425
+ densify_entities=_entity_universe,
1426
+ )
1427
+ p.entity_annual_divest_discounted = _pdX_per_entity(
1428
+ flex_data.ed_entity_annual_divest_discounted, solve_name=solve_name,
1429
+ entity_dim="e", col_name="entity", flex_data=flex_data,
1430
+ densify_entities=_entity_universe,
1431
+ )
1432
+
1433
+ # inflation factors — Series((solve, period)).
1434
+ p.inflation_factor_operations_yearly = _pd_series_solve_period(
1435
+ flex_data.p_inflation_op, solve_name=solve_name,
1436
+ )
1437
+ # No FlexData equivalent for "investment yearly"; reuse operations
1438
+ # (the rate differs only when discount_rate ≠ 0; for fixtures
1439
+ # without explicit rates they're identical).
1440
+ p.inflation_factor_investment_yearly = _pd_series_solve_period(
1441
+ flex_data.p_inflation_op, solve_name=solve_name,
1442
+ )
1443
+
1444
+ # node_capacity_for_scaling / group_capacity_for_scaling.
1445
+ # Densify nodes for downstream slack-scaling lookups in
1446
+ # ``out_node.py`` / ``calc_slacks.py``.
1447
+ # The slack vars span ``nodeBalance ∪ nodeBalancePeriod`` (period nodes
1448
+ # also carry ``vq_state_up/down``), so the densify universe must cover
1449
+ # both — otherwise period-node ``q_state`` columns are missing from the
1450
+ # output frame. De-duplicate while preserving order; when there are no
1451
+ # period nodes the universe is exactly ``nodeBalance`` (byte-identical).
1452
+ nodes_universe = []
1453
+ if (flex_data.nodeBalance is not None
1454
+ and flex_data.nodeBalance.height > 0):
1455
+ nodes_universe = flex_data.nodeBalance["n"].to_list()
1456
+ if (flex_data.nodeBalancePeriod is not None
1457
+ and flex_data.nodeBalancePeriod.height > 0):
1458
+ seen = set(nodes_universe)
1459
+ for n in flex_data.nodeBalancePeriod["n"].to_list():
1460
+ if n not in seen:
1461
+ nodes_universe.append(n)
1462
+ seen.add(n)
1463
+ p.node_capacity_for_scaling = _pdX_per_entity(
1464
+ flex_data.p_node_capacity_for_scaling, solve_name=solve_name,
1465
+ entity_dim="n", col_name="node", flex_data=flex_data,
1466
+ densify_entities=nodes_universe,
1467
+ )
1468
+ p.group_capacity_for_scaling = _pdX_per_entity(
1469
+ flex_data.p_group_capacity_for_scaling, solve_name=solve_name,
1470
+ entity_dim="g", col_name="group", flex_data=flex_data,
1471
+ )
1472
+
1473
+ # complete_period_share_of_year — Series((solve, period)).
1474
+ p.complete_period_share_of_year = _pd_series_solve_period(
1475
+ flex_data.p_period_share, solve_name=solve_name,
1476
+ )
1477
+
1478
+ # nested_model — DataFrame indexed by ``param`` with ``value`` column.
1479
+ # Reflects ``solveFirst`` / ``solveLast`` / ``contains_solve``.
1480
+ rows = {"solveFirst": 1.0 if (flex_data.p_nested_solve_first is None
1481
+ or bool(flex_data.p_nested_solve_first)) else 0.0}
1482
+ p.nested_model = pd.DataFrame(
1483
+ {"value": list(rows.values())},
1484
+ index=pd.Index(list(rows.keys()), name="param"),
1485
+ )
1486
+
1487
+ return p
1488
+
1489
+
1490
+ def _empty_solve_period_per_entity(*, col_name: str) -> pd.DataFrame:
1491
+ """Empty ``(solve, period)``-indexed DataFrame with named empty columns."""
1492
+ idx = pd.MultiIndex.from_arrays([[], []], names=["solve", "period"])
1493
+ out = pd.DataFrame(index=idx, dtype=float)
1494
+ out.columns = pd.Index([], dtype="object", name=col_name)
1495
+ return out
1496
+
1497
+
1498
+ def _build_entity_unitsize_series(
1499
+ flex_data: "FlexData",
1500
+ *,
1501
+ entity_universe: "list[str] | None" = None,
1502
+ ) -> pd.Series:
1503
+ """Per-entity unitsize Series indexed by entity name.
1504
+
1505
+ Preferred source — the solve-invariant
1506
+ ``flex_data.p_all_entity_unitsize`` carrier (a Param ``(e,)`` over ALL
1507
+ entities: unit ∪ node ∪ connection) built input-side with the cascade
1508
+ ``virtual_unitsize OR existing OR 1000.0``. This covers every entity
1509
+ in the model, including invest candidates absent from the LAST solve's
1510
+ pss/invest sets — which the pss-filtered ``p_unitsize`` /
1511
+ ``p_state_unitsize`` reconstruction below misses, causing a ``KeyError``
1512
+ in ``calc_costs`` when ``v.invest`` (the cross-solve column union) is
1513
+ indexed against it.
1514
+
1515
+ Fallback (CSV/fixture paths lacking the carrier) — combines the
1516
+ per-solve ``p_unitsize`` (process side) and ``p_state_unitsize`` (node
1517
+ side), defaulting to ``1000.0`` for any entity in the nodeBalance /
1518
+ process_source_sink / invest universe that doesn't appear in either
1519
+ Param, matching the slow path's preprocessing default at
1520
+ ``preprocessing/entity_period_calc_params.py:191``.
1521
+ """
1522
+ _carrier = getattr(flex_data, "p_all_entity_unitsize", None)
1523
+ if _carrier is not None and getattr(_carrier, "frame", None) is not None \
1524
+ and _carrier.frame.height > 0:
1525
+ df = _carrier.frame.to_pandas()
1526
+ s = df.set_index("e")["value"].astype(float)
1527
+ s = s[~s.index.duplicated(keep="first")]
1528
+ s.index.name = "entity"
1529
+ s.name = "entity"
1530
+ return s
1531
+
1532
+ parts: list[pd.Series] = []
1533
+ if (flex_data.p_unitsize is not None
1534
+ and flex_data.p_unitsize.frame.height > 0):
1535
+ df = flex_data.p_unitsize.frame.to_pandas()
1536
+ s = df.set_index("p")["value"].astype(float)
1537
+ s.index.name = "entity"
1538
+ parts.append(s)
1539
+ if (flex_data.p_state_unitsize is not None
1540
+ and flex_data.p_state_unitsize.frame.height > 0):
1541
+ df = flex_data.p_state_unitsize.frame.to_pandas()
1542
+ s = df.set_index("n")["value"].astype(float)
1543
+ s.index.name = "entity"
1544
+ parts.append(s)
1545
+ if parts:
1546
+ out = pd.concat(parts)
1547
+ # Deduplicate (unlikely; node/process sets are disjoint).
1548
+ out = out[~out.index.duplicated(keep="first")]
1549
+ else:
1550
+ out = pd.Series(dtype=float, index=pd.Index([], name="entity"))
1551
+ out.name = "entity"
1552
+ # Densify with default 1000.0 (preprocessing default at
1553
+ # entity_period_calc_params.py:191).
1554
+ if entity_universe:
1555
+ for e in entity_universe:
1556
+ if e not in out.index:
1557
+ out[e] = 1000.0
1558
+ return out
1559
+
1560
+
1561
+ # ---------------------------------------------------------------------------
1562
+ # Multi-solve wrapper
1563
+ # ---------------------------------------------------------------------------
1564
+
1565
+
1566
+ def _has_solve_level(obj) -> bool:
1567
+ """True if ``obj`` is a pandas DataFrame/Series whose row MultiIndex
1568
+ has a ``solve`` level — i.e. it varies per sub-solve and rows from
1569
+ multiple sub-solves should be concatenated rather than overwritten.
1570
+ """
1571
+ if isinstance(obj, (pd.DataFrame, pd.Series)):
1572
+ idx = obj.index
1573
+ if isinstance(idx, pd.MultiIndex):
1574
+ return "solve" in (idx.names or ())
1575
+ return False
1576
+
1577
+
1578
+ def read_parameters_multi(
1579
+ steps: "list[tuple[str, FlexData, Solution]] | list[tuple[str, FlexData]]",
1580
+ solution: "Solution | None" = None,
1581
+ ) -> SimpleNamespace:
1582
+ """Multi-solve variant of :func:`read_parameters`.
1583
+
1584
+ ``steps`` is a list of ``(solve_name, flex_data)`` pairs covering
1585
+ every sub-solve (roll) of the orchestration's cascade. Per-(solve,
1586
+ period, time) and per-(solve, period) outputs are built per sub-
1587
+ solve and concatenated along the row axis so the resulting ``par``
1588
+ namespace covers the FULL union of every sub-solve's dt axis —
1589
+ matching the union ``v`` carries from parquet aggregation in
1590
+ :mod:`flextool.process_outputs.read_variables`.
1591
+
1592
+ Per-entity static attributes (entity_unitsize, commodity_co2_content,
1593
+ etc.) are invariant across sub-solves and are taken from the LAST
1594
+ step (arbitrary; they're identical across rolls).
1595
+
1596
+ Rationale: rolling-solve orchestration carries per-sub-solve
1597
+ ``flex_data`` already; the output path must reflect the union of
1598
+ every sub-solve's parameter values, NOT just the last roll's. See
1599
+ the structural diagnosis: ``par`` was built from the last
1600
+ ``flex_data.dt`` (one period) while ``v`` carried the union of
1601
+ every parquet (all periods), so downstream ``mul`` / ``join`` ops
1602
+ crashed on row-MultiIndex mismatches.
1603
+ """
1604
+ if not steps:
1605
+ raise ValueError("read_parameters_multi: steps must be non-empty")
1606
+
1607
+ # Accept both (solve_name, flex_data, solution) and
1608
+ # (solve_name, flex_data) tuples for backwards compatibility. The
1609
+ # 3-tuple form is REQUIRED for ``_entity_all_capacity`` to see each
1610
+ # roll's own ``v_invest`` / ``v_divest`` — passing only the last
1611
+ # roll's solution makes ``total`` lag by one period across the
1612
+ # chain (see 2026-05-14 unit_capacity__d.csv diagnosis).
1613
+ def _step_solution(s):
1614
+ if len(s) >= 3:
1615
+ return s[2]
1616
+ if solution is None:
1617
+ raise ValueError(
1618
+ "read_parameters_multi: step is a 2-tuple but no "
1619
+ "fallback solution was supplied"
1620
+ )
1621
+ return solution
1622
+
1623
+ per_step = [
1624
+ (s[0], read_parameters(s[1], _step_solution(s), solve_name=s[0]))
1625
+ for s in steps
1626
+ ]
1627
+ last_ns = per_step[-1][1]
1628
+ if len(per_step) == 1:
1629
+ return last_ns
1630
+
1631
+ # Pre-filter per-step ``entity_lifetime_fixed_cost`` /
1632
+ # ``entity_lifetime_fixed_cost_divest`` to only the step's REALIZED
1633
+ # periods (``flex_data.realized_dispatch`` — same source as
1634
+ # ``read_sets.d_realized_period``). In a rolling/nested cascade
1635
+ # each step holds a period-d "active" lifetime value in the row
1636
+ # where d is the step's realized period, plus forward-discounted
1637
+ # lookahead values for lookahead periods (e.g. y2020 step has
1638
+ # p2020=827152 active + p2025=373153 lookahead). ``v_invest[(solve,
1639
+ # d)]`` is non-zero only for the committing step's realized period,
1640
+ # so the cost in ``calc_costs.compute_costs`` (line 165, ``v.invest
1641
+ # × unitsize × entity_lifetime_fixed_cost``) must use the value
1642
+ # from that same committing step. Filtering each piece pre-concat
1643
+ # means ``drop_levels`` sees one row per period (no dedup
1644
+ # ambiguity). Without this, ``_PAR_DEDUP keep='first'`` picks the
1645
+ # earliest step's lookahead value for every period after the first
1646
+ # (e.g. p2025 → y2020 lookahead 373153 instead of y2025 active
1647
+ # 827152), giving ``costs_discounted.csv:fixed cost invested``
1648
+ # 120.6 instead of the LP-true 267.4. ``ed_invest_set`` /
1649
+ # ``ed_divest_set`` cover both realized + lookahead invest
1650
+ # candidates, so we use ``realized_dispatch`` instead.
1651
+ _lifetime_attrs = ("entity_lifetime_fixed_cost",
1652
+ "entity_lifetime_fixed_cost_divest")
1653
+ for (sn, ns), step in zip(per_step, steps):
1654
+ flex_data = step[1]
1655
+ rd = getattr(flex_data, "realized_dispatch", None)
1656
+ if rd is None or getattr(rd, "height", 0) == 0:
1657
+ continue
1658
+ realized_periods = set(
1659
+ rd.select("period").unique().to_pandas()["period"].tolist()
1660
+ )
1661
+ if not realized_periods:
1662
+ continue
1663
+ for attr in _lifetime_attrs:
1664
+ obj = getattr(ns, attr, None)
1665
+ if obj is None or not _has_solve_level(obj):
1666
+ continue
1667
+ periods = obj.index.get_level_values("period")
1668
+ mask = periods.isin(realized_periods)
1669
+ setattr(ns, attr, obj[mask])
1670
+
1671
+ # Per-step ``entity_all_existing`` carries the period-d existing-
1672
+ # capacity chain (pre_existing on solve_first, later_existing
1673
+ # otherwise). In a NESTED cascade the parent invest step and
1674
+ # every child dispatch step both densify the (period, entity)
1675
+ # grid: invest_24h@(wind_plant, p2020) = 1000 (pre_existing,
1676
+ # solve_first=True) but dispatch_..._roll_17@(wind_plant, p2020)
1677
+ # = 1288.83 (later_existing folding the parent's v_invest back
1678
+ # in). drop_levels' ``keep='last'`` dedup picks the dispatch
1679
+ # row, blowing up the ``existing`` column in unit_capacity__d.csv
1680
+ # to the cumulative-with-current-invest value (HEAD shows 1288.83
1681
+ # for the same cell v3.32.0 emits as 1000). v3.32.0's mod-side
1682
+ # writer only emits rows from the FIRST solve that owns each
1683
+ # period via ``period_capacity`` deduplication (flextool.mod
1684
+ # L5993-6002): in nested that's the parent invest step.
1685
+ #
1686
+ # Fix: filter each step's ``entity_all_existing`` to ONLY the
1687
+ # periods that step realizes (``flex_data.realized_dispatch`` ∪
1688
+ # ``ed_invest_set/ed_divest_set`` "d"s). Dispatch-only children
1689
+ # in a nested cascade drop their lookahead rows; the parent
1690
+ # invest step's per-period values are the sole contributor and
1691
+ # win regardless of dedup direction. In 4-solve / multi_year
1692
+ # invest cascades each step realizes a disjoint period so no
1693
+ # collision arises either way — the filter is a no-op there.
1694
+ for (sn, ns), step in zip(per_step, steps):
1695
+ flex_data = step[1]
1696
+ step_periods: set[str] = set()
1697
+ rd = getattr(flex_data, "realized_dispatch", None)
1698
+ if rd is not None and getattr(rd, "height", 0) > 0:
1699
+ step_periods.update(
1700
+ rd.select("period").unique().to_pandas()["period"].tolist()
1701
+ )
1702
+ for src_attr in ("ed_invest_set", "ed_divest_set"):
1703
+ src = getattr(flex_data, src_attr, None)
1704
+ if src is not None and getattr(src, "height", 0) > 0 and "d" in src.columns:
1705
+ step_periods.update(
1706
+ src.select("d").unique().to_pandas()["d"].tolist()
1707
+ )
1708
+ if not step_periods:
1709
+ # Step realizes nothing — leave as-is. This is the legacy
1710
+ # behaviour for fixtures that don't populate realized_*.
1711
+ continue
1712
+ for attr in ("entity_all_existing",):
1713
+ obj = getattr(ns, attr, None)
1714
+ if obj is None or not _has_solve_level(obj):
1715
+ continue
1716
+ periods = obj.index.get_level_values("period")
1717
+ mask = periods.isin(step_periods)
1718
+ setattr(ns, attr, obj[mask])
1719
+
1720
+ # Per-step ``entity_annual_discounted`` /
1721
+ # ``entity_annual_divest_discounted`` carry the invest-side NPV
1722
+ # coefficients used by ``calc_costs.compute_costs`` to derive
1723
+ # ``cost_entity_invest_d = v_invest × unitsize × annual_discounted``.
1724
+ # The polars derivation builds these from ``period_invest``: a
1725
+ # nested-dispatch child solve has empty ``period_invest`` and the
1726
+ # frame is therefore empty, but ``_pdX_per_entity(densify_entities=
1727
+ # _entity_universe)`` zero-fills it to the full entity × period
1728
+ # grid. In a nested cascade, the parent ``invest`` step holds the
1729
+ # real coefficients (e.g. 4.18 for ``wind_plant @ p2020``) while
1730
+ # every child dispatch step densifies the same (period, entity)
1731
+ # cell to 0. Without intervention, ``drop_levels`` dedup with
1732
+ # ``keep='last'`` picks the last child step's zero, zeroing out
1733
+ # ``r.cost_entity_invest_d`` and dropping ``unit investment &
1734
+ # retirement`` from ``costs_discounted.csv`` (HEAD shows 0,
1735
+ # v3.32.0 shows 471.28). Fix: detect a "dispatch-only" step
1736
+ # (``flex_data.ed_entity_annual_discounted`` empty/None) and clear
1737
+ # the densified-zero per-step frame so it doesn't compete with the
1738
+ # parent invest step's real values.
1739
+ _annual_attrs = ("entity_annual_discounted",
1740
+ "entity_annual_divest_discounted",
1741
+ "entity_annuity")
1742
+ _src_field = {
1743
+ "entity_annual_discounted": "ed_entity_annual_discounted",
1744
+ "entity_annuity": "ed_entity_annual_discounted",
1745
+ "entity_annual_divest_discounted":
1746
+ "ed_entity_annual_divest_discounted",
1747
+ }
1748
+ for (sn, ns), step in zip(per_step, steps):
1749
+ flex_data = step[1]
1750
+ for attr in _annual_attrs:
1751
+ src_attr = _src_field[attr]
1752
+ src_param = getattr(flex_data, src_attr, None)
1753
+ src_empty = (
1754
+ src_param is None
1755
+ or getattr(src_param, "frame", None) is None
1756
+ or getattr(src_param.frame, "height", 0) == 0
1757
+ )
1758
+ if not src_empty:
1759
+ continue
1760
+ obj = getattr(ns, attr, None)
1761
+ if obj is None or not _has_solve_level(obj):
1762
+ continue
1763
+ # Empty out this step's rows (preserve the index/columns
1764
+ # frame for the concat path; head(0) keeps the schema).
1765
+ setattr(ns, attr, obj.iloc[0:0])
1766
+
1767
+ out = SimpleNamespace()
1768
+ attr_names = [a for a in vars(last_ns).keys()]
1769
+ for attr in attr_names:
1770
+ pieces = [getattr(ns, attr) for _, ns in per_step]
1771
+ # If the attribute has a ``solve`` level on its row index, the
1772
+ # union over sub-solves is the correct semantic; concat rows.
1773
+ if any(_has_solve_level(p) for p in pieces):
1774
+ non_empty = [p for p in pieces if len(p) > 0]
1775
+ if not non_empty:
1776
+ setattr(out, attr, pieces[-1])
1777
+ continue
1778
+ # ``pd.concat`` preserves dtypes and row order. Each piece
1779
+ # carries a distinct ``solve`` value so no de-duplication
1780
+ # is needed.
1781
+ merged = pd.concat(non_empty, axis=0)
1782
+ # Preserve column dtype/name (concat keeps it; defensive).
1783
+ if hasattr(pieces[-1], "columns") and hasattr(merged, "columns"):
1784
+ merged.columns.name = pieces[-1].columns.name
1785
+ setattr(out, attr, merged)
1786
+ else:
1787
+ # Static / per-entity / not-solve-keyed: invariant across
1788
+ # sub-solves; pick the last step's value.
1789
+ #
1790
+ # ``entity_unitsize`` is one such attribute. It is now sourced
1791
+ # from the input-side invariant ``input/p_all_entity_unitsize``
1792
+ # (the ``flex_data.p_all_entity_unitsize`` carrier), which covers
1793
+ # ALL entities and is identical for every step — so the last-step
1794
+ # pick is sound and, unlike the old pss-filtered reconstruction,
1795
+ # it includes invest candidates absent from the final sub-solve.
1796
+ # No union/concat is needed (and would be wrong: there is no
1797
+ # ``solve`` level to disambiguate rows).
1798
+ setattr(out, attr, getattr(last_ns, attr))
1799
+ return out