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,412 @@
1
+ """Reconstruct the minimal engine ``s`` (sets) namespace needed by the
2
+ ``spinedb`` writer from PROCESSED ``output_parquet`` results alone.
3
+
4
+ This supports the parquet-replay path of the ``spinedb`` output write-method:
5
+ build the results SpineDB from a previously processed ``output_parquet``
6
+ bundle with NO re-solve (and without ``output_raw`` or the input DB).
7
+
8
+ ``write_spinedb`` consumes exactly five attributes of ``s`` (verified in
9
+ ``write_spinedb.py``):
10
+
11
+ * ``s.solve_period_time`` — ``pd.MultiIndex(['solve','period','time'])``,
12
+ read as a bare attribute in ``_ensure_solve_level`` (:737) to attach a
13
+ ``solve`` level to every ``_dt`` frame lacking one.
14
+ * ``s.solve_period`` — ``pd.MultiIndex(['solve','period'])``, read as a bare
15
+ attribute in ``_ensure_solve_level`` (:750-751) and ``_discount_factor_map``
16
+ (:864) to attach a ``solve`` level to ``_d`` frames.
17
+ * ``s.process_unit`` — ``pd.Index(name='process')``, via ``getattr`` in
18
+ ``_iter_columns`` (:560); the set of unit process names.
19
+ * ``s.process_connection`` — ``pd.Index(name='process')``, via ``getattr`` in
20
+ ``_iter_columns`` (:561) and ``_connection_triple_lookup`` (:782); the set
21
+ of connection process names.
22
+ * ``s.process_source_sink`` — ``pd.MultiIndex(['process','source','sink'])``,
23
+ iterated as ``(process, source, sink)`` tuples in
24
+ ``_connection_triple_lookup`` (:781) to form ``connection__node__node``
25
+ bynames.
26
+
27
+ All structures are rebuilt purely from the ``results`` dict (pure function,
28
+ no randomness) and sorted canonically for cross-process determinism.
29
+
30
+ The two discount-factor params (#33/#34) are NOT recoverable from the
31
+ processed bundle; on the replay path ``par`` is ``None`` and the writer
32
+ skips them with a debug log.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import logging
38
+ from types import SimpleNamespace
39
+
40
+ import pandas as pd
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ # Frames whose first column level (or, for ``_ed_p`` capacity frames, the
46
+ # entity index level) carries the unit / connection process name.
47
+ _UNIT_PREFIX = "unit_"
48
+ _CONNECTION_PREFIX = "connection_"
49
+
50
+ # Net-flow frames (column = bare connection name, value = signed net flow,
51
+ # positive = first→second node) used to detect bidirectional (2-way)
52
+ # connections on the replay path.
53
+ _NET_FLOW_KEYS = ("connection_dt_eee", "connection_d_eee")
54
+ # Net-flow magnitude below which a sign is treated as numerical noise (not a
55
+ # genuine reverse flow).
56
+ _FLOW_SIGN_EPS = 1e-9
57
+
58
+
59
+ def _frame_column_labels(df) -> list:
60
+ """Return the column labels of ``df`` as a flat list, transparently
61
+ handling the squeezed-``Series`` case.
62
+
63
+ ``read_lean_parquet`` (via ``write_outputs``) squeezes any frame whose
64
+ column axis is a single level / single column down to a ``pandas.Series``;
65
+ such a Series has NO ``.columns`` attribute but preserves the original
66
+ single column label in ``.name`` (a scalar for a 1-level frame, or a tuple
67
+ for a multi-level frame squeezed to one column). Scanning only
68
+ ``df.columns`` would therefore silently drop the process carried by such a
69
+ squeezed frame. This helper yields the label list for both shapes so the
70
+ process set / node map can never under-cover."""
71
+ cols = getattr(df, "columns", None)
72
+ if cols is not None:
73
+ return list(cols)
74
+ # Squeezed Series — the (only) column label survives in ``.name``.
75
+ name = getattr(df, "name", None)
76
+ if name is None:
77
+ return []
78
+ return [name]
79
+
80
+
81
+ def build_replay_s(results: dict) -> SimpleNamespace:
82
+ """Build the minimal ``s`` shim for ``write_spinedb`` from ``results``.
83
+
84
+ Parameters
85
+ ----------
86
+ results : dict[str, pandas.DataFrame | pandas.Series]
87
+ Processed results keyed by RAW engine table name, exactly as the
88
+ ``read_parquet_dir`` branch of ``write_outputs`` rebuilds them
89
+ (``scenario`` column level already stripped / single levels
90
+ squeezed).
91
+
92
+ Returns
93
+ -------
94
+ types.SimpleNamespace
95
+ Object exposing ``solve_period_time``, ``solve_period``,
96
+ ``process_unit``, ``process_connection`` and ``process_source_sink``
97
+ with the exact shapes ``write_spinedb`` expects, plus a
98
+ ``replay_solve_name`` convenience attribute (the canonical solve
99
+ label recovered from ``node_prices_dt_e``, or ``None`` when no solve
100
+ axis is recoverable) so the replay caller can pass the real solve
101
+ name to ``write_spinedb`` for the ``collapse_solve`` frames whose
102
+ single ``solve`` Map key is taken from ``solve_name``.
103
+ """
104
+ solve_period_time, solve_period = _build_solve_indexes(results)
105
+ process_unit = _collect_process_names(results, _UNIT_PREFIX)
106
+ process_connection = _collect_process_names(results, _CONNECTION_PREFIX)
107
+ process_source_sink = _build_process_source_sink(results)
108
+ replay_solve_name = _last_solve_name(solve_period)
109
+
110
+ return SimpleNamespace(
111
+ solve_period_time=solve_period_time,
112
+ solve_period=solve_period,
113
+ process_unit=process_unit,
114
+ process_connection=process_connection,
115
+ process_source_sink=process_source_sink,
116
+ replay_solve_name=replay_solve_name,
117
+ )
118
+
119
+
120
+ def _last_solve_name(solve_period: pd.MultiIndex):
121
+ """Return the last solve label in ``solve_period`` (creation order), or
122
+ ``None`` when the index is empty.
123
+
124
+ Frames written under ``collapse_solve`` (``costs_discounted_d_p``) carry
125
+ no per-row solve axis, so the writer wraps the whole frame under a single
126
+ ``solve_name`` key. On the native path that key is the final cascade
127
+ ``solve_name``; the final solve label in ``node_prices`` (= last creating
128
+ solve, on-disk row order) reproduces it, so the parquet path emits the
129
+ same ``solve`` Map key."""
130
+ if solve_period is None or len(solve_period) == 0:
131
+ return None
132
+ return str(solve_period.get_level_values("solve")[-1])
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Solve reconstruction from node_prices_dt_e
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def _build_solve_indexes(results: dict):
140
+ """Return ``(solve_period_time, solve_period)`` MultiIndexes from the
141
+ ``(solve, period, time)`` index of ``node_prices_dt_e``.
142
+
143
+ Native ``s.solve_period_time`` / ``s.solve_period`` are the realized
144
+ ``(solve, period, time)`` / ``(solve, period)`` index deduped on
145
+ ``(period, time)`` / ``period`` with ``keep='last'`` (the last creating
146
+ solve wins). ``node_prices_dt_e`` retains the same pre-dedup
147
+ ``(solve, period, time)`` index in solve-creation row order, so deduping
148
+ it ``keep='last'`` reproduces the native winner exactly.
149
+
150
+ When ``node_prices_dt_e`` is absent or empty, return empty MultiIndexes;
151
+ the writer then falls back to wrapping every frame under the single
152
+ ``solve_name`` key (``_series_to_map`` :669-673).
153
+ """
154
+ empty_spt = pd.MultiIndex.from_arrays(
155
+ [[], [], []], names=["solve", "period", "time"])
156
+ empty_sp = pd.MultiIndex.from_arrays(
157
+ [[], []], names=["solve", "period"])
158
+
159
+ obj = results.get("node_prices_dt_e")
160
+ if obj is None:
161
+ return empty_spt, empty_sp
162
+ idx = obj.index
163
+ if idx is None or len(idx) == 0:
164
+ return empty_spt, empty_sp
165
+ names = list(idx.names)
166
+ if not {"solve", "period", "time"} <= set(names):
167
+ logger.debug(
168
+ "spinedb replay: node_prices_dt_e index %s lacks "
169
+ "(solve, period, time) — solve recon degraded to single wrap",
170
+ names)
171
+ return empty_spt, empty_sp
172
+
173
+ solve = idx.get_level_values("solve")
174
+ period = idx.get_level_values("period")
175
+ time = idx.get_level_values("time")
176
+
177
+ # solve_period_time: dedup on (period, time), keep='last'.
178
+ spt = pd.MultiIndex.from_arrays(
179
+ [solve, period, time], names=["solve", "period", "time"])
180
+ pt = pd.MultiIndex.from_arrays([period, time], names=["period", "time"])
181
+ keep_spt = ~pt.duplicated(keep="last")
182
+ solve_period_time = spt[keep_spt]
183
+
184
+ # solve_period: collapse to (solve, period), dedup on period, keep='last'.
185
+ sp_full = pd.MultiIndex.from_arrays(
186
+ [solve, period], names=["solve", "period"])
187
+ keep_sp = ~period.duplicated(keep="last")
188
+ solve_period = sp_full[keep_sp]
189
+
190
+ return solve_period_time, solve_period
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Unit / connection process-name sets
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def _collect_process_names(results: dict, prefix: str) -> pd.Index:
198
+ """Union the process names across all ``results`` frames whose key starts
199
+ with ``prefix`` (``unit_`` or ``connection_``).
200
+
201
+ The process name is the FIRST column level of the (scenario-stripped)
202
+ frame — named ``unit`` / ``connection`` / ``process`` depending on the
203
+ frame — or, for ``_ed_p`` capacity frames where the entity sits in the
204
+ index, the index level named ``unit`` / ``connection``. Returns a
205
+ canonically sorted ``pd.Index(name='process')`` (matching native
206
+ ``s.process_unit`` / ``s.process_connection``).
207
+ """
208
+ entity_level = "unit" if prefix == _UNIT_PREFIX else "connection"
209
+ names: set[str] = set()
210
+
211
+ for key, df in results.items():
212
+ if not key.startswith(prefix):
213
+ continue
214
+ if df is None:
215
+ continue
216
+ # Capacity / indicator frames carry the entity in the index level.
217
+ idx_names = list(getattr(df.index, "names", []) or [])
218
+ if entity_level in idx_names:
219
+ names.update(df.index.get_level_values(entity_level).tolist())
220
+ continue
221
+ # Otherwise the entity is the first column level — read it via
222
+ # ``_frame_column_labels`` so a squeezed single-column Series (whose
223
+ # label lives in ``.name``, not ``.columns``) still contributes its
224
+ # process and the set never silently under-covers.
225
+ for col in _frame_column_labels(df):
226
+ first = col[0] if isinstance(col, tuple) else col
227
+ names.add(first)
228
+
229
+ return pd.Index(sorted(names), name="process")
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Connection (process, source, sink) triple
234
+ # ---------------------------------------------------------------------------
235
+
236
+ def _build_process_source_sink(results: dict) -> pd.MultiIndex:
237
+ """Reconstruct ``(process, source, sink)`` triples for connections from
238
+ the ``connection_leftward_*`` and ``connection_rightward_*`` column
239
+ bynames.
240
+
241
+ Node-order convention (verified against ``calc_connections.py`` and real
242
+ parquet):
243
+
244
+ * ``connection_leftward_*`` columns are ``(process, first_node)`` —
245
+ ``first_node`` is the **source** in ``s.process_source_sink``.
246
+ * ``connection_rightward_*`` columns are ``(process, second_node)`` —
247
+ ``second_node`` is the **sink**.
248
+
249
+ So the triple is ``(process, left_node, right_node)`` =
250
+ ``(process, source, sink)``, matching the native
251
+ ``_connection_triple_lookup`` which builds ``(process, source, sink)``
252
+ from ``s.process_source_sink``.
253
+
254
+ The ``_dt`` and ``_d`` variants carry identical ``(process, node)``
255
+ columns; both are scanned and unioned so a missing variant cannot drop a
256
+ connection. Returns a canonically sorted
257
+ ``pd.MultiIndex(['process','source','sink'])``.
258
+ """
259
+ left_map = _process_node_map(results, "leftward") # process -> source
260
+ right_map = _process_node_map(results, "rightward") # process -> sink
261
+
262
+ # Warn about any genuinely bidirectional (2-way) connection BEFORE the
263
+ # triple is committed: its (source, sink) ordering is not recoverable
264
+ # from the processed bundle (see ``_warn_two_way_connections``).
265
+ _warn_two_way_connections(results, set(left_map) | set(right_map))
266
+
267
+ triples: set[tuple[str, str, str]] = set()
268
+ for process in set(left_map) | set(right_map):
269
+ source = left_map.get(process)
270
+ sink = right_map.get(process)
271
+ if source is None or sink is None:
272
+ logger.debug(
273
+ "spinedb replay: connection '%s' missing %s node — triple "
274
+ "skipped", process,
275
+ "left/source" if source is None else "right/sink")
276
+ continue
277
+ triples.add((process, source, sink))
278
+
279
+ if not triples:
280
+ return pd.MultiIndex.from_arrays(
281
+ [[], [], []], names=["process", "source", "sink"])
282
+
283
+ ordered = sorted(triples)
284
+ return pd.MultiIndex.from_tuples(
285
+ ordered, names=["process", "source", "sink"])
286
+
287
+
288
+ def _process_node_map(results: dict, direction: str) -> dict:
289
+ """Map ``process -> node`` from every ``connection_<direction>_*`` frame
290
+ (``leftward`` / ``rightward``). Columns are ``(process, node)``.
291
+
292
+ Each connection has exactly one left node and one right node, so a single
293
+ mapping per process is correct; the first encountered wins (frames agree).
294
+ """
295
+ needle = f"connection_{direction}_"
296
+ out: dict[str, str] = {}
297
+ for key, df in results.items():
298
+ if not key.startswith(needle):
299
+ continue
300
+ if df is None:
301
+ continue
302
+ # ``_frame_column_labels`` covers the squeezed single-column Series
303
+ # case (label in ``.name``), so a connection whose only directional
304
+ # frame was squeezed still maps its node.
305
+ for col in _frame_column_labels(df):
306
+ if not isinstance(col, tuple) or len(col) < 2:
307
+ # Expected (process, node); skip malformed.
308
+ continue
309
+ process, node = col[0], col[1]
310
+ out.setdefault(process, node)
311
+ return out
312
+
313
+
314
+ def _warn_two_way_connections(results: dict, connections: set) -> None:
315
+ """Emit a runtime ``logging.warning`` for every BIDIRECTIONAL (2-way)
316
+ connection reconstructed on the replay path, naming it.
317
+
318
+ Why this matters
319
+ ----------------
320
+ For a 1-way connection the ``(source, sink)`` ordering is exact on the
321
+ replay path: native ``s.process_source_sink`` holds the single arc
322
+ ``(left_node, right_node)`` and the reconstruction reproduces it verbatim.
323
+
324
+ For a TRUE 2-way connection native ``s.process_source_sink`` carries BOTH
325
+ arcs ``(a, b)`` and ``(b, a)``; ``_connection_triple_lookup`` keeps the
326
+ lexicographically-first. The processed parquet retains only the
327
+ ``(left_node, right_node)`` *geometry* (``connection_leftward_*`` /
328
+ ``connection_rightward_*`` each name a single node) with NO surviving
329
+ signal for the arc the writer would have picked, so the reconstructed
330
+ ``connection__node__node`` byname may be SILENTLY swapped relative to the
331
+ native DB. We do not attempt to make it exact here (that needs either the
332
+ input DB or a new persisted artifact — a decision still pending with the
333
+ maintainer); we only make the possible swap LOUD.
334
+
335
+ Detection predicate (parquet-only)
336
+ ----------------------------------
337
+ 2-way-ness is not visible in the directional geometry alone — for a 1-way
338
+ connection ``connection_leftward_*`` / ``connection_rightward_*`` BOTH
339
+ carry the (loss-driven) directional flow components, so "appears in both
340
+ roles" matches 1-way connections too and cannot be the test. The one
341
+ signal that survives into the processed bundle is the NET flow
342
+ (``connection_dt_eee`` / ``connection_d_eee``, column = bare connection,
343
+ value positive = first→second node): a genuinely bidirectional connection
344
+ flows BOTH ways across the horizon, so its net flow takes BOTH signs,
345
+ whereas a 1-way connection is strictly single-signed. We therefore warn
346
+ exactly for connections whose net flow has both a value ``> +eps`` and a
347
+ value ``< -eps``.
348
+
349
+ Known residual edge: a 2-way connection that happens to flow in only ONE
350
+ direction across the entire realized horizon is indistinguishable from a
351
+ 1-way connection here (single-signed net flow) and is NOT warned — but in
352
+ that case its single realized direction is exactly what the reconstruction
353
+ emits, so the byname is at least self-consistent with the observed flow.
354
+ Making 2-way fully exact is out of scope (no input-DB read, no new
355
+ persisted artifact).
356
+ """
357
+ two_way = sorted(
358
+ c for c in connections if _connection_is_bidirectional(results, c)
359
+ )
360
+ if not two_way:
361
+ return
362
+ # The maintainer has accepted the parquet-derived (node_1, node_2)
363
+ # geometry as the canonical (source, sink) ordering on the replay path,
364
+ # so this is the intended convention — not a defect. Emit a quiet debug
365
+ # breadcrumb (the ordering may differ from a native solve's
366
+ # lexicographic pick for these connections) without spamming normal runs.
367
+ logger.debug(
368
+ "spinedb replay: %d bidirectional (2-way) connection(s) (%s) use the "
369
+ "parquet (node_1, node_2) geometry for their 'connection__node__node' "
370
+ "(source, sink) byname; this may differ from a native solve's "
371
+ "ordering. 1-way connections are exact.",
372
+ len(two_way), ", ".join(two_way),
373
+ )
374
+
375
+
376
+ def _connection_is_bidirectional(results: dict, connection: str) -> bool:
377
+ """True when ``connection``'s net flow takes BOTH signs in any net-flow
378
+ frame (``connection_dt_eee`` / ``connection_d_eee``) — the parquet-only
379
+ signature of a genuinely 2-way connection (see
380
+ ``_warn_two_way_connections``)."""
381
+ for key in _NET_FLOW_KEYS:
382
+ df = results.get(key)
383
+ if df is None:
384
+ continue
385
+ series = _net_flow_series(df, connection)
386
+ if series is None or len(series) == 0:
387
+ continue
388
+ has_pos = bool((series > _FLOW_SIGN_EPS).any())
389
+ has_neg = bool((series < -_FLOW_SIGN_EPS).any())
390
+ if has_pos and has_neg:
391
+ return True
392
+ return False
393
+
394
+
395
+ def _net_flow_series(df, connection: str):
396
+ """Extract the net-flow Series for ``connection`` from a net-flow frame.
397
+
398
+ Net-flow frames are keyed by the bare connection name (column level
399
+ ``process``). Handles the squeezed single-column ``Series`` case (label
400
+ in ``.name``) and the multi-column frame case (select the column whose
401
+ first level is ``connection``)."""
402
+ cols = getattr(df, "columns", None)
403
+ if cols is None:
404
+ # Squeezed Series — usable only if its name identifies this connection.
405
+ name = getattr(df, "name", None)
406
+ first = name[0] if isinstance(name, tuple) else name
407
+ return df if first == connection else None
408
+ for col in cols:
409
+ first = col[0] if isinstance(col, tuple) else col
410
+ if first == connection:
411
+ return df[col]
412
+ return None
@@ -0,0 +1,224 @@
1
+ """Union per-roll realized param/set slices at output time (stage 3).
2
+
3
+ Multi-sub-solve runs persist each roll's realized param/set slice to
4
+ ``output_raw/<attr>__<solve>.parquet`` while the Solution is still live
5
+ (:mod:`flextool.process_outputs.persist_realized_slice`, stage 2). This
6
+ module is the read side: it reconstructs the full ``par`` / ``s``
7
+ namespaces by UNIONING those per-roll parquets, reproducing exactly what
8
+ the proven-correct in-memory oracle
9
+ (:func:`...read_parameters.read_parameters_multi` /
10
+ :func:`...read_sets.read_sets_multi`) produces — but sourcing the
11
+ per-roll frames from disk instead of holding every roll's
12
+ ``flex_data`` / ``solution`` in memory (which the default
13
+ ``keep_solutions=False`` flow nulls on all but the last step).
14
+
15
+ Contract (identical to the in-memory multi readers):
16
+
17
+ * **Solve-keyed attrs** (carry a ``solve`` index level) — the union over
18
+ rolls. The persisted slices are ALREADY realized-filtered and
19
+ post-hack (``persist_realized_slice`` applies the three
20
+ ``entity_lifetime_fixed_cost`` / ``entity_all_existing`` /
21
+ ``entity_annual_*`` hacks + the realized intersect at persist time),
22
+ so the union is a plain row concat — exactly the ``pd.concat`` in
23
+ ``read_parameters_multi`` / the per-level MultiIndex concat in
24
+ ``read_sets_multi``. No fillna, no dedup (each roll carries a distinct
25
+ ``solve`` value); ``drop_levels`` does the realized-intersect + dedup +
26
+ solve-drop downstream, just as it does for the in-memory path.
27
+ * **Static (solve-invariant) attrs** — taken once from the last step's
28
+ in-memory namespace (``read_parameters`` / ``read_sets`` on the last
29
+ roll's ``flex_data`` / ``solution``). These carry no ``solve`` level
30
+ and are identical across rolls; they are NOT persisted per-roll.
31
+ * **Output-dead solve-keyed params** (``flow_min``, ``flow_max``,
32
+ ``years_from_start_d``, ``entity_max_units``, ``node_annual_flow``,
33
+ ``group_capacity_margin``) are not persisted (``persist_realized_slice
34
+ ._DEAD_PARAMS``) and have no consumer; the last step's value is kept so
35
+ the namespace stays shape-complete.
36
+
37
+ Per-roll parquets are unioned in solve CREATION order
38
+ (:func:`...solve_order.load_solve_order`) — the same order the oracle's
39
+ ``solve_steps`` (``steps.items()``) carries and the same order the
40
+ variable reader uses, so the assembled row order matches.
41
+ """
42
+ from __future__ import annotations
43
+
44
+ from pathlib import Path
45
+ from types import SimpleNamespace
46
+ from typing import Sequence
47
+
48
+ import pandas as pd
49
+
50
+ from flextool.lean_parquet import read_lean_parquet
51
+ from flextool.process_outputs.read_parameters import _has_solve_level
52
+ from flextool.process_outputs.read_sets import (
53
+ _multi_index_has_solve,
54
+ _series_or_df_has_solve,
55
+ )
56
+ from flextool.process_outputs.solve_order import load_solve_order
57
+
58
+ # Sentinel prefixes that are persisted ONLY by the per-roll multi-solve
59
+ # writer (a solve-keyed core set + a solve-keyed core param). Their
60
+ # presence in ``output_raw/`` means the stage-3 union path is live.
61
+ _SENTINEL_PREFIXES: tuple[str, ...] = ("dt", "step_duration")
62
+
63
+
64
+ def has_persisted_slices(output_dir: Path | str) -> bool:
65
+ """True iff MULTI-roll param/set slices were persisted to ``output_dir``.
66
+
67
+ Detection mirrors the variable reader's ``v_flow__*.parquet`` probe:
68
+ look for the per-roll ``<sentinel>__*.parquet`` files the stage-2
69
+ writer emits. Both sentinels must be present (one set, one param),
70
+ AND there must be MORE THAN ONE distinct solve label — a genuine
71
+ multi-sub-solve cascade. A single-solve scenario also persists these
72
+ parquets, but its outputs are produced by the in-memory single-
73
+ ``flex_data`` path (which is NOT realized-filtered); activating the
74
+ union path only for true multi-roll runs keeps single-solve byte-
75
+ identical.
76
+ """
77
+ out = Path(output_dir)
78
+ if not all(
79
+ any(out.glob(f"{prefix}__*.parquet")) for prefix in _SENTINEL_PREFIXES
80
+ ):
81
+ return False
82
+ sentinel = _SENTINEL_PREFIXES[0]
83
+ solves = {
84
+ p.name[len(sentinel) + 2 : -len(".parquet")]
85
+ for p in out.glob(f"{sentinel}__*.parquet")
86
+ }
87
+ return len(solves) > 1
88
+
89
+
90
+ def _ordered_parts(output_dir: Path, attr: str, solve_order: dict[str, int]):
91
+ """Per-roll parquet paths for ``attr`` in solve creation order.
92
+
93
+ ``<attr>__<solve>.parquet`` — the solve label is everything after the
94
+ first ``__``. Parts whose solve is unknown to ``solve_order`` sort
95
+ first (defensive; shouldn't happen).
96
+ """
97
+ parts = list(output_dir.glob(f"{attr}__*.parquet"))
98
+
99
+ def _solve(path: Path) -> str:
100
+ return path.name[len(attr) + 2 : -len(".parquet")]
101
+
102
+ parts.sort(key=lambda p: solve_order.get(_solve(p), -1))
103
+ return parts
104
+
105
+
106
+ def _union_param_attr(parts: Sequence[Path], template) -> object:
107
+ """Union one solve-keyed param attr from its per-roll parquets.
108
+
109
+ ``template`` is the last step's value for this attr (a ``pd.Series``
110
+ or ``pd.DataFrame``) — used only to decide the output shape and to
111
+ restore the ``columns.name`` / Series name. Mirrors the
112
+ ``read_parameters_multi`` per-attr concat: drop empty pieces, concat
113
+ rows, restore the column metadata; if every piece is empty fall back
114
+ to the (empty) template so the namespace stays shape-complete.
115
+ """
116
+ frames = [read_lean_parquet(p) for p in parts]
117
+ non_empty = [f for f in frames if len(f) > 0]
118
+ if not non_empty:
119
+ return template
120
+ merged = pd.concat(non_empty, axis=0)
121
+ if isinstance(template, pd.Series):
122
+ # Series were persisted as a one-column ``value`` frame.
123
+ series = merged.iloc[:, 0]
124
+ series.name = template.name
125
+ return series
126
+ # DataFrame — restore the columns name (concat keeps it, but be
127
+ # explicit to match ``read_parameters_multi``).
128
+ if hasattr(template, "columns"):
129
+ merged.columns.name = template.columns.name
130
+ return merged
131
+
132
+
133
+ def union_params(
134
+ last_par: SimpleNamespace, output_dir: Path | str
135
+ ) -> SimpleNamespace:
136
+ """Reconstruct the full ``par`` namespace by unioning per-roll slices.
137
+
138
+ For every attr carrying a ``solve`` level in ``last_par`` whose
139
+ per-roll parquets exist, replace it with the union; static attrs (and
140
+ the output-dead solve-keyed params that are never persisted) keep the
141
+ last step's value. Byte-equivalent to ``read_parameters_multi`` for
142
+ the consumed attrs.
143
+ """
144
+ output_dir = Path(output_dir)
145
+ solve_order = load_solve_order(output_dir.parent)
146
+
147
+ out = SimpleNamespace()
148
+ for attr, value in vars(last_par).items():
149
+ if not _has_solve_level(value):
150
+ # Static / invariant — last-step value (identical per roll).
151
+ setattr(out, attr, value)
152
+ continue
153
+ parts = _ordered_parts(output_dir, attr, solve_order)
154
+ if not parts:
155
+ # Output-dead solve-keyed param (never persisted) — keep the
156
+ # last step's value; it has no downstream consumer.
157
+ setattr(out, attr, value)
158
+ continue
159
+ setattr(out, attr, _union_param_attr(parts, value))
160
+ return out
161
+
162
+
163
+ def _union_set_multiindex(parts: Sequence[Path], template: pd.MultiIndex):
164
+ """Union one solve-keyed set's per-roll MultiIndex parquets.
165
+
166
+ Each set parquet stores the (Multi)Index as the row index of a
167
+ one-column ``_SET_MARKER_COL`` frame. Drop the marker column and
168
+ rebuild the MultiIndex via the same per-level concat
169
+ ``read_sets_multi`` uses (robust to empty-frame dtype quirks). If
170
+ every roll is empty, fall back to the template (empty) MultiIndex.
171
+ """
172
+ indices: list[pd.MultiIndex] = []
173
+ for p in parts:
174
+ df = read_lean_parquet(p)
175
+ idx = df.index
176
+ if len(idx) > 0:
177
+ indices.append(idx)
178
+ if not indices:
179
+ return template
180
+ names = indices[0].names
181
+ level_lists: list[list] = [[] for _ in names]
182
+ for idx in indices:
183
+ for i in range(len(names)):
184
+ level_lists[i].extend(list(idx.get_level_values(i)))
185
+ return pd.MultiIndex.from_arrays(level_lists, names=names)
186
+
187
+
188
+ def union_sets(
189
+ last_s: SimpleNamespace, output_dir: Path | str
190
+ ) -> SimpleNamespace:
191
+ """Reconstruct the full ``s`` namespace by unioning per-roll slices.
192
+
193
+ Solve-keyed sets (``period``, ``dt``, ``dtt``, ``dtttdt``, the
194
+ ``ed_*`` invest sets, …) are unioned across rolls; static topology
195
+ sets (``node``, ``process``, ``upDown``, …) keep the last step's
196
+ value. Byte-equivalent to ``read_sets_multi``.
197
+ """
198
+ output_dir = Path(output_dir)
199
+ solve_order = load_solve_order(output_dir.parent)
200
+
201
+ out = SimpleNamespace()
202
+ for attr, value in vars(last_s).items():
203
+ if isinstance(value, pd.MultiIndex) and _multi_index_has_solve(value):
204
+ parts = _ordered_parts(output_dir, attr, solve_order)
205
+ if not parts:
206
+ setattr(out, attr, value)
207
+ continue
208
+ setattr(out, attr, _union_set_multiindex(parts, value))
209
+ elif _series_or_df_has_solve(value):
210
+ # Defensive: no varying set is a DataFrame/Series with a solve
211
+ # level today, but the structural test admits one. Reuse the
212
+ # param-side concat.
213
+ parts = _ordered_parts(output_dir, attr, solve_order)
214
+ if not parts:
215
+ setattr(out, attr, value)
216
+ continue
217
+ setattr(out, attr, _union_param_attr(parts, value))
218
+ else:
219
+ # Static topology set — last-step value (invariant per roll).
220
+ setattr(out, attr, value)
221
+ return out
222
+
223
+
224
+ __all__ = ["has_persisted_slices", "union_params", "union_sets"]