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,1095 @@
1
+ """Δ.31 — In-memory replacement for the CSV-based ``read_sets``.
2
+
3
+ Translates the polars ``FlexData`` set-frames into the pandas
4
+ :class:`pd.Index` / :class:`pd.MultiIndex` shapes the downstream
5
+ ``out_*`` modules consume. CSV reads are gone entirely — every
6
+ attribute on the returned :class:`SimpleNamespace` maps to a FlexData
7
+ field (or, for a small handful of derived sets, a simple in-memory
8
+ projection of one or more FlexData fields).
9
+
10
+ Failure mode: every helper raises loudly when a FlexData field is
11
+ absent or has an unexpected schema. Empty sets are returned as
12
+ empty :class:`pd.Index` / :class:`pd.MultiIndex` with the correct
13
+ ``names`` / ``dtype`` so downstream consumers can call
14
+ ``.intersection`` / ``.get_level_values`` etc. without crashing.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from types import SimpleNamespace
19
+ from typing import TYPE_CHECKING, Sequence
20
+
21
+ import pandas as pd
22
+ import polars as pl
23
+
24
+ from flextool.process_outputs._inmemory_helpers import (
25
+ empty_index,
26
+ empty_multi_index,
27
+ long_dim,
28
+ to_index,
29
+ to_multi_index,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from polar_high import Solution
34
+
35
+ from flextool.engine_polars.input import FlexData
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Per-set helpers
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def _index_or_empty(
44
+ frame_pl: "pl.DataFrame | None",
45
+ *,
46
+ dim: str,
47
+ name: str | None = None,
48
+ ) -> pd.Index:
49
+ """Return an :class:`pd.Index` for ``frame_pl[dim]`` or an empty Index."""
50
+ name = name if name is not None else long_dim(dim)
51
+ if frame_pl is None or frame_pl.height == 0 or dim not in frame_pl.columns:
52
+ return empty_index(name=name)
53
+ return to_index(frame_pl, dim=dim, name=name)
54
+
55
+
56
+ def _multi_index_or_empty(
57
+ frame_pl: "pl.DataFrame | None",
58
+ *,
59
+ dims: Sequence[str],
60
+ names: Sequence[str] | None = None,
61
+ ) -> pd.MultiIndex:
62
+ """Return a :class:`pd.MultiIndex` for ``frame_pl[*dims]`` or an
63
+ empty MultiIndex with the given names."""
64
+ if names is None:
65
+ names = [long_dim(d) for d in dims]
66
+ if frame_pl is None or frame_pl.height == 0:
67
+ return empty_multi_index(list(names))
68
+ if not all(d in frame_pl.columns for d in dims):
69
+ return empty_multi_index(list(names))
70
+ return to_multi_index(frame_pl, dims=dims, names=names)
71
+
72
+
73
+ def _multi_index_with_solve(
74
+ frame_pl: "pl.DataFrame | None",
75
+ *,
76
+ solve_name: str,
77
+ dims: Sequence[str],
78
+ names: Sequence[str],
79
+ ) -> pd.MultiIndex:
80
+ """Like :func:`_multi_index_or_empty` but prepend a constant
81
+ ``solve`` level. Used for sets whose legacy CSV layout was
82
+ ``solve, period, …`` but FlexData drops the solve column.
83
+ """
84
+ if frame_pl is None or frame_pl.height == 0:
85
+ return empty_multi_index(["solve"] + list(names))
86
+ if not all(d in frame_pl.columns for d in dims):
87
+ return empty_multi_index(["solve"] + list(names))
88
+ pdf = frame_pl.with_columns(pl.lit(solve_name).alias("solve")).select(
89
+ "solve", *list(dims)
90
+ ).to_pandas()
91
+ return pd.MultiIndex.from_frame(pdf, names=["solve"] + list(names))
92
+
93
+
94
+ def _commodity_node_from_flex(
95
+ flex_data: "FlexData",
96
+ *,
97
+ attrs: Sequence[tuple[str, str]],
98
+ ) -> pd.MultiIndex:
99
+ """Derive a ``(commodity, node)`` MultiIndex from FlexData flow_* frames.
100
+
101
+ Each entry of ``attrs`` is a ``(field_name, node_column)`` tuple where
102
+ ``field_name`` is a ``flex_data`` attribute that — when present — is a
103
+ polars frame with at least columns ``c`` and ``node_column``. The
104
+ union of ``(c, node_column)`` across all listed frames is returned
105
+ as a deterministic, deduplicated ``pd.MultiIndex`` with names
106
+ ``("commodity", "node")``. Returns an empty MultiIndex when no
107
+ listed field is populated.
108
+
109
+ All Enum columns are cast to Utf8 before crossing the pandas
110
+ boundary to avoid Enum-vs-Utf8 join-key mismatches downstream
111
+ (cf. ``specs/enum_dtype_refactor_handoff.md``).
112
+ """
113
+ pieces: list[pl.DataFrame] = []
114
+ for field_name, node_col in attrs:
115
+ fr = getattr(flex_data, field_name, None)
116
+ if fr is None or fr.height == 0 or node_col not in fr.columns:
117
+ continue
118
+ c_col = "c" if "c" in fr.columns else "commodity"
119
+ if c_col not in fr.columns:
120
+ continue
121
+ pieces.append(
122
+ fr.select(
123
+ pl.col(c_col).cast(pl.Utf8).alias("commodity"),
124
+ pl.col(node_col).cast(pl.Utf8).alias("node"),
125
+ )
126
+ )
127
+ if not pieces:
128
+ return empty_multi_index(["commodity", "node"])
129
+ df = (
130
+ pl.concat(pieces, how="vertical_relaxed")
131
+ .unique()
132
+ .sort("commodity", "node")
133
+ .to_pandas()
134
+ )
135
+ if df.empty:
136
+ return empty_multi_index(["commodity", "node"])
137
+ return pd.MultiIndex.from_frame(df, names=["commodity", "node"])
138
+
139
+
140
+ # Emitting-flow frames that contribute to the CO2 emissions set, as
141
+ # ``(field_name, node_column)`` pairs. The union covers CO2-priced,
142
+ # period-capped and multi-period-total-capped flows. A process that sits in
143
+ # several CO2 groups appears in several of these frames; the union is
144
+ # deduplicated on ``(process, commodity, node)`` so the per-process system
145
+ # total (``calc_costs.emissions_co2_d``) counts each emitting flow exactly
146
+ # once. Omitting the ``*_total`` pair made ``co2_method = total`` models
147
+ # report 0 CO2.
148
+ _CO2_EMISSION_FLOW_ATTRS: tuple[tuple[str, str], ...] = (
149
+ ("flow_from_co2_priced", "source"),
150
+ ("flow_from_co2_priced_noEff", "source"),
151
+ ("flow_from_co2_capped", "source"),
152
+ ("flow_from_co2_capped_noEff", "source"),
153
+ ("flow_from_co2_capped_total", "source"),
154
+ ("flow_from_co2_capped_total_noEff", "source"),
155
+ )
156
+
157
+
158
+ def _process_commodity_node_from_flex(
159
+ flex_data: "FlexData",
160
+ *,
161
+ attrs: Sequence[tuple[str, str]],
162
+ ) -> pd.MultiIndex:
163
+ """Derive a ``(process, commodity, node)`` MultiIndex from FlexData
164
+ flow_* frames.
165
+
166
+ Each entry of ``attrs`` is a ``(field_name, node_column)`` tuple. The
167
+ union of ``(p, c, node_column)`` rows across all listed frames is
168
+ returned as a deterministic, deduplicated ``pd.MultiIndex`` with
169
+ names ``("process", "commodity", "node")``.
170
+ """
171
+ pieces: list[pl.DataFrame] = []
172
+ for field_name, node_col in attrs:
173
+ fr = getattr(flex_data, field_name, None)
174
+ if fr is None or fr.height == 0 or node_col not in fr.columns:
175
+ continue
176
+ c_col = "c" if "c" in fr.columns else "commodity"
177
+ p_col = "p" if "p" in fr.columns else "process"
178
+ if c_col not in fr.columns or p_col not in fr.columns:
179
+ continue
180
+ pieces.append(
181
+ fr.select(
182
+ pl.col(p_col).cast(pl.Utf8).alias("process"),
183
+ pl.col(c_col).cast(pl.Utf8).alias("commodity"),
184
+ pl.col(node_col).cast(pl.Utf8).alias("node"),
185
+ )
186
+ )
187
+ if not pieces:
188
+ return empty_multi_index(["process", "commodity", "node"])
189
+ df = (
190
+ pl.concat(pieces, how="vertical_relaxed")
191
+ .unique()
192
+ .sort("process", "commodity", "node")
193
+ .to_pandas()
194
+ )
195
+ if df.empty:
196
+ return empty_multi_index(["process", "commodity", "node"])
197
+ return pd.MultiIndex.from_frame(
198
+ df, names=["process", "commodity", "node"],
199
+ )
200
+
201
+
202
+ def _node_types(flex_data: "FlexData") -> dict[str, str]:
203
+ r"""Return ``{node: p_node_type}`` for every node, defaulting to
204
+ ``balance``.
205
+
206
+ FlexData carries the type-discrimination through dedicated set
207
+ frames (``nodeBalance``, ``nodeState``). We synthesize the
208
+ ``p_node_type`` semantics from those:
209
+
210
+ * ``storage`` — node in nodeState
211
+ * ``balance`` — node in nodeBalance \ nodeState
212
+ * ``balance_within_period`` — node in nodeBalance with the
213
+ ``balance_within_period`` flag (no FlexData field carries this
214
+ flag standalone; treat as default for now)
215
+ * ``commodity`` — node attached to a commodity (via
216
+ ``commodity_node``)
217
+ """
218
+ nodes = []
219
+ if (flex_data.nodeBalance is not None
220
+ and flex_data.nodeBalance.height > 0):
221
+ nodes = flex_data.nodeBalance["n"].to_list()
222
+ state_nodes = set()
223
+ if (flex_data.nodeState is not None
224
+ and flex_data.nodeState.height > 0):
225
+ state_nodes = set(flex_data.nodeState["n"].to_list())
226
+
227
+ types: dict[str, str] = {}
228
+ for n in nodes:
229
+ if n in state_nodes:
230
+ types[n] = "storage"
231
+ else:
232
+ types[n] = "balance"
233
+ return types
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Main entry point
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ def read_sets(
242
+ flex_data: "FlexData",
243
+ solution: "Solution",
244
+ *,
245
+ solve_name: str = "solve",
246
+ ) -> SimpleNamespace:
247
+ """Translate ``FlexData`` set frames into the legacy ``s`` namespace.
248
+
249
+ Parameters
250
+ ----------
251
+ flex_data : FlexData
252
+ The polars input bundle the LP was built from.
253
+ solution : polar_high.Solution
254
+ Currently unused — kept for signature symmetry with
255
+ :func:`read_parameters` and to allow future post-solve sets.
256
+ solve_name : str, optional
257
+ The active solve identifier; injected as the leading level on
258
+ sets whose legacy layout was ``(solve, period, …)``.
259
+
260
+ Returns
261
+ -------
262
+ SimpleNamespace
263
+ Sets as :class:`pd.Index` / :class:`pd.MultiIndex` /
264
+ :class:`pd.Series` matching the legacy CSV-path signature.
265
+ """
266
+ s = SimpleNamespace()
267
+
268
+ # ─── Process / entity sets ────────────────────────────────────────────
269
+ # entity = nodes ∪ processes
270
+ nodes = []
271
+ if (flex_data.nodeBalance is not None
272
+ and flex_data.nodeBalance.height > 0):
273
+ nodes = flex_data.nodeBalance["n"].to_list()
274
+
275
+ processes = []
276
+ if (flex_data.process_source_sink is not None
277
+ and flex_data.process_source_sink.height > 0):
278
+ processes = (flex_data.process_source_sink.select("p")
279
+ .unique().to_pandas()["p"].tolist())
280
+
281
+ s.entity = pd.Index(nodes + processes, name="entity")
282
+ s.node = pd.Index(nodes, name="node")
283
+ s.process = pd.Index(processes, name="process")
284
+
285
+ # entityInvest / entityDivest — entities with a non-empty invest/divest set.
286
+ invest_entities = []
287
+ if (flex_data.ed_invest_set is not None
288
+ and flex_data.ed_invest_set.height > 0):
289
+ invest_entities = (flex_data.ed_invest_set.select("e")
290
+ .unique().to_pandas()["e"].tolist())
291
+ s.entityInvest = pd.Index(invest_entities, name="entity")
292
+
293
+ divest_entities = []
294
+ if (flex_data.ed_divest_set is not None
295
+ and flex_data.ed_divest_set.height > 0):
296
+ divest_entities = (flex_data.ed_divest_set.select("e")
297
+ .unique().to_pandas()["e"].tolist())
298
+ s.entityDivest = pd.Index(divest_entities, name="entity")
299
+
300
+ # process_unit / process_connection — partition of processes.
301
+ unit_set = set()
302
+ if (flex_data.process_unit is not None
303
+ and flex_data.process_unit.height > 0):
304
+ unit_set = set(flex_data.process_unit["p"].to_list())
305
+ s.process_unit = pd.Index(sorted(unit_set), name="process")
306
+ s.process_connection = pd.Index(
307
+ [p for p in processes if p not in unit_set],
308
+ name="process",
309
+ )
310
+
311
+ # process_profile — processes with a profile method (any of the
312
+ # profile_upper / lower / fixed sets).
313
+ profile_processes = set()
314
+ for fld in (flex_data.process_profile_upper,
315
+ flex_data.process_profile_lower,
316
+ flex_data.process_profile_fixed):
317
+ if fld is not None and fld.height > 0 and "p" in fld.columns:
318
+ profile_processes.update(fld["p"].to_list())
319
+ s.process_profile = pd.Index(sorted(profile_processes), name="process")
320
+
321
+ # process_online / process_online_integer / process_online_linear —
322
+ # processes with unit commitment.
323
+ s.process_online = _index_or_empty(
324
+ flex_data.process_online, dim="p", name="process",
325
+ )
326
+ s.process_online_integer = _index_or_empty(
327
+ flex_data.process_online_integer, dim="p", name="process",
328
+ )
329
+ s.process_online_linear = _index_or_empty(
330
+ flex_data.process_online_linear, dim="p", name="process",
331
+ )
332
+
333
+ # process_VRE — processes whose source is via a profile_upper
334
+ # method on the source side. Flextool emits this set distinctly;
335
+ # in FlexData it's encoded via process_profile_upper rows where
336
+ # source ∉ sink (i.e., upstream profile drives the flow). We
337
+ # leave this as an approximation: union of profile-upper processes
338
+ # constrained to (process, node) pairs.
339
+ if (flex_data.process_profile_upper is not None
340
+ and flex_data.process_profile_upper.height > 0):
341
+ # process_VRE: (process, node) pairs where the process has a
342
+ # profile_upper on its sink (output) arc. ``calc_storage_vre``
343
+ # filters via ``process_VRE.isin(process_sink)`` and indexes
344
+ # flow_dt / potentialVREgen_dt columns by (process, sink) — so
345
+ # the node
346
+ # half must be the OUTPUT side (sink), matching the convention
347
+ # used by ``process__node__profile__profile_method`` below.
348
+ cols = flex_data.process_profile_upper.columns
349
+ if "sink" in cols:
350
+ ent_dim = "sink"
351
+ elif "source" in cols:
352
+ ent_dim = "source"
353
+ else:
354
+ ent_dim = cols[1] if len(cols) > 1 else cols[0]
355
+ pdf = (flex_data.process_profile_upper.select("p", ent_dim)
356
+ .unique().to_pandas())
357
+ s.process_VRE = pd.MultiIndex.from_frame(
358
+ pdf, names=["process", "node"],
359
+ )
360
+ else:
361
+ s.process_VRE = empty_multi_index(["process", "node"])
362
+
363
+ # process_source_sink: (process, source, sink) tuples.
364
+ s.process_source_sink = _multi_index_or_empty(
365
+ flex_data.process_source_sink, dims=("p", "source", "sink"),
366
+ names=["process", "source", "sink"],
367
+ )
368
+
369
+ # process_method_sources_sinks — derived (method/orig_source/orig_sink/
370
+ # always_source/always_sink). FlexData doesn't carry these explicit
371
+ # fields, so we run a 3-way join.
372
+ #
373
+ # The ``always_*`` form keeps the process as one endpoint for every
374
+ # arc (so each direct-method unit has one source-side row pointing
375
+ # to ``(source → p)`` and one sink-side row pointing to ``(p → sink)``).
376
+ # The downstream slope branch in calc_capacity_flows expects this
377
+ # split — without it, the source-side fuel-flow column is missing and
378
+ # commodity costs are computed against the output-side flow (the
379
+ # e89bf53e regression).
380
+ #
381
+ # Method label: any value in ``s.method_1var_per_way`` (defined below)
382
+ # triggers the slope multiplier in calc_capacity_flows. Connections
383
+ # are 1var-per-way by default in flextool; indirect units have already
384
+ # been split into ``(p, source, p)`` / ``(p, p, sink)`` rows by
385
+ # ``process_source_sink_canonical`` upstream, so always=orig is correct
386
+ # for those rows.
387
+ if (flex_data.process_source_sink is not None
388
+ and flex_data.process_source_sink.height > 0):
389
+ pss_pdf = flex_data.process_source_sink.select(
390
+ "p", "source", "sink",
391
+ ).to_pandas()
392
+
393
+ rows: list[tuple[str, str, str, str, str, str]] = []
394
+ for p, src, snk in zip(
395
+ pss_pdf["p"], pss_pdf["source"], pss_pdf["sink"],
396
+ ):
397
+ if src == p or snk == p:
398
+ # Indirect / noConversion form — already split, always=orig.
399
+ rows.append((p, "method_1way_1var_LP", src, snk, src, snk))
400
+ else:
401
+ # Direct-method arc — emit BOTH always-process variants so
402
+ # downstream consumers get a source-side and a sink-side
403
+ # column in r.flow_dt (with slope applied on the source
404
+ # side). See process_arc_unions write_process_arc_unions
405
+ # at L248-263 for the legacy equivalent.
406
+ rows.append((p, "method_1way_1var_LP", src, snk, src, p))
407
+ rows.append((p, "method_1way_1var_LP", src, snk, p, snk))
408
+ rows = list(dict.fromkeys(rows))
409
+ cols = ["process", "method", "orig_source", "orig_sink",
410
+ "always_source", "always_sink"]
411
+ s.process_method_sources_sinks = pd.MultiIndex.from_frame(
412
+ pd.DataFrame(rows, columns=cols),
413
+ names=cols,
414
+ )
415
+ else:
416
+ s.process_method_sources_sinks = empty_multi_index(
417
+ ["process", "method", "orig_source", "orig_sink",
418
+ "always_source", "always_sink"],
419
+ )
420
+
421
+ # process_method — variable-style classification (1var_LP, 2var_LP,
422
+ # …). Currently approximated as ``method_1way_1var_LP`` for every
423
+ # process that appears in ``process_source_sink``; this is the
424
+ # legacy seed the rest of the reader has always carried.
425
+ if (flex_data.process_source_sink is not None
426
+ and flex_data.process_source_sink.height > 0):
427
+ pdf = flex_data.process_source_sink.select("p").unique().to_pandas()
428
+ pdf["method"] = "method_1way_1var_LP"
429
+ # Tag method_2way_1var_off processes correctly (the .mod's
430
+ # single-signed-flow 2-way method). ``calc_connections`` keys on
431
+ # this label to KEEP the sign of the flow (positive = node_1→node_2,
432
+ # negative = the reverse) instead of clipping it at 0 like a 2-var
433
+ # connection. Without this, a forced reverse flow would be clipped
434
+ # away. ``process_source_sink_2way_1var`` is the engine-side set of
435
+ # those arcs (DC + non-DC).
436
+ twoway1var = getattr(flex_data, "process_source_sink_2way_1var", None)
437
+ if twoway1var is not None and twoway1var.height > 0:
438
+ tw_procs = set(twoway1var.select("p").unique().to_pandas()["p"])
439
+ pdf.loc[pdf["p"].isin(tw_procs), "method"] = \
440
+ "method_2way_1var_off"
441
+ s.process_method = pd.MultiIndex.from_frame(
442
+ pdf, names=["process", "method"],
443
+ )
444
+ else:
445
+ s.process_method = empty_multi_index(["process", "method"])
446
+
447
+ # process__ct_method — conversion-time classification, orthogonal
448
+ # to ``process_method``. Values that the downstream calculators
449
+ # check for include ``"min_load_efficiency"``,
450
+ # ``"constant_efficiency"``, ``"min_load"`` etc. The only field
451
+ # FlexData carries on the conversion-time axis is
452
+ # ``process_min_load_eff`` (the subset of processes with
453
+ # ct_method=min_load_efficiency); we materialise those rows so the
454
+ # membership check ``(p, 'min_load_efficiency') in
455
+ # s.process__ct_method`` in calc_capacity_flows resolves to True
456
+ # for the right processes. Processes that fall outside that
457
+ # subset simply don't appear in the index — which is correct: no
458
+ # downstream consumer keys on any other ct_method value via this
459
+ # set.
460
+ pmle = getattr(flex_data, "process_min_load_eff", None)
461
+ if pmle is not None and pmle.height > 0 and "p" in pmle.columns:
462
+ pdf = pmle.select("p").unique().to_pandas()
463
+ pdf["method"] = "min_load_efficiency"
464
+ s.process__ct_method = pd.MultiIndex.from_frame(
465
+ pdf, names=["process", "method"],
466
+ )
467
+ else:
468
+ s.process__ct_method = empty_multi_index(["process", "method"])
469
+
470
+ # method_1var_per_way / method_nvar — flextool's known-method names.
471
+ s.method_1var_per_way = pd.Index(
472
+ ["method_1way_1var_off", "method_1way_1var_LP",
473
+ "method_1way_1var_MIP", "method_2way_1var_off",
474
+ "method_2way_1var_LP", "method_2way_1var_MIP"],
475
+ name="method",
476
+ )
477
+ s.method_nvar = pd.Index(
478
+ ["method_1way_nvar_off", "method_1way_nvar_LP",
479
+ "method_1way_nvar_MIP", "method_2way_nvar_off",
480
+ "method_2way_nvar_LP", "method_2way_nvar_MIP"],
481
+ name="method",
482
+ )
483
+
484
+ # ─── Time-related sets (per-solve) ────────────────────────────────────
485
+ # period — (solve, period). Derived from dt's distinct d values.
486
+ if flex_data.dt is not None and flex_data.dt.height > 0:
487
+ periods_pl = flex_data.dt.select("d").unique()
488
+ period_list = periods_pl.to_pandas()["d"].tolist()
489
+ s.period = pd.MultiIndex.from_arrays(
490
+ [[solve_name] * len(period_list), period_list],
491
+ names=["solve", "period"],
492
+ )
493
+ s.dt = pd.MultiIndex.from_frame(
494
+ flex_data.dt.with_columns(pl.lit(solve_name).alias("solve"))
495
+ .select("solve", "d", "t").to_pandas(),
496
+ names=["solve", "period", "time"],
497
+ )
498
+ else:
499
+ s.period = empty_multi_index(["solve", "period"])
500
+ s.dt = empty_multi_index(["solve", "period", "time"])
501
+
502
+ # d_realized_period / d_realize_invest / dt_realize_dispatch /
503
+ # d_realize_dispatch_or_invest — these are subsets of period / dt.
504
+ # Prefer the explicit ``flex_data.realized_dispatch`` (period, step)
505
+ # frame when supplied — this restricts dispatch-time CSVs to the
506
+ # realized window and excludes foresight rows from rolling solves.
507
+ # Falls back to ``period`` / ``dt`` when no realized_dispatch is
508
+ # available (single-period fixtures where every period is realized).
509
+ if (flex_data.realized_dispatch is not None
510
+ and flex_data.realized_dispatch.height > 0):
511
+ rd_pl = flex_data.realized_dispatch
512
+ s.dt_realize_dispatch = pd.MultiIndex.from_frame(
513
+ rd_pl.with_columns(pl.lit(solve_name).alias("solve"))
514
+ .select("solve", "period", "step").to_pandas(),
515
+ names=["solve", "period", "time"],
516
+ )
517
+ # Preserve the canonical period order from ``flex_data.dt`` —
518
+ # ``realized_dispatch`` uniqueness can reorder periods, which
519
+ # breaks period-indexed CSV row order (e.g. ``unit_capacity__d``).
520
+ rd_periods_unordered = set(
521
+ rd_pl.select("period").unique().to_pandas()["period"].tolist()
522
+ )
523
+ if flex_data.dt is not None and flex_data.dt.height > 0:
524
+ dt_period_order = (flex_data.dt.select("d")
525
+ .unique(maintain_order=True)
526
+ .to_pandas()["d"].tolist())
527
+ rd_periods = [p for p in dt_period_order if p in rd_periods_unordered]
528
+ else:
529
+ rd_periods = list(rd_periods_unordered)
530
+ s.d_realized_period = pd.MultiIndex.from_arrays(
531
+ [[solve_name] * len(rd_periods), rd_periods],
532
+ names=["solve", "period"],
533
+ )
534
+ else:
535
+ # Without explicit ``realized_dispatch`` we cannot tell which
536
+ # rows of ``dt`` are realized vs foresight. Emit empty sets so
537
+ # this solve contributes no realized rows to ``read_sets_multi``
538
+ # unions. Single-solve modern fixtures always populate
539
+ # ``flex_data.realized_dispatch``; legacy/empty cases will
540
+ # surface as empty output which is still cleaner than leaking
541
+ # the full per-solve ``dt`` (including foresight horizons).
542
+ s.d_realized_period = empty_multi_index(["solve", "period"])
543
+ s.dt_realize_dispatch = empty_multi_index(
544
+ ["solve", "period", "time"],
545
+ )
546
+ s.dt_fix_storage_timesteps = empty_multi_index(
547
+ ["solve", "period", "time"],
548
+ )
549
+
550
+ # d_realize_invest — periods where invest *or divest* decisions are
551
+ # realized. Use the union of ed_invest_set and ed_divest_set "d"
552
+ # columns: scenarios that only divest (e.g. ``coal_retire``) have
553
+ # empty ed_invest_set but non-empty ed_divest_set, and we still need
554
+ # those periods in d_realize_invest so the drop_levels inner-join
555
+ # against ed_divest doesn't collapse to empty (which was leaving
556
+ # ``divested`` as NaN in ``unit_capacity__d.csv``).
557
+ #
558
+ # Intersect with ``realized_dispatch`` periods: ed_invest_set covers
559
+ # CANDIDATE (e, d) pairs which include lookahead periods (e.g.
560
+ # test_a_lot_but_not_multi_year has p2020 realized + p2025 lookahead;
561
+ # ed_invest_set spans both, but only p2020 is a REALIZED invest
562
+ # period). Without this filter, ``unit_capacity__d`` emitted both
563
+ # p2020 and p2025 rows instead of just p2020.
564
+ invest_periods_list: list = []
565
+ seen: set = set()
566
+ realized_period_set: set = set(
567
+ s.d_realized_period.get_level_values("period").tolist()
568
+ ) if len(s.d_realized_period) > 0 else set()
569
+ for src in (flex_data.ed_invest_set, flex_data.ed_divest_set):
570
+ if (src is not None and src.height > 0
571
+ and "d" in src.columns):
572
+ for d in src.select("d").unique().to_pandas()["d"].tolist():
573
+ if d in seen:
574
+ continue
575
+ if realized_period_set and d not in realized_period_set:
576
+ continue
577
+ seen.add(d)
578
+ invest_periods_list.append(d)
579
+ invest_periods = invest_periods_list
580
+ s.d_realize_invest = pd.MultiIndex.from_arrays(
581
+ [[solve_name] * len(invest_periods), invest_periods],
582
+ names=["solve", "period"],
583
+ )
584
+ # d_realize_dispatch_or_invest = d_realized_period ∪ d_realize_invest.
585
+ # Use realized-dispatch periods (not all dt periods) so foresight
586
+ # periods don't leak into invest/period-aggregate outputs. Reuse
587
+ # the canonically ordered ``d_realized_period`` we just built.
588
+ realized_periods: list = list(
589
+ s.d_realized_period.get_level_values("period")
590
+ ) if len(s.d_realized_period) > 0 else []
591
+ union_periods = list(dict.fromkeys(realized_periods + invest_periods))
592
+ s.d_realize_dispatch_or_invest = pd.MultiIndex.from_arrays(
593
+ [[solve_name] * len(union_periods), union_periods],
594
+ names=["solve", "period"],
595
+ )
596
+
597
+ # ed_invest / ed_divest / edd_invest — (entity, period) /
598
+ # (entity, period_invest, period).
599
+ s.ed_invest = _multi_index_with_solve(
600
+ flex_data.ed_invest_set, solve_name=solve_name,
601
+ dims=("e", "d"), names=["entity", "period"],
602
+ )
603
+ s.ed_divest = _multi_index_with_solve(
604
+ flex_data.ed_divest_set, solve_name=solve_name,
605
+ dims=("e", "d"), names=["entity", "period"],
606
+ )
607
+
608
+ # edd_invest may have either (e, d_invest, d) or (e, d) dims.
609
+ if (flex_data.edd_invest_set is not None
610
+ and flex_data.edd_invest_set.height > 0):
611
+ cols = flex_data.edd_invest_set.columns
612
+ if len(cols) >= 3:
613
+ pdf = flex_data.edd_invest_set.with_columns(
614
+ pl.lit(solve_name).alias("solve")
615
+ ).select("solve", cols[0], cols[1], cols[2]).to_pandas()
616
+ s.edd_invest = pd.MultiIndex.from_frame(
617
+ pdf, names=["solve", "entity", "period_invest", "period"],
618
+ )
619
+ else:
620
+ s.edd_invest = empty_multi_index(
621
+ ["solve", "entity", "period_invest", "period"]
622
+ )
623
+ else:
624
+ s.edd_invest = empty_multi_index(
625
+ ["solve", "entity", "period_invest", "period"]
626
+ )
627
+
628
+ # process__node__profile__profile_method — DataFrame from FlexData
629
+ # process_profile_upper / lower / fixed.
630
+ pieces: list[pd.DataFrame] = []
631
+ for fld, method in (
632
+ (flex_data.process_profile_upper, "upper_limit"),
633
+ (flex_data.process_profile_lower, "lower_limit"),
634
+ (flex_data.process_profile_fixed, "equality"),
635
+ ):
636
+ if fld is not None and fld.height > 0:
637
+ pdf = fld.to_pandas().copy()
638
+ cols = pdf.columns.tolist()
639
+ # canonical: (p, source, sink, f) → (process, node, profile, method)
640
+ if "f" in cols:
641
+ pdf = pdf.rename(columns={"p": "process", "f": "profile"})
642
+ # node = sink (output) by convention
643
+ if "sink" in pdf.columns:
644
+ pdf["node"] = pdf["sink"]
645
+ elif "source" in pdf.columns:
646
+ pdf["node"] = pdf["source"]
647
+ else:
648
+ pdf["node"] = ""
649
+ pdf["profile_method"] = method
650
+ pieces.append(pdf[["process", "node", "profile", "profile_method"]])
651
+ if pieces:
652
+ all_df = pd.concat(pieces, ignore_index=True)
653
+ s.process__node__profile__profile_method = pd.MultiIndex.from_frame(
654
+ all_df,
655
+ names=["process", "node", "profile", "profile_method"],
656
+ )
657
+ else:
658
+ s.process__node__profile__profile_method = empty_multi_index(
659
+ ["process", "node", "profile", "profile_method"]
660
+ )
661
+
662
+ # ─── Time helpers ─────────────────────────────────────────────────────
663
+ if (flex_data.dtttdt is not None and flex_data.dtttdt.height > 0):
664
+ cols = flex_data.dtttdt.columns
665
+ # Expected: (d, t, t_previous, t_previous_within_timeset, d_previous, t_previous_within_solve)
666
+ pdf = flex_data.dtttdt.with_columns(
667
+ pl.lit(solve_name).alias("solve")
668
+ ).to_pandas()
669
+ # Map flex names → legacy names
670
+ rename = {"d": "period", "t": "time"}
671
+ pdf = pdf.rename(columns=rename)
672
+ # Order: solve, period, time, t_previous, t_previous_within_timeset,
673
+ # d_previous, t_previous_within_solve
674
+ legacy_names = ["solve", "period", "time", "t_previous",
675
+ "t_previous_within_timeset", "d_previous",
676
+ "t_previous_within_solve"]
677
+ # Only keep columns we have
678
+ use_cols = ["solve", "period", "time"] + [
679
+ c for c in legacy_names[3:] if c in pdf.columns
680
+ ]
681
+ s.dtttdt = pd.MultiIndex.from_frame(
682
+ pdf[use_cols], names=use_cols,
683
+ )
684
+ else:
685
+ s.dtttdt = empty_multi_index(
686
+ ["solve", "period", "time", "t_previous"]
687
+ )
688
+
689
+ # dtt — typically (d, t, t_previous). Derive from dtttdt if available.
690
+ # Sort by (solve, period, time, t_previous) so the resulting
691
+ # MultiIndex follows the canonical iteration order — without
692
+ # this, downstream droplevel('t_previous') retains polars'
693
+ # ``unique()`` hash-order and writers like ``unit_ramps`` emit time
694
+ # columns out of order (e.g. coal_ramp_limit).
695
+ if (flex_data.dtttdt is not None and flex_data.dtttdt.height > 0):
696
+ pdf = (flex_data.dtttdt.with_columns(
697
+ pl.lit(solve_name).alias("solve"),
698
+ )
699
+ .select("solve", "d", "t", "t_previous")
700
+ .unique()
701
+ .sort(["solve", "d", "t", "t_previous"])
702
+ .to_pandas())
703
+ s.dtt = pd.MultiIndex.from_frame(
704
+ pdf, names=["solve", "period", "time", "t_previous"],
705
+ )
706
+ else:
707
+ s.dtt = empty_multi_index(
708
+ ["solve", "period", "time", "t_previous"]
709
+ )
710
+
711
+ s.period__time_first = empty_multi_index(["solve", "period", "time"])
712
+ s.period_first_of_solve = empty_multi_index(["solve", "period"])
713
+ s.period_in_use = (
714
+ _multi_index_with_solve(
715
+ flex_data.period_in_use_set, solve_name=solve_name,
716
+ dims=("d",), names=["period"],
717
+ )
718
+ if flex_data.period_in_use_set is not None
719
+ else s.period
720
+ )
721
+
722
+ # ─── Node-related sets ────────────────────────────────────────────────
723
+ node_types = _node_types(flex_data)
724
+ s.node_state = pd.Index(
725
+ [n for n, t in node_types.items() if t == "storage"], name="node",
726
+ )
727
+ s.node_balance = pd.Index(
728
+ [n for n, t in node_types.items() if t in ("balance", "storage")],
729
+ name="node",
730
+ )
731
+ s.node_balance_period = pd.Index(
732
+ [n for n, t in node_types.items() if t == "balance_within_period"],
733
+ name="node",
734
+ )
735
+ s.node_commodity = pd.Index([], name="node", dtype="object")
736
+ if (flex_data.flow_to_commodity is not None
737
+ and flex_data.flow_to_commodity.height > 0):
738
+ # flow_to_commodity rows imply the sink node is a commodity node
739
+ cols = flex_data.flow_to_commodity.columns
740
+ sink_col = "sink" if "sink" in cols else None
741
+ if sink_col is not None:
742
+ s.node_commodity = pd.Index(
743
+ flex_data.flow_to_commodity.select(sink_col).unique()
744
+ .to_pandas()[sink_col].tolist(),
745
+ name="node",
746
+ )
747
+
748
+ # node_self_discharge — nodes with non-zero self-discharge.
749
+ if (flex_data.p_state_self_discharge is not None
750
+ and flex_data.p_state_self_discharge.frame.height > 0):
751
+ s.node_self_discharge = pd.Index(
752
+ (flex_data.p_state_self_discharge.frame
753
+ .filter(pl.col("value") != 0.0)
754
+ .select("n").unique().to_pandas()["n"].tolist()),
755
+ name="node",
756
+ )
757
+ else:
758
+ s.node_self_discharge = pd.Index([], name="node", dtype="object")
759
+
760
+ # node__storage_binding_method — (node, method).
761
+ #
762
+ # Walk every storage_bind_* projection exposed by FlexData. The four
763
+ # frames below mirror the entries in ``_projection_params.py:1858-1861``
764
+ # and the FlexData declarations in ``input.py:579-582``. Pre-fix this
765
+ # block walked only ``storage_bind_within_solve`` and
766
+ # ``storage_bind_forward_only`` (audit §5.2), which silently dropped the
767
+ # ``bind_within_timeblock`` and ``bind_within_solve_blended_weights`` methods and
768
+ # broke ``calc_storage_vre.py`` for nodes carrying them.
769
+ #
770
+ # Orthogonal gap (per audit §4): the v54 value list also accepts
771
+ # ``bind_within_period`` and ``bind_intraperiod_blocks``, but
772
+ # ``nodeBalance_eq`` (model.py:615) has no constraint branch for either,
773
+ # and FlexData carries no projection attribute for them. Those methods
774
+ # are accepted at ingestion but currently produce no constraint term —
775
+ # tracked separately, not in scope here.
776
+ binding_pieces: list[tuple[str, str]] = []
777
+ bind_projections: tuple[tuple[str, str], ...] = (
778
+ ("storage_bind_within_solve", "bind_within_solve"),
779
+ ("storage_bind_forward_only", "bind_forward_only"),
780
+ ("storage_bind_within_timeblock", "bind_within_timeblock"),
781
+ ("storage_bind_within_solve_blended_weights", "bind_within_solve_blended_weights"),
782
+ # Phase D — added with the constraint wiring.
783
+ ("storage_bind_forward_only_blended_weights",
784
+ "bind_forward_only_blended_weights"),
785
+ # Phase E — per-period cyclic-closure variant.
786
+ ("storage_bind_within_period_blended_weights",
787
+ "bind_within_period_blended_weights"),
788
+ )
789
+ for attr_name, method_string in bind_projections:
790
+ frame = getattr(flex_data, attr_name, None)
791
+ if frame is not None and frame.height > 0:
792
+ for n in frame["n"].to_list():
793
+ binding_pieces.append((n, method_string))
794
+ if binding_pieces:
795
+ s.node__storage_binding_method = pd.MultiIndex.from_tuples(
796
+ binding_pieces, names=["node", "method"],
797
+ )
798
+ else:
799
+ s.node__storage_binding_method = empty_multi_index(["node", "method"])
800
+
801
+ s.node__storage_start_end_method = empty_multi_index(["node", "method"])
802
+ s.node__inflow_method = empty_multi_index(["node", "method"])
803
+ s.node__storage_nested_fix_method = empty_multi_index(["node", "method"])
804
+
805
+ # process_source / process_sink — (process, source) / (process, sink).
806
+ #
807
+ # Must contain exactly one canonical (process, node) per input/output arc:
808
+ # • units : unit__inputNode nodes for source, unit__outputNode for sink
809
+ # • connections : node_1 of connection__node__node for source, node_2 for sink
810
+ #
811
+ # The collapsed process_source_sink includes reverse arcs for 2-way
812
+ # connections AND intermediate (p, p) arcs for indirect units, so it
813
+ # cannot be projected directly. Use the pre-computed canonical fields
814
+ # (populated in input.py from the raw entity tables) when available; fall
815
+ # back to a pss-based derivation that filters out process-as-node arcs.
816
+ if (flex_data.process_source_canonical is not None
817
+ and flex_data.process_source_canonical.height > 0):
818
+ s.process_source = pd.MultiIndex.from_frame(
819
+ flex_data.process_source_canonical.to_pandas(),
820
+ names=["process", "source"],
821
+ )
822
+ elif (flex_data.process_source_sink is not None
823
+ and flex_data.process_source_sink.height > 0):
824
+ pss_pl = flex_data.process_source_sink
825
+ src_df = (pss_pl.filter(pl.col("source") != pl.col("p"))
826
+ .select("p", "source").unique().to_pandas())
827
+ s.process_source = pd.MultiIndex.from_frame(src_df, names=["process", "source"])
828
+ else:
829
+ s.process_source = empty_multi_index(["process", "source"])
830
+
831
+ if (flex_data.process_sink_canonical is not None
832
+ and flex_data.process_sink_canonical.height > 0):
833
+ s.process_sink = pd.MultiIndex.from_frame(
834
+ flex_data.process_sink_canonical.to_pandas(),
835
+ names=["process", "sink"],
836
+ )
837
+ elif (flex_data.process_source_sink is not None
838
+ and flex_data.process_source_sink.height > 0):
839
+ pss_pl = flex_data.process_source_sink
840
+ snk_df = (pss_pl.filter(pl.col("sink") != pl.col("p"))
841
+ .select("p", "sink").unique().to_pandas())
842
+ s.process_sink = pd.MultiIndex.from_frame(snk_df, names=["process", "sink"])
843
+ else:
844
+ s.process_sink = empty_multi_index(["process", "sink"])
845
+
846
+ # process__source__sink__profile__profile_method — DataFrame for VRE etc.
847
+ s.process__source__sink__profile__profile_method = pd.DataFrame(
848
+ columns=["process", "source", "sink", "profile", "profile_method"],
849
+ )
850
+
851
+ # ─── Commodity-related sets ───────────────────────────────────────────
852
+ # Recover the four sets that the legacy CSV path obtained from
853
+ # ``solve_data/commodity_node.csv`` / ``commodity_node_co2.csv`` /
854
+ # ``process__commodity__node.csv`` / ``process__commodity__node_co2.csv``
855
+ # by deriving from the FlexData flow_* frames the model already builds.
856
+ # Each flow_* frame is a join of (p, source, sink) arcs onto either
857
+ # ``commodity__node.csv`` (commodity-priced) or a CO2-priced/capped
858
+ # (g, c, n) projection, so the union of source/sink-side commodity
859
+ # nodes across the frames covers exactly the (c, n) pairs the LP
860
+ # objective and downstream cost calculation reference.
861
+ # Regression introduced by e89bf53e (Δ.31 piece 2, in-memory readers).
862
+ s.commodity_node = _commodity_node_from_flex(
863
+ flex_data,
864
+ attrs=(
865
+ ("flow_from_commodity_eff", "source"),
866
+ ("flow_from_commodity_noEff", "source"),
867
+ ("flow_to_commodity", "sink"),
868
+ ),
869
+ )
870
+ s.commodity_node_co2 = _commodity_node_from_flex(
871
+ flex_data,
872
+ attrs=(
873
+ ("flow_from_co2_priced", "source"),
874
+ ("flow_from_co2_priced_noEff", "source"),
875
+ ("flow_from_co2_capped", "source"),
876
+ ("flow_from_co2_capped_noEff", "source"),
877
+ ),
878
+ )
879
+ s.process__commodity__node = _process_commodity_node_from_flex(
880
+ flex_data,
881
+ attrs=(
882
+ ("flow_from_commodity_eff", "source"),
883
+ ("flow_from_commodity_noEff", "source"),
884
+ ("flow_to_commodity", "sink"),
885
+ ),
886
+ )
887
+ s.process__commodity__node_co2 = _process_commodity_node_from_flex(
888
+ flex_data, attrs=_CO2_EMISSION_FLOW_ATTRS,
889
+ )
890
+ s.group_co2_price = pd.Index([], name="group", dtype="object")
891
+ s.group_co2_limit = pd.Index([], name="group", dtype="object")
892
+ if (flex_data.p_co2_price is not None
893
+ and flex_data.p_co2_price.frame.height > 0):
894
+ s.group_co2_price = pd.Index(
895
+ flex_data.p_co2_price.frame.select("g").unique()
896
+ .to_pandas()["g"].tolist(),
897
+ name="group",
898
+ )
899
+ # Groups limited by either the per-period cap (``co2_max_period``) or the
900
+ # multi-period total cap (``co2_max_total``). Both feed the group-level
901
+ # CO2 attribution; omitting the total cap dropped ``co2_method = total``
902
+ # groups from the group CO2 reports.
903
+ _limit_groups: list = []
904
+ for _cap in (flex_data.group_co2_max_period, flex_data.group_co2_max_total):
905
+ if _cap is not None and _cap.height > 0:
906
+ _gcol = _cap.columns[0]
907
+ _limit_groups += (_cap.select(_gcol).unique()
908
+ .to_pandas()[_gcol].tolist())
909
+ if _limit_groups:
910
+ s.group_co2_limit = pd.Index(
911
+ list(dict.fromkeys(_limit_groups)), name="group")
912
+
913
+ # ─── Group-related sets ───────────────────────────────────────────────
914
+ s.groupInertia = _index_or_empty(flex_data.groupInertia, dim="g", name="group")
915
+ s.groupNonSync = _index_or_empty(flex_data.groupNonSync, dim="g", name="group")
916
+ s.groupCapacityMargin = _index_or_empty(
917
+ flex_data.groupCapacityMargin, dim="g", name="group",
918
+ )
919
+
920
+ # Aggregator / dispatch group sets — empty by default; populated
921
+ # when nodeGroupDispatch features are configured. No FlexData
922
+ # field carries these directly today.
923
+ s.nodeGroupDispatch = pd.Index([], name="group", dtype="object")
924
+ s.nodeGroupIndicators = pd.Index([], name="group", dtype="object")
925
+ s.flowGroupIndicators = pd.Index([], name="group", dtype="object")
926
+ s.nodeGroupDispatch__connection_Not_in_aggregate = empty_multi_index(
927
+ ["group", "connection"]
928
+ )
929
+ s.nodeGroupDispatch__process__unit__to_node_Not_in_aggregate = empty_multi_index(
930
+ ["group", "process", "unit", "node"]
931
+ )
932
+ s.nodeGroupDispatch__process__node__to_unit_Not_in_aggregate = empty_multi_index(
933
+ ["group", "process", "node", "unit"]
934
+ )
935
+ s.nodeGroupDispatch__process__connection__to_node_Not_in_aggregate = empty_multi_index(
936
+ ["group", "process", "connection", "node"]
937
+ )
938
+ s.nodeGroupDispatch__process__node__to_connection_Not_in_aggregate = empty_multi_index(
939
+ ["group", "process", "node", "connection"]
940
+ )
941
+ s.nodeGroupDispatch__processGroup_Unit_to_group = empty_multi_index(
942
+ ["group", "group_aggregate"]
943
+ )
944
+ s.nodeGroupDispatch__processGroup__process__unit__to_node = empty_multi_index(
945
+ ["group", "group_aggregate", "process", "unit", "node"]
946
+ )
947
+ s.nodeGroupDispatch__processGroup_Group_to_unit = empty_multi_index(
948
+ ["group", "group_aggregate"]
949
+ )
950
+ s.nodeGroupDispatch__processGroup__process__node__to_unit = empty_multi_index(
951
+ ["group", "group_aggregate", "process", "node", "unit"]
952
+ )
953
+ s.nodeGroupDispatch__processGroup_Connection = empty_multi_index(
954
+ ["group", "group_aggregate"]
955
+ )
956
+ s.nodeGroupDispatch__processGroup__process__connection__to_node = empty_multi_index(
957
+ ["group", "group_aggregate", "process", "connection", "node"]
958
+ )
959
+ s.nodeGroupDispatch__processGroup__process__node__to_connection = empty_multi_index(
960
+ ["group", "group_aggregate", "process", "node", "connection"]
961
+ )
962
+ s.nodeGroupDispatch__process_fully_inside = empty_multi_index(
963
+ ["group", "process"]
964
+ )
965
+
966
+ # group_node — (group, node) from group_node FlexData field.
967
+ if (flex_data.group_node is not None
968
+ and flex_data.group_node.height > 0):
969
+ cols = flex_data.group_node.columns
970
+ s.group_node = pd.MultiIndex.from_frame(
971
+ flex_data.group_node.to_pandas(),
972
+ names=[long_dim(c) for c in cols],
973
+ )
974
+ else:
975
+ s.group_node = empty_multi_index(["group", "node"])
976
+ s.group_process = empty_multi_index(["group", "process"])
977
+ s.group_process_node = empty_multi_index(["group", "process", "node"])
978
+
979
+ # upDown — flextool's known up/down set.
980
+ s.upDown = pd.Index(["up", "down"], name="updown")
981
+
982
+ # enable_optional_outputs — read from FlexData if available. Empty set OK.
983
+ s.enable_optional_outputs = set()
984
+
985
+ # ─── DC power flow sets ───────────────────────────────────────────────
986
+ s.node_dc_power_flow = _index_or_empty(
987
+ flex_data.node_dc_power_flow, dim="n", name="node",
988
+ )
989
+ s.connection_dc_power_flow = _index_or_empty(
990
+ flex_data.connection_dc_power_flow, dim="p", name="connection",
991
+ )
992
+ if (flex_data.p_connection_susceptance is not None
993
+ and flex_data.p_connection_susceptance.frame.height > 0):
994
+ df = flex_data.p_connection_susceptance.frame.to_pandas()
995
+ s.connection_susceptance = df.set_index("p")["value"].astype(float)
996
+ s.connection_susceptance.index.name = "connection"
997
+ else:
998
+ s.connection_susceptance = pd.Series(dtype=float)
999
+
1000
+ return s
1001
+
1002
+
1003
+ # ---------------------------------------------------------------------------
1004
+ # Multi-solve wrapper
1005
+ # ---------------------------------------------------------------------------
1006
+
1007
+
1008
+ def _multi_index_has_solve(obj) -> bool:
1009
+ if isinstance(obj, pd.MultiIndex):
1010
+ return "solve" in (obj.names or ())
1011
+ return False
1012
+
1013
+
1014
+ def _series_or_df_has_solve(obj) -> bool:
1015
+ if isinstance(obj, (pd.DataFrame, pd.Series)):
1016
+ idx = obj.index
1017
+ if isinstance(idx, pd.MultiIndex):
1018
+ return "solve" in (idx.names or ())
1019
+ return False
1020
+
1021
+
1022
+ def read_sets_multi(
1023
+ steps: "list[tuple[str, FlexData, Solution]] | list[tuple[str, FlexData]]",
1024
+ solution: "Solution | None" = None,
1025
+ ) -> SimpleNamespace:
1026
+ """Multi-solve variant of :func:`read_sets`.
1027
+
1028
+ See :func:`flextool.process_outputs.read_parameters.read_parameters_multi`
1029
+ for the rationale. Per-sub-solve ``(solve, period, …)``-keyed sets
1030
+ (``period``, ``dt``, ``d_realized_period``, ``dt_realize_dispatch``,
1031
+ ``d_realize_invest``, ``ed_invest``, ``dtttdt``, etc.) are unioned
1032
+ across sub-solves; static set attributes (``node``, ``process``,
1033
+ ``upDown``, …) are taken from the last step (invariant).
1034
+ """
1035
+ if not steps:
1036
+ raise ValueError("read_sets_multi: steps must be non-empty")
1037
+
1038
+ def _step_solution(s):
1039
+ if len(s) >= 3:
1040
+ return s[2]
1041
+ if solution is None:
1042
+ raise ValueError(
1043
+ "read_sets_multi: step is a 2-tuple but no fallback "
1044
+ "solution was supplied"
1045
+ )
1046
+ return solution
1047
+
1048
+ per_step = [
1049
+ (s[0], read_sets(s[1], _step_solution(s), solve_name=s[0]))
1050
+ for s in steps
1051
+ ]
1052
+ last_ns = per_step[-1][1]
1053
+ if len(per_step) == 1:
1054
+ return last_ns
1055
+
1056
+ out = SimpleNamespace()
1057
+ attr_names = list(vars(last_ns).keys())
1058
+ for attr in attr_names:
1059
+ pieces = [getattr(ns, attr) for _, ns in per_step]
1060
+ first = pieces[0]
1061
+ if isinstance(first, pd.MultiIndex):
1062
+ if _multi_index_has_solve(first):
1063
+ # Concat MultiIndex rows from each sub-solve. Non-empty
1064
+ # pieces only — empty MultiIndex with mismatching dtypes
1065
+ # can perturb the result's dtype.
1066
+ non_empty = [p for p in pieces if len(p) > 0]
1067
+ if not non_empty:
1068
+ setattr(out, attr, first)
1069
+ continue
1070
+ # Build per-level Python lists and reassemble (works
1071
+ # regardless of dtype quirks at the empty-frame
1072
+ # boundary).
1073
+ names = non_empty[0].names
1074
+ # All pieces share the same names by construction.
1075
+ level_lists = [[] for _ in names]
1076
+ for p in non_empty:
1077
+ for i in range(len(names)):
1078
+ level_lists[i].extend(list(p.get_level_values(i)))
1079
+ setattr(out, attr, pd.MultiIndex.from_arrays(
1080
+ level_lists, names=names,
1081
+ ))
1082
+ else:
1083
+ setattr(out, attr, getattr(last_ns, attr))
1084
+ elif _series_or_df_has_solve(first):
1085
+ non_empty = [p for p in pieces if len(p) > 0]
1086
+ if not non_empty:
1087
+ setattr(out, attr, pieces[-1])
1088
+ continue
1089
+ merged = pd.concat(non_empty, axis=0)
1090
+ if hasattr(pieces[-1], "columns") and hasattr(merged, "columns"):
1091
+ merged.columns.name = pieces[-1].columns.name
1092
+ setattr(out, attr, merged)
1093
+ else:
1094
+ setattr(out, attr, getattr(last_ns, attr))
1095
+ return out