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,553 @@
1
+ """Read solver variable outputs into a namespace.
2
+
3
+ Supports two pathways:
4
+
5
+ * **CSV** — ``output_raw/*.csv`` (legacy path). Kept alive for
6
+ ``--use-old-raw-csv`` runs.
7
+ * **Parquet** — per-solve parquets in
8
+ ``output_raw/<var>__{solve}.parquet`` written by
9
+ :mod:`flextool.process_outputs.read_highs_solution`. The parquets
10
+ carry full MultiIndex information in compact ``flextool`` metadata,
11
+ so they round-trip to the same DataFrame shape the CSV path
12
+ produces (see ``lean_parquet``).
13
+
14
+ Both pathways use the same ``output_raw/`` folder. Dispatch is by
15
+ file presence: if any ``v_flow__*.parquet`` exists and no
16
+ ``v_flow.csv`` does, the parquet pathway is used; otherwise the CSV
17
+ pathway.
18
+ """
19
+ import ast
20
+ import json
21
+ import os
22
+ import tempfile
23
+ from types import SimpleNamespace
24
+ from pathlib import Path
25
+ from typing import Sequence
26
+ import pandas as pd
27
+ import pyarrow as pa
28
+ import pyarrow.parquet as pq
29
+
30
+ from flextool.process_outputs.read_highs_solution import empty_variable_frame
31
+ from flextool.process_outputs.solve_order import load_solve_order
32
+
33
+
34
+ def read_variables(output_dir):
35
+ """Read all variable outputs into a :class:`SimpleNamespace`.
36
+
37
+ ``output_dir`` is the ``output_raw/`` path. ``input/`` is resolved
38
+ as a sibling folder (needed for ``group_entity_invest.csv``).
39
+ """
40
+ output_path = Path(output_dir)
41
+ work_folder = output_path.parent
42
+ input_path = work_folder / "input"
43
+
44
+ # Prefer the CSV pathway when its output is present. Fall back to
45
+ # the per-solve parquets when ``v_flow.csv`` is absent.
46
+ if (output_path / "v_flow.csv").exists():
47
+ return _read_from_csv(output_path, input_path)
48
+ if any(output_path.glob("v_flow__*.parquet")):
49
+ return _read_from_parquet(output_path, input_path)
50
+ return _read_from_csv(output_path, input_path)
51
+
52
+
53
+ def _stream_concat_parts(parts: list[Path]) -> pd.DataFrame:
54
+ """Concatenate per-solve parquet *parts* without materialising them
55
+ all at once.
56
+
57
+ The previous implementation built ``frames = [read_lean_parquet(p)
58
+ for p in parts]`` then ``pd.concat(frames).fillna(0.0)``. On 72-roll
59
+ SouthAfrica that peaked at ~50 GB for ``v_flow`` alone — the Python
60
+ list held every per-solve frame simultaneously, then ``pd.concat``
61
+ produced a second dense copy of the union.
62
+
63
+ Phase E2 strategy:
64
+
65
+ 1. Footer-only pass over each part to gather the union of physical
66
+ column names, the per-column canonical type (preferring concrete
67
+ types over ``null`` from empty parts), and the row-index level
68
+ info from the first non-empty part's ``flextool`` metadata.
69
+ 2. Open a single ``ParquetWriter`` on a temp file with the union
70
+ schema. For each part read its table, promote (add nulls / drop)
71
+ columns to align with the union schema, and write it as one
72
+ row-group. Drop the table reference before reading the next
73
+ part so we never hold more than ONE part's arrow data in memory
74
+ during the write phase.
75
+ 3. Read the consolidated temp parquet back as a single pandas
76
+ DataFrame via ``pq.read_table(...).to_pandas(self_destruct=True)``.
77
+ arrow buffers are released column-by-column during the
78
+ conversion.
79
+ 4. Apply the historical fixups (1-level MultiIndex collapse +
80
+ ``.fillna(0.0)``). The zero-fill matters for downstream
81
+ consumers (e.g. ``calc_storage_vre`` uses
82
+ ``v.state[n].reindex(...)``; a NaN there would break the
83
+ arithmetic).
84
+
85
+ Memory profile (per variable), where ``F = N × per_part`` is the
86
+ union frame size:
87
+
88
+ old : peak ≈ N × per_part + 2 × F (Python list of N frames
89
+ + ``pd.concat`` temp + final)
90
+ new : peak ≈ max(2 × per_part, (write phase: one part
91
+ 2 × F) + writer buffer)
92
+ (read phase: arrow table
93
+ + pandas frame during
94
+ column-by-column convert)
95
+
96
+ Concretely: the spec-cited 50 GB SouthAfrica peak (dominated by
97
+ the N-frame list term) drops to roughly ``2 × F`` — the read-back
98
+ floor. When ``F ≈ N × per_part`` (parts share most columns) the
99
+ win is ~halving peak; when parts share *no* columns (each part
100
+ contributes disjoint entities) ``F`` shrinks proportionally and
101
+ the win grows.
102
+
103
+ The temp file lives in the same directory as the parts so it stays
104
+ on the workspace filesystem (predictable disk locality, no
105
+ cross-filesystem rename surprises).
106
+ """
107
+ # Filter out parts that wrote zero rows AND zero columns. The old
108
+ # path kept these only if they were the *only* parts; we mirror
109
+ # that fallback here so an all-empty variable still returns the
110
+ # same shape it did before.
111
+ non_empty_parts: list[Path] = []
112
+ for p in parts:
113
+ pf = pq.ParquetFile(str(p))
114
+ if pf.metadata.num_rows == 0 and pf.metadata.num_columns == 0:
115
+ continue
116
+ non_empty_parts.append(p)
117
+ use_parts = non_empty_parts or parts
118
+
119
+ if not use_parts:
120
+ # Defensive — caller guarantees ``parts`` is non-empty.
121
+ return pd.DataFrame()
122
+
123
+ # Pass 1: union of columns + per-column canonical type + row-index
124
+ # level info from the first non-empty part's metadata. Reading
125
+ # just the schema costs one footer read per file (cheap; KB not GB).
126
+ union_cols: list[str] = []
127
+ seen: set[str] = set()
128
+ flextool_info: bytes | None = None
129
+ null_type = pa.null()
130
+ name_to_type: dict[str, pa.DataType] = {}
131
+ for p in use_parts:
132
+ pf = pq.ParquetFile(str(p))
133
+ sch = pf.schema_arrow
134
+ meta = sch.metadata or {}
135
+ if flextool_info is None and b"flextool" in meta:
136
+ flextool_info = meta[b"flextool"]
137
+ for field in sch:
138
+ if field.name not in seen:
139
+ seen.add(field.name)
140
+ union_cols.append(field.name)
141
+ name_to_type[field.name] = field.type
142
+ else:
143
+ # Prefer any concrete type over ``null`` — a part with
144
+ # zero rows may emit a column of ``null`` type, and
145
+ # arrow can't cast a later concrete column *into* that.
146
+ if (
147
+ name_to_type[field.name] == null_type
148
+ and field.type != null_type
149
+ ):
150
+ name_to_type[field.name] = field.type
151
+
152
+ union_fields = [pa.field(c, name_to_type[c]) for c in union_cols]
153
+ # Preserve flextool metadata so the temp file is readable via
154
+ # ``read_lean_parquet`` (round-trips the index / columns names).
155
+ schema_meta = {b"flextool": flextool_info} if flextool_info else None
156
+ union_schema = pa.schema(union_fields, metadata=schema_meta)
157
+
158
+ # Pass 2: stream-write each part as a single row-group.
159
+ parent = use_parts[0].parent
160
+ # ``mkstemp`` creates the file with O_EXCL — no collision with
161
+ # concurrent runs writing into the same folder.
162
+ fd, tmp_path_str = tempfile.mkstemp(
163
+ prefix=f".{use_parts[0].stem.split('__')[0]}__concat_", suffix=".parquet",
164
+ dir=str(parent),
165
+ )
166
+ os.close(fd) # ParquetWriter will reopen by path.
167
+ tmp_path = Path(tmp_path_str)
168
+ try:
169
+ with pq.ParquetWriter(str(tmp_path), union_schema) as writer:
170
+ for p in use_parts:
171
+ table = pq.read_table(str(p))
172
+ # Strip per-part metadata so the writer doesn't reject
173
+ # the row-group on schema mismatch — only the file-level
174
+ # ``union_schema`` carries the canonical ``flextool``
175
+ # blob.
176
+ table = table.replace_schema_metadata(None)
177
+ # Align the part's columns to ``union_cols`` order,
178
+ # filling absent columns with a null array (cheap —
179
+ # arrow stores a single null reference, not a dense
180
+ # ndarray).
181
+ n_rows = table.num_rows
182
+ arrays: list[pa.ChunkedArray | pa.Array] = []
183
+ part_cols = {f.name: i for i, f in enumerate(table.schema)}
184
+ for col_name in union_cols:
185
+ if col_name in part_cols:
186
+ arr = table.column(part_cols[col_name])
187
+ target_type = name_to_type[col_name]
188
+ if arr.type != target_type:
189
+ if arr.type == null_type:
190
+ # All-null part column → emit a properly
191
+ # typed null array of the same length.
192
+ arr = pa.nulls(arr.length(), type=target_type)
193
+ else:
194
+ # Concrete → target (e.g. int32 → int64).
195
+ arr = arr.cast(target_type)
196
+ arrays.append(arr)
197
+ else:
198
+ arrays.append(
199
+ pa.nulls(n_rows, type=name_to_type[col_name])
200
+ )
201
+ aligned = pa.Table.from_arrays(
202
+ arrays, schema=union_schema,
203
+ )
204
+ writer.write_table(aligned)
205
+ # Free part-level references before reading the next.
206
+ del table, aligned, arrays
207
+
208
+ # Pass 3: single read-back as pandas. ``self_destruct=True``
209
+ # discards arrow buffers as columns are materialised, so peak
210
+ # during this conversion is one buffer + the growing pandas
211
+ # frame, not two complete copies. The temp file's row-group
212
+ # layout is preserved (one per part) — multi-row-group reads
213
+ # are still single-frame from the consumer's perspective.
214
+ result_table = pq.read_table(str(tmp_path))
215
+ df = result_table.to_pandas(self_destruct=True)
216
+ del result_table
217
+ finally:
218
+ # Best-effort cleanup — never let a stray temp file accumulate.
219
+ try:
220
+ tmp_path.unlink()
221
+ except OSError:
222
+ pass
223
+
224
+ # --- Rebuild row index from the flextool metadata ---
225
+ # ``read_lean_parquet`` does this for a single file; we replicate
226
+ # the logic against the union schema's metadata so the streaming
227
+ # writer is interchangeable with the old eager path.
228
+ if flextool_info is not None:
229
+ info = json.loads(flextool_info)
230
+ idx_names = info.get("idx")
231
+ if idx_names:
232
+ col_lookup: list[str] = []
233
+ for i, lname in enumerate(idx_names):
234
+ col_lookup.append(
235
+ f"__index_level_{i}__" if lname is None else lname
236
+ )
237
+ present = [c for c in col_lookup if c in df.columns]
238
+ if present:
239
+ df = df.set_index(present)
240
+ if isinstance(df.index, pd.MultiIndex):
241
+ df.index.names = idx_names
242
+ else:
243
+ df.index.name = idx_names[0]
244
+
245
+ col_level_names = info.get("col")
246
+ if col_level_names:
247
+ tuples = [ast.literal_eval(c) for c in df.columns]
248
+ df.columns = pd.MultiIndex.from_tuples(
249
+ tuples, names=col_level_names,
250
+ )
251
+ else:
252
+ single_col_name = info.get("col_name")
253
+ if single_col_name is not None:
254
+ df.columns.name = single_col_name
255
+
256
+ # ``astype(float)`` matched the old behaviour — values are already
257
+ # float64 from the writer side, but this preserves the historical
258
+ # promotion for any oddly-typed (e.g. int-zero) all-null columns.
259
+ df = df.astype(float)
260
+
261
+ # Collapse 1-level MultiIndex columns to plain Index — same fixup
262
+ # the old eager path applied (the CSV pathway produces a plain
263
+ # Index here; downstream ``DataFrame.mul(..., level=0)`` treats
264
+ # MultiIndex-with-one-level differently).
265
+ if isinstance(df.columns, pd.MultiIndex) and df.columns.nlevels == 1:
266
+ df.columns = pd.Index(
267
+ [c[0] for c in df.columns], name=df.columns.names[0],
268
+ )
269
+
270
+ return df.fillna(0.0)
271
+
272
+
273
+ def _read_from_parquet(parquet_dir: Path, input_path: Path) -> SimpleNamespace:
274
+ """New pathway: concat per-solve parquets for each variable.
275
+
276
+ Per-solve parquets are concatenated in solve **creation** order —
277
+ the same order the CSV pathway sees rows appended by phase-1
278
+ printfs. Reading in creation order avoids any post-concat sort;
279
+ downstream ``DataFrame.mul(axis=1, level=0)`` aligns operands by
280
+ row index, and both reader pathways producing the same row order
281
+ is enough.
282
+ """
283
+ v = SimpleNamespace()
284
+ work_folder = parquet_dir.parent
285
+ solve_order = load_solve_order(work_folder)
286
+
287
+ def _solve_from_filename(name: str, path: Path) -> str:
288
+ # ``<output_name>__<solve>.parquet`` — strip prefix + extension.
289
+ return path.name[len(name) + 2:-len(".parquet")]
290
+
291
+ def _read(
292
+ name: str,
293
+ col_names: Sequence[str],
294
+ *,
295
+ has_period: bool = True,
296
+ has_time: bool = True,
297
+ ) -> pd.DataFrame:
298
+ # Parquet carries full float64 precision; no rounding — that
299
+ # belongs only at the final user-facing CSV write boundary.
300
+ #
301
+ # When no parquet exists (writer was never called for this variable),
302
+ # fall back to a typed empty frame via ``empty_variable_frame`` so
303
+ # downstream calc code still sees the expected ``columns.name`` /
304
+ # row-index ``names``.
305
+ parts = list(parquet_dir.glob(f"{name}__*.parquet"))
306
+ if not parts:
307
+ return empty_variable_frame(
308
+ solve_name="", col_names=col_names,
309
+ has_period=has_period, has_time=has_time,
310
+ )
311
+ # Order per-solve parts by solve creation order so the concat
312
+ # result is already in the correct row order — no post-concat
313
+ # sort needed. Solves missing from ``solve_order`` sort first
314
+ # (shouldn't happen normally; defensive).
315
+ parts.sort(key=lambda p: solve_order.get(_solve_from_filename(name, p), -1))
316
+ # Phase E2 — stream per-solve parts through pyarrow as one
317
+ # row-group each, so we never hold all frames in a Python list
318
+ # nor pay for ``pd.concat``'s second-copy. Reader contract is
319
+ # unchanged: the public return is still a ``pd.DataFrame`` with
320
+ # the same row order, column union, MultiIndex/columns-name
321
+ # semantics, and zero-filled missing cells.
322
+ return _stream_concat_parts(parts)
323
+
324
+ v.obj = _read("v_obj", ("objective",), has_period=False, has_time=False)
325
+ v.flow = _read("v_flow", ("process", "source", "sink"))
326
+ # Reverse-flow auxiliary for method_2way_1var_off arcs (empty frame
327
+ # when no such arc is present). Folded into the signed net flow by
328
+ # calc_capacity_flows.
329
+ v.flow_back = _read("v_flow_back", ("process", "source", "sink"))
330
+ v.ramp = _read("v_ramp", ("process", "source", "sink"))
331
+ v.reserve = _read("v_reserve", ("process", "reserve", "updown", "node"))
332
+ v.state = _read("v_state", ("node",))
333
+ v.online_linear = _read("v_online_linear", ("process",))
334
+ v.startup_linear = _read("v_startup_linear", ("process",))
335
+ v.shutdown_linear = _read("v_shutdown_linear", ("process",))
336
+ v.online_integer = _read("v_online_integer", ("process",))
337
+ v.startup_integer = _read("v_startup_integer", ("process",))
338
+ v.shutdown_integer = _read("v_shutdown_integer", ("process",))
339
+ v.q_state_up = _read("vq_state_up", ("node",))
340
+ v.q_state_down = _read("vq_state_down", ("node",))
341
+ v.q_reserve = _read("vq_reserve", ("reserve", "updown", "node_group"))
342
+ v.q_inertia = _read("vq_inertia", ("group",))
343
+ v.q_non_synchronous = _read("vq_non_synchronous", ("group",))
344
+ v.q_state_up_group = _read("vq_state_up_group", ("group",))
345
+ v.q_capacity_margin = _read("vq_capacity_margin", ("group",), has_time=False)
346
+ v.invest = _read("v_invest", ("entity",), has_time=False)
347
+ v.divest = _read("v_divest", ("entity",), has_time=False)
348
+ v.dual_node_balance = _read("v_dual_node_balance", ("node",))
349
+ v.dual_reserve_balance = _read(
350
+ "v_dual_reserve__upDown__group__period__t",
351
+ ("reserve", "updown", "node_group"),
352
+ )
353
+ v.angle = _read("v_angle", ("node",))
354
+ v.dual_invest_unit = _read("v_dual_invest_unit", ("unit",), has_time=False)
355
+ v.dual_invest_connection = _read("v_dual_invest_connection", ("connection",), has_time=False)
356
+ v.dual_invest_node = _read("v_dual_invest_node", ("node",), has_time=False)
357
+ v.dual_maxInvest_period = _read("v_dual_maxInvest_period", ("entity",), has_time=False)
358
+ v.dual_maxInvest_total = _read("v_dual_maxInvest_total", ("entity",), has_time=False)
359
+ v.dual_maxCumulative = _read("v_dual_maxCumulative", ("entity",), has_time=False)
360
+ v.dual_maxInvestGroup_period = _read("v_dual_maxInvestGroup_period", ("group",), has_time=False)
361
+ v.dual_maxInvestGroup_total = _read("v_dual_maxInvestGroup_total", ("group",), has_time=False)
362
+ v.dual_maxInvestGroup_cumulative = _read("v_dual_maxInvestGroup_cumulative", ("group",), has_time=False)
363
+ # Investment-floor (min-side) duals — mirror the max-side readers above.
364
+ v.dual_minInvest_period = _read("v_dual_minInvest_period", ("entity",), has_time=False)
365
+ v.dual_minInvest_total = _read("v_dual_minInvest_total", ("entity",), has_time=False)
366
+ v.dual_minCumulative = _read("v_dual_minCumulative", ("entity",), has_time=False)
367
+ v.dual_minInvestGroup_period = _read("v_dual_minInvestGroup_period", ("group",), has_time=False)
368
+ v.dual_minInvestGroup_total = _read("v_dual_minInvestGroup_total", ("group",), has_time=False)
369
+ v.dual_minInvestGroup_cumulative = _read("v_dual_minInvestGroup_cumulative", ("group",), has_time=False)
370
+ v.dual_co2_max_period = _read("v_dual_co2_max_period", ("group",), has_time=False)
371
+ v.dual_co2_max_total = _read("v_dual_co2_max_total", ("group",), has_period=False, has_time=False)
372
+
373
+ # ``group_entity_invest`` is a static map (solveFirst-only) written
374
+ # during phase 1 directly to ``input/`` — same as the CSV pathway.
375
+ # Δ.31: tolerate absence (the polars cascade doesn't always emit
376
+ # this file; downstream code only consumes it for the
377
+ # ``maxInvestGroup`` family which is empty without group invest).
378
+ gei_path = input_path / "group_entity_invest.csv"
379
+ if gei_path.exists():
380
+ v.group_entity_invest = pd.read_csv(gei_path)
381
+ else:
382
+ v.group_entity_invest = pd.DataFrame(columns=["group", "entity"])
383
+ return v
384
+
385
+
386
+ def _read_from_csv(output_path: Path, input_path: Path) -> SimpleNamespace:
387
+ """CSV pathway: read ``output_raw/<var>.csv`` files."""
388
+ v = SimpleNamespace()
389
+
390
+ # Variables with (solve, period, time) index
391
+ v.obj = pd.read_csv(output_path / 'v_obj.csv', header=[0], index_col=[0]).astype(float)
392
+ v.flow = pd.read_csv(output_path / 'v_flow.csv', header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
393
+ # Reverse-flow auxiliary for method_2way_1var_off arcs. The legacy CSV
394
+ # writer only emits this file when the variable exists; mirror v_flow's
395
+ # shape with an empty frame otherwise.
396
+ _flow_back_csv = output_path / 'v_flow_back.csv'
397
+ if _flow_back_csv.exists():
398
+ v.flow_back = pd.read_csv(_flow_back_csv, header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
399
+ else:
400
+ v.flow_back = pd.DataFrame(index=v.flow.index, columns=v.flow.columns[:0])
401
+ v.ramp = pd.read_csv(output_path / 'v_ramp.csv', header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
402
+ v.reserve = pd.read_csv(output_path / 'v_reserve.csv', header=[0, 1, 2, 3], index_col=[0, 1, 2]).astype(float)
403
+ v.state = pd.read_csv(output_path / 'v_state.csv', index_col=[0, 1, 2]).astype(float)
404
+ v.online_linear = pd.read_csv(output_path / 'v_online_linear.csv', index_col=[0, 1, 2]).astype(float)
405
+ v.startup_linear = pd.read_csv(output_path / 'v_startup_linear.csv', index_col=[0, 1, 2]).astype(float)
406
+ v.shutdown_linear = pd.read_csv(output_path / 'v_shutdown_linear.csv', index_col=[0, 1, 2]).astype(float)
407
+ v.online_integer = pd.read_csv(output_path / 'v_online_integer.csv', index_col=[0, 1, 2]).astype(float)
408
+ v.startup_integer = pd.read_csv(output_path / 'v_startup_integer.csv', index_col=[0, 1, 2]).astype(float)
409
+ v.shutdown_integer = pd.read_csv(output_path / 'v_shutdown_integer.csv', index_col=[0, 1, 2]).astype(float)
410
+ v.q_state_up = pd.read_csv(output_path / 'vq_state_up.csv', index_col=[0, 1, 2]).astype(float)
411
+ v.q_state_down = pd.read_csv(output_path / 'vq_state_down.csv', index_col=[0, 1, 2]).astype(float)
412
+ v.q_reserve = pd.read_csv(output_path / 'vq_reserve.csv', header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
413
+ v.q_inertia = pd.read_csv(output_path / 'vq_inertia.csv', index_col=[0, 1, 2]).astype(float)
414
+ v.q_non_synchronous = pd.read_csv(output_path / 'vq_non_synchronous.csv', index_col=[0, 1, 2]).astype(float)
415
+ v.q_state_up_group = pd.read_csv(output_path / 'vq_state_up_group.csv', index_col=[0, 1, 2]).astype(float)
416
+ v.q_capacity_margin = pd.read_csv(output_path / 'vq_capacity_margin.csv', index_col=[0, 1]).astype(float)
417
+ v.invest = pd.read_csv(output_path / 'v_invest.csv', index_col=[0, 1]).astype(float)
418
+ v.divest = pd.read_csv(output_path / 'v_divest.csv', index_col=[0, 1]).astype(float)
419
+ v.dual_node_balance = pd.read_csv(output_path / 'v_dual_node_balance.csv', index_col=[0, 1, 2]).astype(float)
420
+ v.dual_reserve_balance = pd.read_csv(output_path / 'v_dual_reserve__upDown__group__period__t.csv', header=[0, 1, 2], index_col=[0, 1, 2]).astype(float)
421
+
422
+ # DC power flow voltage angles (may be empty when no DC PF nodes exist)
423
+ angle_path = output_path / 'v_angle.csv'
424
+ if angle_path.exists():
425
+ v.angle = pd.read_csv(angle_path, index_col=[0, 1, 2]).astype(float)
426
+ if v.angle.empty:
427
+ v.angle = pd.DataFrame()
428
+ else:
429
+ v.angle.index.names = ['solve', 'period', 'time']
430
+ v.angle.columns.name = 'node'
431
+ else:
432
+ v.angle = pd.DataFrame()
433
+
434
+ v.dual_invest_unit = pd.read_csv(output_path / 'v_dual_invest_unit.csv', index_col=[0, 1]).astype(float)
435
+ v.dual_invest_connection = pd.read_csv(output_path / 'v_dual_invest_connection.csv', index_col=[0, 1]).astype(float)
436
+ v.dual_invest_node = pd.read_csv(output_path / 'v_dual_invest_node.csv', index_col=[0, 1]).astype(float)
437
+ # Investment constraint duals (per MW, unscaled)
438
+ v.dual_maxInvest_period = pd.read_csv(output_path / 'v_dual_maxInvest_period.csv', index_col=[0, 1]).astype(float)
439
+ v.dual_maxInvest_total = pd.read_csv(output_path / 'v_dual_maxInvest_total.csv', index_col=[0, 1]).astype(float)
440
+ v.dual_maxCumulative = pd.read_csv(output_path / 'v_dual_maxCumulative.csv', index_col=[0, 1]).astype(float)
441
+ v.dual_maxInvestGroup_period = pd.read_csv(output_path / 'v_dual_maxInvestGroup_period.csv', index_col=[0, 1]).astype(float)
442
+ v.dual_maxInvestGroup_total = pd.read_csv(output_path / 'v_dual_maxInvestGroup_total.csv', index_col=[0, 1]).astype(float)
443
+ v.dual_maxInvestGroup_cumulative = pd.read_csv(output_path / 'v_dual_maxInvestGroup_cumulative.csv', index_col=[0, 1]).astype(float)
444
+ # Investment-floor (min-side) duals (per MW, unscaled)
445
+ v.dual_minInvest_period = pd.read_csv(output_path / 'v_dual_minInvest_period.csv', index_col=[0, 1]).astype(float)
446
+ v.dual_minInvest_total = pd.read_csv(output_path / 'v_dual_minInvest_total.csv', index_col=[0, 1]).astype(float)
447
+ v.dual_minCumulative = pd.read_csv(output_path / 'v_dual_minCumulative.csv', index_col=[0, 1]).astype(float)
448
+ v.dual_minInvestGroup_period = pd.read_csv(output_path / 'v_dual_minInvestGroup_period.csv', index_col=[0, 1]).astype(float)
449
+ v.dual_minInvestGroup_total = pd.read_csv(output_path / 'v_dual_minInvestGroup_total.csv', index_col=[0, 1]).astype(float)
450
+ v.dual_minInvestGroup_cumulative = pd.read_csv(output_path / 'v_dual_minInvestGroup_cumulative.csv', index_col=[0, 1]).astype(float)
451
+ # CO2 emission-cap duals (raw dual is per /1000-scaled RHS; downstream * 1000 for tCO2)
452
+ v.dual_co2_max_period = pd.read_csv(output_path / 'v_dual_co2_max_period.csv', index_col=[0, 1]).astype(float)
453
+ v.dual_co2_max_total = pd.read_csv(output_path / 'v_dual_co2_max_total.csv', index_col=[0]).astype(float)
454
+ # group_entity_invest lives in input/ (solveFirst-gated static).
455
+ v.group_entity_invest = pd.read_csv(input_path / 'group_entity_invest.csv')
456
+
457
+ v.flow.index.names = ['solve', 'period', 'time']
458
+ v.ramp.index.names = ['solve', 'period', 'time']
459
+ v.reserve.index.names = ['solve', 'period', 'time']
460
+ v.state.index.names = ['solve', 'period', 'time']
461
+ v.online_linear.index.names = ['solve', 'period', 'time']
462
+ v.startup_linear.index.names = ['solve', 'period', 'time']
463
+ v.shutdown_linear.index.names = ['solve', 'period', 'time']
464
+ v.online_integer.index.names = ['solve', 'period', 'time']
465
+ v.startup_integer.index.names = ['solve', 'period', 'time']
466
+ v.shutdown_integer.index.names = ['solve', 'period', 'time']
467
+ v.q_state_up.index.names = ['solve', 'period', 'time']
468
+ v.q_state_down.index.names = ['solve', 'period', 'time']
469
+ v.q_reserve.index.names = ['solve', 'period', 'time']
470
+ v.q_inertia.index.names = ['solve', 'period', 'time']
471
+ v.q_non_synchronous.index.names = ['solve', 'period', 'time']
472
+ v.q_state_up_group.index.names = ['solve', 'period', 'time']
473
+ v.q_capacity_margin.index.names = ['solve', 'period']
474
+ v.invest.index.names = ['solve', 'period']
475
+ v.divest.index.names = ['solve', 'period']
476
+ v.dual_node_balance.index.names = ['solve', 'period', 'time']
477
+ v.dual_reserve_balance.index.names = ['solve', 'period', 'time']
478
+ v.dual_invest_unit.index.names = ['solve', 'period']
479
+ v.dual_invest_connection.index.names = ['solve', 'period']
480
+ v.dual_invest_node.index.names = ['solve', 'period']
481
+ v.dual_maxInvest_period.index.names = ['solve', 'period']
482
+ v.dual_maxInvest_total.index.names = ['solve', 'period']
483
+ v.dual_maxCumulative.index.names = ['solve', 'period']
484
+ v.dual_maxInvestGroup_period.index.names = ['solve', 'period']
485
+ v.dual_maxInvestGroup_total.index.names = ['solve', 'period']
486
+ v.dual_maxInvestGroup_cumulative.index.names = ['solve', 'period']
487
+ v.dual_minInvest_period.index.names = ['solve', 'period']
488
+ v.dual_minInvest_total.index.names = ['solve', 'period']
489
+ v.dual_minCumulative.index.names = ['solve', 'period']
490
+ v.dual_minInvestGroup_period.index.names = ['solve', 'period']
491
+ v.dual_minInvestGroup_total.index.names = ['solve', 'period']
492
+ v.dual_minInvestGroup_cumulative.index.names = ['solve', 'period']
493
+ v.dual_co2_max_period.index.names = ['solve', 'period']
494
+ v.dual_co2_max_total.index.name = 'solve'
495
+
496
+ # Create multi-index for variables with single header row
497
+ v.state.columns.name = 'node'
498
+ v.online_linear.columns.name = 'process'
499
+ v.startup_linear.columns.name = 'process'
500
+ v.shutdown_linear.columns.name = 'process'
501
+ v.online_integer.columns.name = 'process'
502
+ v.startup_integer.columns.name = 'process'
503
+ v.shutdown_integer.columns.name = 'process'
504
+ v.q_state_up.columns.name = 'node'
505
+ v.q_state_down.columns.name = 'node'
506
+ v.q_inertia.columns.name = 'group'
507
+ v.q_non_synchronous.columns.name = 'group'
508
+ v.q_state_up_group.columns.name = 'group'
509
+ v.q_capacity_margin.columns.name = 'group'
510
+ v.invest.columns.name = 'entity'
511
+ v.divest.columns.name = 'entity'
512
+ v.dual_node_balance.columns.name = 'node'
513
+ v.dual_invest_unit.columns.name = 'unit'
514
+ v.dual_invest_connection.columns.name = 'connection'
515
+ v.dual_invest_node.columns.name = 'node'
516
+ v.dual_maxInvest_period.columns.name = 'entity'
517
+ v.dual_maxInvest_total.columns.name = 'entity'
518
+ v.dual_maxCumulative.columns.name = 'entity'
519
+ v.dual_maxInvestGroup_period.columns.name = 'group'
520
+ v.dual_maxInvestGroup_total.columns.name = 'group'
521
+ v.dual_maxInvestGroup_cumulative.columns.name = 'group'
522
+ v.dual_minInvest_period.columns.name = 'entity'
523
+ v.dual_minInvest_total.columns.name = 'entity'
524
+ v.dual_minCumulative.columns.name = 'entity'
525
+ v.dual_minInvestGroup_period.columns.name = 'group'
526
+ v.dual_minInvestGroup_total.columns.name = 'group'
527
+ v.dual_minInvestGroup_cumulative.columns.name = 'group'
528
+ v.dual_co2_max_period.columns.name = 'group'
529
+ v.dual_co2_max_total.columns.name = 'group'
530
+
531
+ # Add multi-index to variables with multiple header rows (this multi-index creation works also when the dataframe is empty)
532
+ v.flow.columns = pd.MultiIndex.from_tuples(
533
+ [(col[0], col[1], col[2]) for col in v.flow.columns],
534
+ names=['process', 'source', 'sink']
535
+ )
536
+ v.ramp.columns = pd.MultiIndex.from_tuples(
537
+ [(col[0], col[1], col[2]) for col in v.ramp.columns],
538
+ names=['process', 'source', 'sink']
539
+ )
540
+ v.reserve.columns = pd.MultiIndex.from_tuples(
541
+ [(col[0], col[1], col[2], col[3]) for col in v.reserve.columns],
542
+ names=['process', 'reserve', 'updown', 'node']
543
+ )
544
+ v.q_reserve.columns = pd.MultiIndex.from_tuples(
545
+ [(col[0], col[1], col[2]) for col in v.q_reserve.columns],
546
+ names=['reserve', 'updown', 'node_group']
547
+ )
548
+ v.dual_reserve_balance.columns = pd.MultiIndex.from_tuples(
549
+ [(col[0], col[1], col[2]) for col in v.dual_reserve_balance.columns],
550
+ names=['reserve', 'updown', 'node_group']
551
+ )
552
+
553
+ return v
@@ -0,0 +1,81 @@
1
+ """Solve creation-order helper.
2
+
3
+ Reads ``solve_data/solve__p_entity_pre_existing.csv`` (a per-solve parameter
4
+ that's appended on every solve and that's present for every realistic
5
+ model) to recover the order in which solves were executed.
6
+
7
+ Used by :mod:`read_variables._read_from_parquet` to concatenate per-solve
8
+ parquets in solve creation order, so the resulting row order matches
9
+ the CSV-reader pathway (where rows are naturally in creation order via
10
+ phase-1 ``for {s in solve_current, ...}`` printf appends). Matching
11
+ row order at the source avoids needing any post-concat sort or
12
+ per-call-site reindex.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import pandas as pd
19
+
20
+
21
+ # Creation-order manifest written incrementally by the per-roll output
22
+ # hook (one line per sub-solve, in execution order). This is the
23
+ # authoritative order source on the default ``keep_solutions=False`` flow,
24
+ # where ``solve_data/solve__p_entity_pre_existing.csv`` is absent.
25
+ _MANIFEST_NAME = "_solve_order.txt"
26
+
27
+
28
+ def append_solve_order(output_dir: Path | str, solve_name: str) -> None:
29
+ """Append ``solve_name`` to the creation-order manifest in ``output_dir``.
30
+
31
+ Idempotent within a run only in the sense that re-running the cascade
32
+ into the same folder must start from a clean manifest — callers that
33
+ re-run must clear ``output_dir`` first (the cascade does). Called
34
+ once per sub-solve, in execution order, so the file's line order is
35
+ the cascade creation order both the variable reader and the stage-3
36
+ param/set union read back via :func:`load_solve_order`.
37
+ """
38
+ out = Path(output_dir)
39
+ out.mkdir(parents=True, exist_ok=True)
40
+ with (out / _MANIFEST_NAME).open("a", encoding="utf-8") as f:
41
+ f.write(f"{solve_name}\n")
42
+
43
+
44
+ def _load_from_manifest(output_dir: Path) -> dict[str, int]:
45
+ path = output_dir / _MANIFEST_NAME
46
+ if not path.exists():
47
+ return {}
48
+ seen: list[str] = []
49
+ seen_set: set[str] = set()
50
+ for line in path.read_text(encoding="utf-8").splitlines():
51
+ name = line.strip()
52
+ if name and name not in seen_set:
53
+ seen_set.add(name)
54
+ seen.append(name)
55
+ return {s: i for i, s in enumerate(seen)}
56
+
57
+
58
+ def load_solve_order(work_folder: Path | str) -> dict[str, int]:
59
+ """Map solve names to their creation order (0-indexed).
60
+
61
+ Sources, in priority:
62
+
63
+ 1. ``output_raw/_solve_order.txt`` — the per-roll manifest appended by
64
+ the output hook (:func:`append_solve_order`). Authoritative on the
65
+ default ``keep_solutions=False`` flow.
66
+ 2. ``solve_data/solve__p_entity_pre_existing.csv`` — legacy per-solve
67
+ appendage; unique solves in row order = creation order.
68
+
69
+ Returns ``{}`` if neither source is present.
70
+ """
71
+ work = Path(work_folder)
72
+ manifest = _load_from_manifest(work / "output_raw")
73
+ if manifest:
74
+ return manifest
75
+
76
+ path = work / "solve_data" / "solve__p_entity_pre_existing.csv"
77
+ if not path.exists():
78
+ return {}
79
+ df = pd.read_csv(path, usecols=["solve"])
80
+ solves = df["solve"].astype(str).drop_duplicates().tolist()
81
+ return {s: i for i, s in enumerate(solves)}