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,1261 @@
1
+ """
2
+ region_filter.py — Regional filter for Benders decomposition (Agent 3.1).
3
+
4
+ Given a populated ``input/`` directory and a region group name (a group with
5
+ ``decomposition_method = 'benders_regional'``), produce a self-contained
6
+ ``input_region_<group>/`` directory for that region's standalone solve.
7
+
8
+ Cross-region processes (pipelines, transmission) are removed from the
9
+ filtered directory and replaced by **import/export half-flows**:
10
+
11
+ * Each cross-region ``connection`` whose in-region endpoint is the *source*
12
+ becomes an **export half-flow** from the in-region node to a new virtual
13
+ node ``<connection>__export__<region>``.
14
+ * Each cross-region ``connection`` whose in-region endpoint is the *sink*
15
+ becomes an **import half-flow** from a new virtual node
16
+ ``<connection>__import__<region>`` to the in-region node.
17
+
18
+ The virtual nodes are declared with ``node_type = commodity`` — they
19
+ participate in the node balance but have no state, and their flow is the
20
+ trade coupling variable that the Benders master pins each iteration
21
+ (Agent 3.2).
22
+
23
+ Entry point: ``build_region_directory``.
24
+
25
+ Top-level contract:
26
+
27
+ * Reads the already-populated ``input/`` produced by ``write_input``.
28
+ * Does NOT modify the staging directory.
29
+ * Writes the filtered copy to ``output_dir``.
30
+ * Also writes ``solve_data/region_coupling.csv`` listing the coupling
31
+ variables for this region.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import csv
36
+ import shutil
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+ from typing import TYPE_CHECKING, Iterable
40
+
41
+ import polars as pl
42
+
43
+ if TYPE_CHECKING: # pragma: no cover
44
+ from flextool.engine_polars._flex_data_provider import FlexDataProvider
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Region membership & cross-region classification
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ @dataclass
53
+ class RegionMembership:
54
+ """Membership sets for one decomposition region."""
55
+
56
+ region: str
57
+ # Entities that belong to this region (from group__node / group__unit /
58
+ # group__connection of the region_group).
59
+ nodes: set[str] = field(default_factory=set)
60
+ units: set[str] = field(default_factory=set)
61
+ connections: set[str] = field(default_factory=set)
62
+ # Nodes that belong to some OTHER decomposition region.
63
+ other_region_nodes: set[str] = field(default_factory=set)
64
+ # Units and connections in any OTHER decomposition region.
65
+ other_region_units: set[str] = field(default_factory=set)
66
+ other_region_connections: set[str] = field(default_factory=set)
67
+ # All decomposition region names (for reference).
68
+ all_regions: set[str] = field(default_factory=set)
69
+
70
+
71
+ @dataclass
72
+ class HalfFlow:
73
+ """One import or export half-flow replacing a cross-region connection."""
74
+
75
+ original_connection: str
76
+ region: str
77
+ side: str # "import" or "export"
78
+ # For export: in-region node is source, virtual node is sink.
79
+ # For import: virtual node is source, in-region node is sink.
80
+ in_region_node: str
81
+ virtual_node: str
82
+ # Half-flow connection name: the virtual process that appears in
83
+ # ``input_region_<region>/process_connection.csv`` replacing the
84
+ # cross-region pipe. Convention: ``hf_<pipe>__<side>__<region>``
85
+ # — the ``hf_`` prefix disambiguates the virtual connection from
86
+ # the virtual node (which re-uses the ``<pipe>__<side>__<region>``
87
+ # stem) so ``entity.csv`` does not carry a duplicate tuple.
88
+ virtual_connection: str
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Reading helpers
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def _read_csv_rows(path: Path) -> tuple[list[str], list[list[str]]]:
97
+ """Return ``(header_fields, data_rows)`` for a CSV at *path*.
98
+
99
+ Rows are returned as lists of strings (no type coercion). Blank lines
100
+ are dropped. If the file does not exist, returns ``([], [])``.
101
+ """
102
+ if not path.exists():
103
+ return [], []
104
+ with path.open() as fh:
105
+ reader = csv.reader(fh)
106
+ rows = [row for row in reader if row and any(cell.strip() for cell in row)]
107
+ if not rows:
108
+ return [], []
109
+ return rows[0], rows[1:]
110
+
111
+
112
+ def _write_csv_rows(
113
+ path: Path, header: list[str], rows: Iterable[list[str]]
114
+ ) -> None:
115
+ """Write CSV with *header* and *rows* to *path*. Creates parent dirs."""
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ with path.open("w", newline="") as fh:
118
+ writer = csv.writer(fh)
119
+ writer.writerow(header)
120
+ for row in rows:
121
+ writer.writerow(row)
122
+
123
+
124
+ def _read_single_col(path: Path) -> list[str]:
125
+ """Read a single-column CSV and return the list of values (minus header)."""
126
+ _, rows = _read_csv_rows(path)
127
+ return [r[0] for r in rows if r]
128
+
129
+
130
+ def _read_two_col_dict(path: Path) -> dict[str, list[str]]:
131
+ """Read a two-column ``key,value`` CSV into ``{key: [values...]}``."""
132
+ _, rows = _read_csv_rows(path)
133
+ out: dict[str, list[str]] = {}
134
+ for r in rows:
135
+ if len(r) < 2:
136
+ continue
137
+ out.setdefault(r[0], []).append(r[1])
138
+ return out
139
+
140
+
141
+ def _read_connection_endpoints(
142
+ input_dir: Path,
143
+ ) -> dict[str, tuple[str, str]]:
144
+ """Return ``{connection: (source_node, sink_node)}`` for every connection.
145
+
146
+ Derives endpoints from ``process__source.csv`` and ``process__sink.csv``,
147
+ keeping only those processes that are listed in ``process_connection.csv``.
148
+ """
149
+ connections = set(_read_single_col(input_dir / "process_connection.csv"))
150
+ src_of: dict[str, str] = {}
151
+ for proc, src in (
152
+ (r[0], r[1])
153
+ for r in _read_csv_rows(input_dir / "process__source.csv")[1]
154
+ if len(r) >= 2
155
+ ):
156
+ if proc in connections:
157
+ src_of[proc] = src
158
+ snk_of: dict[str, str] = {}
159
+ for proc, snk in (
160
+ (r[0], r[1])
161
+ for r in _read_csv_rows(input_dir / "process__sink.csv")[1]
162
+ if len(r) >= 2
163
+ ):
164
+ if proc in connections:
165
+ snk_of[proc] = snk
166
+ endpoints: dict[str, tuple[str, str]] = {}
167
+ for conn in connections:
168
+ if conn in src_of and conn in snk_of:
169
+ endpoints[conn] = (src_of[conn], snk_of[conn])
170
+ return endpoints
171
+
172
+
173
+ def discover_region_membership(
174
+ input_dir: Path, region: str
175
+ ) -> RegionMembership:
176
+ """Parse ``input/`` to build the membership set for *region*.
177
+
178
+ Identifies:
179
+
180
+ * in-region nodes/units/connections from ``group__*`` CSVs
181
+ * other regions' membership (every other group whose
182
+ ``decomposition_method`` is ``benders_regional``)
183
+
184
+ ``group__decomposition_method.csv`` is not a direct output of
185
+ ``write_input``; instead the group→decomposition_method pairing lives
186
+ in the group-parameter CSVs. For V1 we rely on the caller passing the
187
+ full list of decomposition regions. If unavailable, we fall back to
188
+ "every group listed in group.csv that has members in group__node AND
189
+ whose name starts with ``region_``" (the LH2 fixture convention).
190
+ """
191
+ # group → nodes / units / connections mappings
192
+ group_nodes = _read_two_col_dict(input_dir / "group__node.csv")
193
+ group_processes = _read_two_col_dict(input_dir / "group__process.csv")
194
+
195
+ # Discover all decomposition regions: pragmatic heuristic for V1 is
196
+ # "group that has at least one node or process member and is not a
197
+ # resolution group". Better: read from a sibling file the caller can
198
+ # populate. For now we require the caller to pass *region* as the
199
+ # concrete name; sibling regions come from the caller via
200
+ # ``list_decomposition_regions``.
201
+ # Fallback heuristic for the LH2 fixture: region groups start with
202
+ # ``region_``.
203
+ all_groups = set(_read_single_col(input_dir / "group.csv"))
204
+ region_like = {g for g in all_groups if g.startswith("region_")}
205
+ if region not in region_like:
206
+ region_like.add(region) # trust the caller
207
+
208
+ mem = RegionMembership(
209
+ region=region,
210
+ all_regions=region_like,
211
+ )
212
+ mem.nodes = set(group_nodes.get(region, []))
213
+ in_region_processes = set(group_processes.get(region, []))
214
+
215
+ # group__process.csv has *every* process (unit or connection). We
216
+ # disambiguate by consulting process_unit.csv and process_connection.csv.
217
+ units = set(_read_single_col(input_dir / "process_unit.csv"))
218
+ connections = set(_read_single_col(input_dir / "process_connection.csv"))
219
+ mem.units = in_region_processes & units
220
+ mem.connections = in_region_processes & connections
221
+
222
+ # Other regions' memberships
223
+ for other in region_like:
224
+ if other == region:
225
+ continue
226
+ mem.other_region_nodes.update(group_nodes.get(other, []))
227
+ other_procs = set(group_processes.get(other, []))
228
+ mem.other_region_units.update(other_procs & units)
229
+ mem.other_region_connections.update(other_procs & connections)
230
+
231
+ return mem
232
+
233
+
234
+ def discover_decomposition_regions_from_db(input_db_url: str) -> list[str]:
235
+ """Return the list of group names whose ``decomposition_method`` is
236
+ ``benders_regional``.
237
+
238
+ Used by the CLI to present the list of available regions and by
239
+ ``build_region_directory`` to know other regions' membership.
240
+ """
241
+ from spinedb_api import DatabaseMapping # lazy import
242
+
243
+ with DatabaseMapping(input_db_url) as db:
244
+ names: list[str] = []
245
+ for pv in db.find_parameter_values(
246
+ entity_class_name="group",
247
+ parameter_definition_name="decomposition_method",
248
+ ):
249
+ if pv["type"] is None:
250
+ continue
251
+ if str(pv["parsed_value"]) == "benders_regional":
252
+ names.append(pv["entity_byname"][0])
253
+ return names
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Cross-region classification
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ def classify_half_flows(
262
+ input_dir: Path, mem: RegionMembership
263
+ ) -> list[HalfFlow]:
264
+ """Identify cross-region connections for *mem.region* and produce half-flow
265
+ specs for each.
266
+
267
+ A connection is cross-region if exactly one endpoint (source or sink)
268
+ lies in ``mem.nodes`` and the other lies in ``mem.other_region_nodes``.
269
+ """
270
+ endpoints = _read_connection_endpoints(input_dir)
271
+ half_flows: list[HalfFlow] = []
272
+ for conn, (src, snk) in endpoints.items():
273
+ src_in = src in mem.nodes
274
+ snk_in = snk in mem.nodes
275
+ src_out = src in mem.other_region_nodes
276
+ snk_out = snk in mem.other_region_nodes
277
+ # Only one endpoint in-region, other endpoint in another region.
278
+ if src_in and snk_out:
279
+ # Flow leaves the region — export half-flow. Virtual node
280
+ # and virtual connection must have distinct names: entity.csv
281
+ # carries both, and a duplicate tuple would be flagged.
282
+ virtual = f"{conn}__export__{mem.region}"
283
+ half_flows.append(HalfFlow(
284
+ original_connection=conn,
285
+ region=mem.region,
286
+ side="export",
287
+ in_region_node=src,
288
+ virtual_node=virtual,
289
+ virtual_connection=f"hf_{conn}__export__{mem.region}",
290
+ ))
291
+ elif snk_in and src_out:
292
+ # Flow enters the region — import half-flow. Same
293
+ # naming-distinction rule as the export branch above.
294
+ virtual = f"{conn}__import__{mem.region}"
295
+ half_flows.append(HalfFlow(
296
+ original_connection=conn,
297
+ region=mem.region,
298
+ side="import",
299
+ in_region_node=snk,
300
+ virtual_node=virtual,
301
+ virtual_connection=f"hf_{conn}__import__{mem.region}",
302
+ ))
303
+ # else: both endpoints in-region, both out-of-region, or one is
304
+ # a shared commodity — leave the connection as is (filter will
305
+ # handle it by keeping/dropping whole).
306
+ return half_flows
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Per-CSV filter spec: which columns reference which entity kind
311
+ # ---------------------------------------------------------------------------
312
+
313
+
314
+ # Mapping from header-column position to the "kind" of entity it refers to.
315
+ # Kind is one of: "node", "unit", "connection", "process" (either unit or
316
+ # connection), "entity" (any of node/unit/connection), or a literal group
317
+ # name.
318
+ #
319
+ # We only list columns that MUST be in-region or shared. Parameter-value
320
+ # columns (time, numeric value, method name, etc.) are ignored — we copy
321
+ # those through.
322
+
323
+ # Set of CSVs that are entity-level (column positions identify entities);
324
+ # we also treat single-column CSVs as "column 0 is an entity of kind X".
325
+ # Each entry: filename → list of (column_index, kind) tuples where kind
326
+ # identifies which membership set the column must be in.
327
+ #
328
+ # For "process" kind: accept if the value is in units ∪ connections ∪
329
+ # kept_processes (we classify unit vs connection by reading process_unit
330
+ # vs process_connection).
331
+ #
332
+ # If a row has any filter column whose value is NOT in the kept set,
333
+ # the row is dropped.
334
+
335
+
336
+ def _build_column_specs() -> dict[str, list[tuple[int, str]]]:
337
+ """Per-CSV column spec for filtering.
338
+
339
+ Returns {filename: [(col_index, kind), ...]}. Files not listed are
340
+ copied verbatim (e.g. solver config, timeline, default_values).
341
+ """
342
+ specs: dict[str, list[tuple[int, str]]] = {}
343
+
344
+ # Single-column "entity-set" CSVs — column 0 is the entity name.
345
+ for name, kind in (
346
+ ("node.csv", "node"),
347
+ ("process.csv", "process"),
348
+ ("process_unit.csv", "unit"),
349
+ ("process_connection.csv", "connection"),
350
+ ("entity.csv", "entity"),
351
+ ("process_nonSync_connection.csv", "connection"),
352
+ ("process_min_downtime.csv", "process"),
353
+ ("process_min_uptime.csv", "process"),
354
+ ):
355
+ specs[name] = [(0, kind)]
356
+
357
+ # Two-column CSVs where col-0 is entity of given kind.
358
+ for name, kind0 in (
359
+ ("commodity__node.csv", "node_col1"), # special: commodity,node
360
+ ("node__inflow_method.csv", "node"),
361
+ ("node__penalty_method.csv", "node"),
362
+ ("node__storage_binding_method.csv", "node"),
363
+ ("node__storage_nested_fix_method.csv", "node"),
364
+ ("node__storage_solve_horizon_method.csv", "node"),
365
+ ("node__storage_start_end_method.csv", "node"),
366
+ ("node__profile__profile_method.csv", "node"),
367
+ ("p_node_type.csv", "node"),
368
+ ("process__ct_method.csv", "process"),
369
+ ("process__startup_method.csv", "process"),
370
+ ("process__profile__profile_method.csv", "process"),
371
+ ("entity__invest_method.csv", "entity"),
372
+ ("entity__lifetime_method.csv", "entity"),
373
+ ):
374
+ specs[name] = [(0, kind0)] if kind0 != "node_col1" else [(1, "node")]
375
+
376
+ # Process→source / process→sink (col 0 process, col 1 node).
377
+ for name in (
378
+ "process__source.csv",
379
+ "process__sink.csv",
380
+ ):
381
+ specs[name] = [(0, "process"), (1, "node")]
382
+
383
+ # process_method.csv (user-input, derived by input_writer): col 0 is
384
+ # process. Other process-keyed sets (process_online*, process_profile,
385
+ # process_VRE, process__commodity__node*) are now written to
386
+ # solve_data/ by flextool.mod and never seen by the region filter.
387
+ specs["process_method.csv"] = [(0, "process")]
388
+
389
+ # Group-related: col 0 group, col 1 node/process.
390
+ specs["group__node.csv"] = [(1, "node")] # keep group; filter node
391
+ specs["group__process.csv"] = [(1, "process")]
392
+ specs["flowGroup__process__node.csv"] = [(1, "process"), (2, "node")]
393
+
394
+ # Reserves (process, reserve, upDown, node) — col 0 process, col 3 node.
395
+ specs["process__reserve__upDown__node.csv"] = [(0, "process"), (3, "node")]
396
+
397
+ # Node-keyed parameter CSVs (header starts with 'node'). These are
398
+ # ALL ``pt_node*`` / ``pd_node*`` / ``pbt_node*`` / ``p_node*``
399
+ # variants. The entity is always column 0.
400
+ # Rather than enumerate, we'll catch them dynamically in the main
401
+ # loop by inspecting headers — see ``_infer_column_spec_from_header``.
402
+
403
+ # Same for process-keyed parameter CSVs and process__node-keyed.
404
+
405
+ return specs
406
+
407
+
408
+ _EXPLICIT_SPECS = _build_column_specs()
409
+
410
+
411
+ def _infer_column_spec_from_header(
412
+ header: list[str], filename: str
413
+ ) -> list[tuple[int, str]]:
414
+ """Infer filter columns from header names for parameter CSVs.
415
+
416
+ Common patterns:
417
+ * header[0] == "node" → column 0 is a node
418
+ * header[0] == "process" → column 0 is a process; if col 1 is
419
+ "source"/"sink"/"node" it's a node
420
+ * header[0] == "connection" → column 0 is a connection
421
+ * header[0] == "entity" → column 0 is any of node/unit/connection
422
+ * header[0] == "commodity" → leave alone (commodity is shared)
423
+ * header[0] == "group" → column 0 is a group; filter by extra
424
+ known-entity columns (node, process)
425
+ * header[0] == "profile" → leave alone (profiles are shared
426
+ identifiers, not spatial)
427
+ * header[0] == "reserve" → leave alone
428
+ * header[0] == "constraint" → leave alone
429
+ * header[0] == "timeline"/"timeset"/"period"/"solve"/"model"/
430
+ "version"/"class" → leave alone
431
+ """
432
+ if filename in _EXPLICIT_SPECS:
433
+ return _EXPLICIT_SPECS[filename]
434
+ if not header:
435
+ return []
436
+
437
+ col0 = header[0].lower()
438
+ spec: list[tuple[int, str]] = []
439
+
440
+ if col0 == "node":
441
+ spec.append((0, "node"))
442
+ # Some node CSVs have a "profile" column (which is always kept
443
+ # globally) — skip.
444
+ elif col0 == "process":
445
+ spec.append((0, "process"))
446
+ # Process-keyed may have a "source"/"sink"/"node" in col 1.
447
+ if len(header) > 1 and header[1].lower() in {"source", "sink", "node"}:
448
+ spec.append((1, "node"))
449
+ elif col0 == "connection":
450
+ spec.append((0, "connection"))
451
+ elif col0 == "unit":
452
+ spec.append((0, "unit"))
453
+ elif col0 == "entity":
454
+ spec.append((0, "entity"))
455
+ elif col0 == "group":
456
+ # Groups themselves stay; filter by any trailing entity column.
457
+ for i, h in enumerate(header[1:], start=1):
458
+ hl = h.lower()
459
+ if hl == "node":
460
+ spec.append((i, "node"))
461
+ elif hl == "process":
462
+ spec.append((i, "process"))
463
+ elif hl == "connection":
464
+ spec.append((i, "connection"))
465
+ elif hl == "unit":
466
+ spec.append((i, "unit"))
467
+ elif hl in {"source", "sink"}:
468
+ spec.append((i, "node"))
469
+ # else: no spatial filter (commodity, profile, reserve, timeline,
470
+ # solve, model, etc.)
471
+
472
+ return spec
473
+
474
+
475
+ # ---------------------------------------------------------------------------
476
+ # Membership predicate
477
+ # ---------------------------------------------------------------------------
478
+
479
+
480
+ @dataclass
481
+ class _KeepSets:
482
+ """Resolved kept-entity sets for a region, including virtual entities."""
483
+
484
+ nodes: set[str]
485
+ units: set[str]
486
+ connections: set[str]
487
+ processes: set[str] # units ∪ connections
488
+ entities: set[str] # nodes ∪ units ∪ connections
489
+
490
+ def contains(self, kind: str, value: str) -> bool:
491
+ if kind == "node":
492
+ return value in self.nodes
493
+ if kind == "unit":
494
+ return value in self.units
495
+ if kind == "connection":
496
+ return value in self.connections
497
+ if kind == "process":
498
+ return value in self.processes
499
+ if kind == "entity":
500
+ return value in self.entities
501
+ # Unknown kind → conservative: accept.
502
+ return True
503
+
504
+
505
+ def _resolve_keep_sets(
506
+ input_dir: Path,
507
+ mem: RegionMembership,
508
+ half_flows: list[HalfFlow],
509
+ ) -> _KeepSets:
510
+ """Build the kept-entity sets: in-region members + shared (non-region) +
511
+ virtual import/export nodes & half-flow connections.
512
+ """
513
+ all_nodes = set(_read_single_col(input_dir / "node.csv"))
514
+ all_units = set(_read_single_col(input_dir / "process_unit.csv"))
515
+ all_connections = set(_read_single_col(input_dir / "process_connection.csv"))
516
+
517
+ # Shared (non-region-specific) entities: nodes/units/connections NOT
518
+ # assigned to any decomposition region.
519
+ shared_nodes = all_nodes - mem.nodes - mem.other_region_nodes
520
+ shared_units = all_units - mem.units - mem.other_region_units
521
+ shared_connections = (
522
+ all_connections - mem.connections - mem.other_region_connections
523
+ )
524
+
525
+ kept_nodes = mem.nodes | shared_nodes
526
+ kept_units = mem.units | shared_units
527
+ kept_connections = mem.connections | shared_connections
528
+
529
+ # Drop the original cross-region connections (they will be replaced
530
+ # by half-flow virtual connections).
531
+ cross_region_conns = {hf.original_connection for hf in half_flows}
532
+ kept_connections -= cross_region_conns
533
+
534
+ # Add virtual half-flow nodes and virtual connections.
535
+ virtual_nodes = {hf.virtual_node for hf in half_flows}
536
+ virtual_conns = {hf.virtual_connection for hf in half_flows}
537
+ kept_nodes |= virtual_nodes
538
+ kept_connections |= virtual_conns
539
+
540
+ kept_processes = kept_units | kept_connections
541
+ kept_entities = kept_nodes | kept_processes
542
+
543
+ return _KeepSets(
544
+ nodes=kept_nodes,
545
+ units=kept_units,
546
+ connections=kept_connections,
547
+ processes=kept_processes,
548
+ entities=kept_entities,
549
+ )
550
+
551
+
552
+ # ---------------------------------------------------------------------------
553
+ # Virtual entities — rows to inject into each CSV
554
+ # ---------------------------------------------------------------------------
555
+
556
+
557
+ _VIRTUAL_NODE_TYPE = "commodity"
558
+
559
+
560
+ def _virtual_rows(
561
+ half_flows: list[HalfFlow],
562
+ filename: str,
563
+ header: list[str],
564
+ ) -> list[list[str]]:
565
+ """Return rows to inject into *filename* for the virtual half-flow
566
+ entities.
567
+
568
+ Only a small number of files need virtual entries:
569
+
570
+ * ``node.csv`` — add virtual nodes
571
+ * ``entity.csv`` — add virtual nodes + virtual connections
572
+ * ``process.csv`` — add virtual connections
573
+ * ``process_connection.csv`` — add virtual connections
574
+ * ``p_node_type.csv`` — declare virtual nodes as commodity
575
+ * ``process__source.csv`` — for each half-flow, (conn, source_node)
576
+ * ``process__sink.csv`` — for each half-flow, (conn, sink_node)
577
+ * ``process__ct_method.csv`` — virtual connection is no-loss
578
+ * ``process_method.csv`` — virtual connection enters process_method set
579
+
580
+ We keep it simple: virtual connections inherit the existing
581
+ ``transfer_method = no_losses_no_variable_cost`` (method_2way_1var_off)
582
+ analogous to the group-level DC-flow overrides used elsewhere in
583
+ FlexTool. This is just enough to make them appear in the LP with a
584
+ flow variable; Agent 3.2's coordinator (the Benders master) then pins
585
+ them per iteration.
586
+ """
587
+ rows: list[list[str]] = []
588
+ if not half_flows:
589
+ return rows
590
+
591
+ def _src_sink(hf: HalfFlow) -> tuple[str, str]:
592
+ # Export: in-region node → virtual node
593
+ # Import: virtual node → in-region node
594
+ if hf.side == "export":
595
+ return hf.in_region_node, hf.virtual_node
596
+ return hf.virtual_node, hf.in_region_node
597
+
598
+ if filename == "node.csv":
599
+ for hf in half_flows:
600
+ rows.append([hf.virtual_node])
601
+ elif filename == "entity.csv":
602
+ for hf in half_flows:
603
+ rows.append([hf.virtual_node])
604
+ rows.append([hf.virtual_connection])
605
+ elif filename == "process.csv":
606
+ for hf in half_flows:
607
+ rows.append([hf.virtual_connection])
608
+ elif filename == "process_connection.csv":
609
+ for hf in half_flows:
610
+ rows.append([hf.virtual_connection])
611
+ elif filename == "process_unit.csv":
612
+ pass # virtual entities are connections, not units
613
+ elif filename == "p_node_type.csv":
614
+ # node,p_node_type
615
+ for hf in half_flows:
616
+ rows.append([hf.virtual_node, _VIRTUAL_NODE_TYPE])
617
+ elif filename == "process__source.csv":
618
+ for hf in half_flows:
619
+ src, _ = _src_sink(hf)
620
+ rows.append([hf.virtual_connection, src])
621
+ elif filename == "process__sink.csv":
622
+ for hf in half_flows:
623
+ _, snk = _src_sink(hf)
624
+ rows.append([hf.virtual_connection, snk])
625
+ elif filename == "process__ct_method.csv":
626
+ for hf in half_flows:
627
+ rows.append([hf.virtual_connection, "no_losses_no_variable_cost"])
628
+ elif filename == "process_method.csv":
629
+ # flextool.mod reads ``input/process_method.csv`` directly into
630
+ # the ``process_method`` set; without this row the virtual
631
+ # half-flow connection never enters ``process_source_toSink``
632
+ # and v_flow[hf_*, ...] columns are absent from the MPS.
633
+ for hf in half_flows:
634
+ rows.append([hf.virtual_connection, "method_1way_1var_off"])
635
+ elif filename == "p_process.csv":
636
+ # The half-flow's ``existing`` capacity gates ``p_flow_max`` at
637
+ # zero otherwise (v_flow columns appear in the MPS but with
638
+ # ``lb = ub = 0``, so the master's trade pin would have no effect).
639
+ # ``efficiency = 1.0`` because the half-flow is a bookkeeping
640
+ # artefact, not a physical pipe — the underlying pipe
641
+ # efficiency stays embedded in the importing region's import
642
+ # half-flow.
643
+ #
644
+ # Capacity: we source the original pipe's ``existing`` from
645
+ # :func:`_original_connection_existing` (reads
646
+ # input/p_process.csv one directory up from the filtered copy
647
+ # before the rename swap, or defaults to a conservative
648
+ # ``1e3`` when that information is no longer available). The
649
+ # caller (input_writer) sets the value via a module-level
650
+ # context dict — see
651
+ # :data:`_virtual_capacity_override`.
652
+ for hf in half_flows:
653
+ cap = _virtual_capacity_override.get(hf.original_connection, 1e3)
654
+ rows.append([hf.virtual_connection, "existing", str(cap)])
655
+ rows.append([hf.virtual_connection, "efficiency", "1.0"])
656
+ rows.append([hf.virtual_connection, "availability", "1.0"])
657
+ rows.append([hf.virtual_connection, "virtual_unitsize", "1.0"])
658
+ return rows
659
+
660
+
661
+ # Module-level override for original-connection capacities injected
662
+ # into the filtered ``p_process.csv`` for virtual half-flow
663
+ # connections. Populated by ``build_region_directory`` before
664
+ # ``_virtual_rows`` is called, cleared afterwards. Using a module
665
+ # attribute rather than function parameters keeps the ``_virtual_rows``
666
+ # signature backward-compatible with the tests added by Agent 3.1.
667
+ _virtual_capacity_override: dict[str, float] = {}
668
+
669
+
670
+ # ---------------------------------------------------------------------------
671
+ # The main build function
672
+ # ---------------------------------------------------------------------------
673
+
674
+
675
+ def build_region_directory(
676
+ input_dir: Path,
677
+ output_dir: Path,
678
+ region: str,
679
+ *,
680
+ all_regions: list[str] | None = None,
681
+ ) -> dict:
682
+ """Build a filtered ``input_region_<region>/`` directory.
683
+
684
+ Parameters
685
+ ----------
686
+ input_dir
687
+ The fully-populated ``input/`` directory produced by
688
+ ``write_input``.
689
+ output_dir
690
+ Destination directory for the filtered copy, e.g.
691
+ ``work_folder / "input_region_region_A"``.
692
+ region
693
+ The decomposition region group name.
694
+ all_regions
695
+ The full list of decomposition-region group names. If ``None``,
696
+ falls back to the ``region_*`` naming convention (LH2 fixture).
697
+
698
+ Returns a dict with entries:
699
+
700
+ * ``region``: the region name
701
+ * ``half_flows``: list of :class:`HalfFlow`
702
+ * ``kept_nodes``, ``kept_units``, ``kept_connections``: sets
703
+ """
704
+ input_dir = Path(input_dir)
705
+ output_dir = Path(output_dir)
706
+
707
+ mem = discover_region_membership(input_dir, region)
708
+ if all_regions is not None:
709
+ mem.all_regions = set(all_regions) | {region}
710
+ # Re-populate other_region_* given the authoritative list.
711
+ group_nodes = _read_two_col_dict(input_dir / "group__node.csv")
712
+ group_processes = _read_two_col_dict(input_dir / "group__process.csv")
713
+ units = set(_read_single_col(input_dir / "process_unit.csv"))
714
+ connections = set(_read_single_col(input_dir / "process_connection.csv"))
715
+ mem.other_region_nodes = set()
716
+ mem.other_region_units = set()
717
+ mem.other_region_connections = set()
718
+ for other in mem.all_regions:
719
+ if other == region:
720
+ continue
721
+ mem.other_region_nodes.update(group_nodes.get(other, []))
722
+ other_procs = set(group_processes.get(other, []))
723
+ mem.other_region_units.update(other_procs & units)
724
+ mem.other_region_connections.update(other_procs & connections)
725
+
726
+ half_flows = classify_half_flows(input_dir, mem)
727
+ keep = _resolve_keep_sets(input_dir, mem, half_flows)
728
+
729
+ # Cross-region connection names: these are REMOVED from the filtered
730
+ # output (replaced by virtual half-flow connections).
731
+ cross_region_conns = {hf.original_connection for hf in half_flows}
732
+
733
+ # Populate per-pipe capacity override so _virtual_rows can inject a
734
+ # matching ``existing`` line into the filtered ``p_process.csv``.
735
+ # Without this, p_flow_max gates v_flow[hf_*, ...] at zero and
736
+ # the master's trade pin has no effect on the solve.
737
+ _virtual_capacity_override.clear()
738
+ try:
739
+ _, p_process_rows = _read_csv_rows(input_dir / "p_process.csv")
740
+ for row in p_process_rows:
741
+ if len(row) >= 3 and row[0] in cross_region_conns and row[1] == "existing":
742
+ try:
743
+ _virtual_capacity_override[row[0]] = float(row[2])
744
+ except ValueError:
745
+ continue
746
+ except Exception: # noqa: BLE001 — defensive; fall back to defaults.
747
+ pass
748
+
749
+ # Ensure output dir exists and is empty.
750
+ output_dir.mkdir(parents=True, exist_ok=True)
751
+
752
+ # Walk every CSV in input_dir. For each, decide:
753
+ # * if it has a column spec: filter rows and optionally inject virtuals
754
+ # * else: copy verbatim
755
+ for src in sorted(input_dir.iterdir()):
756
+ if not src.is_file():
757
+ continue
758
+ if src.suffix.lower() != ".csv":
759
+ shutil.copy2(src, output_dir / src.name)
760
+ continue
761
+
762
+ dst = output_dir / src.name
763
+ header, rows = _read_csv_rows(src)
764
+ if not header:
765
+ # Empty file — copy as is.
766
+ shutil.copy2(src, dst)
767
+ continue
768
+
769
+ spec = _infer_column_spec_from_header(header, src.name)
770
+
771
+ if not spec:
772
+ # No spatial filtering needed — copy verbatim. Still inject
773
+ # virtual rows if this file has an entry.
774
+ virtuals = _virtual_rows(half_flows, src.name, header)
775
+ if virtuals:
776
+ _write_csv_rows(dst, header, rows + virtuals)
777
+ else:
778
+ shutil.copy2(src, dst)
779
+ continue
780
+
781
+ # Filter rows: every (col_index, kind) in spec must be satisfied.
782
+ kept_rows: list[list[str]] = []
783
+ for row in rows:
784
+ # Defensive: pad short rows.
785
+ keep_row = True
786
+ for col_idx, kind in spec:
787
+ if col_idx >= len(row):
788
+ continue
789
+ val = row[col_idx]
790
+ if kind == "process":
791
+ # Also drop cross-region original connections.
792
+ if val in cross_region_conns:
793
+ keep_row = False
794
+ break
795
+ if val not in keep.processes:
796
+ keep_row = False
797
+ break
798
+ elif kind == "connection":
799
+ if val in cross_region_conns:
800
+ keep_row = False
801
+ break
802
+ if val not in keep.connections:
803
+ keep_row = False
804
+ break
805
+ elif kind == "unit":
806
+ if val not in keep.units:
807
+ keep_row = False
808
+ break
809
+ elif kind == "node":
810
+ if val not in keep.nodes:
811
+ keep_row = False
812
+ break
813
+ elif kind == "entity":
814
+ if val in cross_region_conns:
815
+ keep_row = False
816
+ break
817
+ if val not in keep.entities:
818
+ keep_row = False
819
+ break
820
+ if keep_row:
821
+ kept_rows.append(row)
822
+
823
+ virtuals = _virtual_rows(half_flows, src.name, header)
824
+ _write_csv_rows(dst, header, kept_rows + virtuals)
825
+
826
+ return {
827
+ "region": region,
828
+ "half_flows": half_flows,
829
+ "kept_nodes": keep.nodes,
830
+ "kept_units": keep.units,
831
+ "kept_connections": keep.connections,
832
+ }
833
+
834
+
835
+ def write_region_coupling_manifest(
836
+ work_folder: Path,
837
+ results: Iterable[dict],
838
+ ) -> Path:
839
+ """Write ``solve_data/region_coupling.csv`` listing coupling variables
840
+ across all produced regions.
841
+
842
+ Columns: ``region,process,side,virtual_node``.
843
+
844
+ Returns the path written.
845
+ """
846
+ work_folder = Path(work_folder)
847
+ solve_data = work_folder / "solve_data"
848
+ solve_data.mkdir(parents=True, exist_ok=True)
849
+ path = solve_data / "region_coupling.csv"
850
+ rows: list[list[str]] = []
851
+ for res in results:
852
+ for hf in res.get("half_flows", []):
853
+ rows.append([hf.region, hf.original_connection, hf.side, hf.virtual_node])
854
+ _write_csv_rows(path, ["region", "process", "side", "virtual_node"], rows)
855
+ return path
856
+
857
+
858
+ # ---------------------------------------------------------------------------
859
+ # Step 2.6 — Provider-based port
860
+ # ---------------------------------------------------------------------------
861
+ #
862
+ # Below: an in-memory parallel of the disk-walking machinery above. Both
863
+ # share the same filter rules (``_EXPLICIT_SPECS`` /
864
+ # ``_infer_column_spec_from_header`` / ``_virtual_rows``); the difference
865
+ # is the data carrier. Disk walkers read CSV bytes through ``csv.reader``
866
+ # and write CSVs via ``csv.writer``; the Provider port consumes
867
+ # :class:`polars.DataFrame` frames out of a :class:`FlexDataProvider` and
868
+ # emits filtered frames into a new region-scoped Provider.
869
+ #
870
+ # The CLI deliverable contract (``--region GROUP`` writes
871
+ # ``input_region_<GROUP>/<file>.csv``) still terminates in a disk
872
+ # directory — but the materialisation is one downward
873
+ # ``snapshot_processed_inputs`` from the region-Provider in
874
+ # ``region_decomposition.write_input_for_region``, NOT a snapshot of the
875
+ # full monolithic input/ followed by a disk-walk filter. No bridge.
876
+
877
+
878
+ def _frame_to_csv_rows(
879
+ df: pl.DataFrame,
880
+ ) -> tuple[list[str], list[list[str]]]:
881
+ """Return ``(header, rows_as_strings)`` for a Polars frame.
882
+
883
+ Mirrors :func:`_read_csv_rows`'s "list of strings" representation so
884
+ the rest of the filter machinery (which is string-equality-based)
885
+ operates identically on disk and Provider data.
886
+
887
+ Numeric values are stringified with ``str(v)``; ``None`` becomes an
888
+ empty string (mirroring how empty CSV cells round-trip through
889
+ ``csv.reader``).
890
+ """
891
+ if df.height == 0:
892
+ return list(df.columns), []
893
+ header = list(df.columns)
894
+ rows: list[list[str]] = []
895
+ for row in df.iter_rows():
896
+ rows.append(["" if v is None else str(v) for v in row])
897
+ return header, rows
898
+
899
+
900
+ def _rows_to_frame(
901
+ header: list[str], rows: Iterable[list[str]],
902
+ ) -> pl.DataFrame:
903
+ """Inverse of :func:`_frame_to_csv_rows` — assemble a Polars frame.
904
+
905
+ Every column is typed as ``Utf8``; downstream loaders coerce to
906
+ numeric where required. This matches the round-trip a CSV would
907
+ take through ``pl.read_csv`` with default settings on these
908
+ string-keyed entity tables — column types are inferred lazily by
909
+ consumers.
910
+ """
911
+ if not header:
912
+ return pl.DataFrame()
913
+ rows_list = list(rows)
914
+ if not rows_list:
915
+ # Empty body with explicit columns.
916
+ return pl.DataFrame({col: pl.Series(col, [], dtype=pl.Utf8) for col in header})
917
+ # Pad/truncate to header width so polars accepts uniform rows.
918
+ width = len(header)
919
+ padded = [
920
+ row[:width] + [""] * (width - len(row)) if len(row) < width else row[:width]
921
+ for row in rows_list
922
+ ]
923
+ cols = {h: [r[i] for r in padded] for i, h in enumerate(header)}
924
+ return pl.DataFrame({h: pl.Series(h, vals, dtype=pl.Utf8) for h, vals in cols.items()})
925
+
926
+
927
+ def _provider_csv_rows(
928
+ provider: "FlexDataProvider", name: str,
929
+ ) -> tuple[list[str], list[list[str]]]:
930
+ """Provider-side analogue of :func:`_read_csv_rows`.
931
+
932
+ Looks up ``input/<name>`` first, falls back to bare ``<name>``.
933
+ Returns ``([], [])`` for missing frames (matches the disk helper's
934
+ "absent file → empty" semantics).
935
+ """
936
+ df = provider.get(f"input/{name}")
937
+ if df is None:
938
+ df = provider.get(name)
939
+ if df is None:
940
+ return [], []
941
+ return _frame_to_csv_rows(df)
942
+
943
+
944
+ def _provider_single_col(provider: "FlexDataProvider", name: str) -> list[str]:
945
+ _, rows = _provider_csv_rows(provider, name)
946
+ return [r[0] for r in rows if r and r[0] != ""]
947
+
948
+
949
+ def _provider_two_col_dict(
950
+ provider: "FlexDataProvider", name: str,
951
+ ) -> dict[str, list[str]]:
952
+ _, rows = _provider_csv_rows(provider, name)
953
+ out: dict[str, list[str]] = {}
954
+ for r in rows:
955
+ if len(r) < 2:
956
+ continue
957
+ out.setdefault(r[0], []).append(r[1])
958
+ return out
959
+
960
+
961
+ def _provider_connection_endpoints(
962
+ provider: "FlexDataProvider",
963
+ ) -> dict[str, tuple[str, str]]:
964
+ """Provider-side analogue of :func:`_read_connection_endpoints`."""
965
+ connections = set(_provider_single_col(provider, "process_connection.csv"))
966
+ src_of: dict[str, str] = {}
967
+ _, src_rows = _provider_csv_rows(provider, "process__source.csv")
968
+ for r in src_rows:
969
+ if len(r) >= 2 and r[0] in connections:
970
+ src_of[r[0]] = r[1]
971
+ snk_of: dict[str, str] = {}
972
+ _, snk_rows = _provider_csv_rows(provider, "process__sink.csv")
973
+ for r in snk_rows:
974
+ if len(r) >= 2 and r[0] in connections:
975
+ snk_of[r[0]] = r[1]
976
+ return {
977
+ conn: (src_of[conn], snk_of[conn])
978
+ for conn in connections
979
+ if conn in src_of and conn in snk_of
980
+ }
981
+
982
+
983
+ def discover_region_membership_from_provider(
984
+ provider: "FlexDataProvider", region: str,
985
+ ) -> RegionMembership:
986
+ """Provider-side analogue of :func:`discover_region_membership`."""
987
+ group_nodes = _provider_two_col_dict(provider, "group__node.csv")
988
+ group_processes = _provider_two_col_dict(provider, "group__process.csv")
989
+
990
+ all_groups = set(_provider_single_col(provider, "group.csv"))
991
+ region_like = {g for g in all_groups if g.startswith("region_")}
992
+ if region not in region_like:
993
+ region_like.add(region)
994
+
995
+ mem = RegionMembership(region=region, all_regions=region_like)
996
+ mem.nodes = set(group_nodes.get(region, []))
997
+ in_region_processes = set(group_processes.get(region, []))
998
+
999
+ units = set(_provider_single_col(provider, "process_unit.csv"))
1000
+ connections = set(_provider_single_col(provider, "process_connection.csv"))
1001
+ mem.units = in_region_processes & units
1002
+ mem.connections = in_region_processes & connections
1003
+
1004
+ for other in region_like:
1005
+ if other == region:
1006
+ continue
1007
+ mem.other_region_nodes.update(group_nodes.get(other, []))
1008
+ other_procs = set(group_processes.get(other, []))
1009
+ mem.other_region_units.update(other_procs & units)
1010
+ mem.other_region_connections.update(other_procs & connections)
1011
+ return mem
1012
+
1013
+
1014
+ def classify_half_flows_from_provider(
1015
+ provider: "FlexDataProvider", mem: RegionMembership,
1016
+ ) -> list[HalfFlow]:
1017
+ """Provider-side analogue of :func:`classify_half_flows`."""
1018
+ endpoints = _provider_connection_endpoints(provider)
1019
+ half_flows: list[HalfFlow] = []
1020
+ for conn, (src, snk) in endpoints.items():
1021
+ src_in = src in mem.nodes
1022
+ snk_in = snk in mem.nodes
1023
+ src_out = src in mem.other_region_nodes
1024
+ snk_out = snk in mem.other_region_nodes
1025
+ if src_in and snk_out:
1026
+ virtual = f"{conn}__export__{mem.region}"
1027
+ half_flows.append(HalfFlow(
1028
+ original_connection=conn,
1029
+ region=mem.region,
1030
+ side="export",
1031
+ in_region_node=src,
1032
+ virtual_node=virtual,
1033
+ virtual_connection=f"hf_{conn}__export__{mem.region}",
1034
+ ))
1035
+ elif snk_in and src_out:
1036
+ virtual = f"{conn}__import__{mem.region}"
1037
+ half_flows.append(HalfFlow(
1038
+ original_connection=conn,
1039
+ region=mem.region,
1040
+ side="import",
1041
+ in_region_node=snk,
1042
+ virtual_node=virtual,
1043
+ virtual_connection=f"hf_{conn}__import__{mem.region}",
1044
+ ))
1045
+ return half_flows
1046
+
1047
+
1048
+ def _resolve_keep_sets_from_provider(
1049
+ provider: "FlexDataProvider",
1050
+ mem: RegionMembership,
1051
+ half_flows: list[HalfFlow],
1052
+ ) -> _KeepSets:
1053
+ """Provider-side analogue of :func:`_resolve_keep_sets`."""
1054
+ all_nodes = set(_provider_single_col(provider, "node.csv"))
1055
+ all_units = set(_provider_single_col(provider, "process_unit.csv"))
1056
+ all_connections = set(_provider_single_col(provider, "process_connection.csv"))
1057
+
1058
+ shared_nodes = all_nodes - mem.nodes - mem.other_region_nodes
1059
+ shared_units = all_units - mem.units - mem.other_region_units
1060
+ shared_connections = (
1061
+ all_connections - mem.connections - mem.other_region_connections
1062
+ )
1063
+
1064
+ kept_nodes = mem.nodes | shared_nodes
1065
+ kept_units = mem.units | shared_units
1066
+ kept_connections = mem.connections | shared_connections
1067
+
1068
+ cross_region_conns = {hf.original_connection for hf in half_flows}
1069
+ kept_connections -= cross_region_conns
1070
+
1071
+ virtual_nodes = {hf.virtual_node for hf in half_flows}
1072
+ virtual_conns = {hf.virtual_connection for hf in half_flows}
1073
+ kept_nodes |= virtual_nodes
1074
+ kept_connections |= virtual_conns
1075
+
1076
+ kept_processes = kept_units | kept_connections
1077
+ kept_entities = kept_nodes | kept_processes
1078
+
1079
+ return _KeepSets(
1080
+ nodes=kept_nodes,
1081
+ units=kept_units,
1082
+ connections=kept_connections,
1083
+ processes=kept_processes,
1084
+ entities=kept_entities,
1085
+ )
1086
+
1087
+
1088
+ def build_region_provider(
1089
+ provider: "FlexDataProvider",
1090
+ region: str,
1091
+ *,
1092
+ all_regions: list[str] | None = None,
1093
+ ) -> tuple["FlexDataProvider", dict]:
1094
+ """Provider-in/Provider-out analogue of :func:`build_region_directory`.
1095
+
1096
+ Operates entirely in-memory: consumes the cascade-input *provider*
1097
+ (every ``input/<name>`` frame populated by
1098
+ :func:`flextool.input_derivation.run`) and returns
1099
+ ``(region_provider, result)``:
1100
+
1101
+ * ``region_provider`` carries the same set of keys filtered to the
1102
+ region's members, with virtual half-flow rows injected where
1103
+ needed. CLI mode materialises this with one call to
1104
+ ``region_provider.snapshot_processed_inputs(output_dir)``.
1105
+ * ``result`` is the same metadata dict shape returned by the disk
1106
+ port (``region``, ``half_flows``, ``kept_nodes``,
1107
+ ``kept_units``, ``kept_connections``) so downstream code is
1108
+ drop-in.
1109
+
1110
+ Filter rules are shared with :func:`build_region_directory`
1111
+ (``_EXPLICIT_SPECS`` / ``_infer_column_spec_from_header`` /
1112
+ ``_virtual_rows``); only the data carrier changes.
1113
+ """
1114
+ from flextool.engine_polars._flex_data_provider import FlexDataProvider
1115
+
1116
+ mem = discover_region_membership_from_provider(provider, region)
1117
+ if all_regions is not None:
1118
+ mem.all_regions = set(all_regions) | {region}
1119
+ group_nodes = _provider_two_col_dict(provider, "group__node.csv")
1120
+ group_processes = _provider_two_col_dict(provider, "group__process.csv")
1121
+ units = set(_provider_single_col(provider, "process_unit.csv"))
1122
+ connections = set(_provider_single_col(provider, "process_connection.csv"))
1123
+ mem.other_region_nodes = set()
1124
+ mem.other_region_units = set()
1125
+ mem.other_region_connections = set()
1126
+ for other in mem.all_regions:
1127
+ if other == region:
1128
+ continue
1129
+ mem.other_region_nodes.update(group_nodes.get(other, []))
1130
+ other_procs = set(group_processes.get(other, []))
1131
+ mem.other_region_units.update(other_procs & units)
1132
+ mem.other_region_connections.update(other_procs & connections)
1133
+
1134
+ half_flows = classify_half_flows_from_provider(provider, mem)
1135
+ keep = _resolve_keep_sets_from_provider(provider, mem, half_flows)
1136
+
1137
+ cross_region_conns = {hf.original_connection for hf in half_flows}
1138
+
1139
+ # Same per-pipe capacity override as the disk path: walk p_process to
1140
+ # find each cross-region connection's ``existing`` value so the
1141
+ # virtual half-flow inherits a non-zero p_flow_max.
1142
+ _virtual_capacity_override.clear()
1143
+ try:
1144
+ _, p_process_rows = _provider_csv_rows(provider, "p_process.csv")
1145
+ for row in p_process_rows:
1146
+ if len(row) >= 3 and row[0] in cross_region_conns and row[1] == "existing":
1147
+ try:
1148
+ _virtual_capacity_override[row[0]] = float(row[2])
1149
+ except ValueError:
1150
+ continue
1151
+ except Exception: # noqa: BLE001 — defensive; fall back to defaults.
1152
+ pass
1153
+
1154
+ region_provider = FlexDataProvider()
1155
+
1156
+ # Iterate every ``input/<name>`` frame in the source Provider and
1157
+ # produce a filtered/virtual-augmented frame in the region Provider.
1158
+ # Frames stored under other parents (e.g. ``solve_data/*``) are
1159
+ # passed through unchanged — region filtering only applies to the
1160
+ # entity-set + parameter CSVs under ``input/``.
1161
+ seen_filenames: set[str] = set()
1162
+ for key, frame in provider.items():
1163
+ parent = key.split("/", 1)[0] if "/" in key else ""
1164
+ if parent != "input":
1165
+ # Non-input frames are copied verbatim.
1166
+ region_provider.put(key, frame)
1167
+ continue
1168
+
1169
+ stem = key.split("/", 1)[1] if "/" in key else key
1170
+ filename = f"{stem}.csv"
1171
+ seen_filenames.add(filename)
1172
+ header, rows = _frame_to_csv_rows(frame)
1173
+ if not header:
1174
+ region_provider.put(key, frame)
1175
+ continue
1176
+ spec = _infer_column_spec_from_header(header, filename)
1177
+ if not spec:
1178
+ virtuals = _virtual_rows(half_flows, filename, header)
1179
+ if virtuals:
1180
+ region_provider.put(
1181
+ key, _rows_to_frame(header, rows + virtuals),
1182
+ )
1183
+ else:
1184
+ region_provider.put(key, frame)
1185
+ continue
1186
+ kept_rows: list[list[str]] = []
1187
+ for row in rows:
1188
+ keep_row = True
1189
+ for col_idx, kind in spec:
1190
+ if col_idx >= len(row):
1191
+ continue
1192
+ val = row[col_idx]
1193
+ if kind == "process":
1194
+ if val in cross_region_conns or val not in keep.processes:
1195
+ keep_row = False
1196
+ break
1197
+ elif kind == "connection":
1198
+ if val in cross_region_conns or val not in keep.connections:
1199
+ keep_row = False
1200
+ break
1201
+ elif kind == "unit":
1202
+ if val not in keep.units:
1203
+ keep_row = False
1204
+ break
1205
+ elif kind == "node":
1206
+ if val not in keep.nodes:
1207
+ keep_row = False
1208
+ break
1209
+ elif kind == "entity":
1210
+ if val in cross_region_conns or val not in keep.entities:
1211
+ keep_row = False
1212
+ break
1213
+ if keep_row:
1214
+ kept_rows.append(row)
1215
+ virtuals = _virtual_rows(half_flows, filename, header)
1216
+ region_provider.put(key, _rows_to_frame(header, kept_rows + virtuals))
1217
+
1218
+ # Some files referenced by ``_virtual_rows`` (e.g. ``process_method``
1219
+ # or ``p_process``) might not be in the source Provider at all (the
1220
+ # fixture didn't populate them). ``_virtual_rows`` skips those; we
1221
+ # already passed through every key we saw, so nothing to do here.
1222
+ _ = seen_filenames # diagnostic hook — kept for future debugging.
1223
+
1224
+ return region_provider, {
1225
+ "region": region,
1226
+ "half_flows": half_flows,
1227
+ "kept_nodes": keep.nodes,
1228
+ "kept_units": keep.units,
1229
+ "kept_connections": keep.connections,
1230
+ }
1231
+
1232
+
1233
+ def write_region_coupling_manifest_to_provider(
1234
+ provider: "FlexDataProvider",
1235
+ results: Iterable[dict],
1236
+ ) -> None:
1237
+ """Provider-side analogue of :func:`write_region_coupling_manifest`.
1238
+
1239
+ Stores ``solve_data/region_coupling`` into *provider* with the same
1240
+ four-column layout (``region, process, side, virtual_node``). The
1241
+ CLI driver materialises it via ``snapshot_processed_inputs``.
1242
+ """
1243
+ rows: list[tuple[str, str, str, str]] = []
1244
+ for res in results:
1245
+ for hf in res.get("half_flows", []):
1246
+ rows.append((hf.region, hf.original_connection, hf.side, hf.virtual_node))
1247
+ if rows:
1248
+ df = pl.DataFrame({
1249
+ "region": [r[0] for r in rows],
1250
+ "process": [r[1] for r in rows],
1251
+ "side": [r[2] for r in rows],
1252
+ "virtual_node": [r[3] for r in rows],
1253
+ })
1254
+ else:
1255
+ df = pl.DataFrame({
1256
+ "region": pl.Series("region", [], dtype=pl.Utf8),
1257
+ "process": pl.Series("process", [], dtype=pl.Utf8),
1258
+ "side": pl.Series("side", [], dtype=pl.Utf8),
1259
+ "virtual_node": pl.Series("virtual_node", [], dtype=pl.Utf8),
1260
+ })
1261
+ provider.put("solve_data/region_coupling", df)