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.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- 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)
|