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,451 @@
|
|
|
1
|
+
"""Precision cleanup and near-duplicate detection for input CSV parameters.
|
|
2
|
+
|
|
3
|
+
This module implements the Agent 7 precision cleanup pass for the
|
|
4
|
+
LP-scaling project (see
|
|
5
|
+
``~/.claude/projects/-home-jkiviluo-sources-flextool/memory/project_lp_scaling_2026-04.md``).
|
|
6
|
+
|
|
7
|
+
Two user-facing entry points:
|
|
8
|
+
|
|
9
|
+
* :func:`round_to_sigfigs` — round a float to N significant figures.
|
|
10
|
+
Used by :mod:`flextool.spinedb_backend._backend` (structural-parameter
|
|
11
|
+
precision policy) to collapse
|
|
12
|
+
accumulated floating-point noise (e.g. ``1/3`` stored as
|
|
13
|
+
``0.3333333333333333``) so HiGHS' ``mip_detect_symmetry`` presolver
|
|
14
|
+
collapses structurally-identical coefficients.
|
|
15
|
+
|
|
16
|
+
* :func:`find_near_duplicates` / :func:`report_near_duplicates` — a
|
|
17
|
+
diagnostic that clusters nearly-equal values within each parameter
|
|
18
|
+
CSV. Purpose: point users at cases where symmetry detection could
|
|
19
|
+
benefit if the values were made *exactly* equal. Silent by default;
|
|
20
|
+
only runs when ``--report-near-duplicates`` (or the corresponding
|
|
21
|
+
env var) is set.
|
|
22
|
+
|
|
23
|
+
Design constraints:
|
|
24
|
+
|
|
25
|
+
* Stdlib only (``math``, ``os``, ``csv``, ``pathlib``). No new deps.
|
|
26
|
+
* Default behaviour must be unchanged — a run with no CLI flag, no
|
|
27
|
+
env var, and ``precision_digits=0`` must round-trip every CSV
|
|
28
|
+
byte-for-byte with the pre-Agent-7 state.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import csv
|
|
34
|
+
import math
|
|
35
|
+
import os
|
|
36
|
+
import warnings
|
|
37
|
+
from collections.abc import Iterable, Iterator
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Optional
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Precision env-var resolution
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
PRECISION_ENV_VAR = "FLEXTOOL_PRECISION_DIGITS"
|
|
47
|
+
"""Environment variable fallback for the ``--precision-digits`` CLI flag."""
|
|
48
|
+
|
|
49
|
+
REPORT_ENV_VAR = "FLEXTOOL_REPORT_NEAR_DUPS"
|
|
50
|
+
"""Environment variable fallback for ``--report-near-duplicates``."""
|
|
51
|
+
|
|
52
|
+
DEFAULT_PRECISION_DIGITS = 10
|
|
53
|
+
"""Default significant-figure count when the user enables rounding by
|
|
54
|
+
setting the CLI flag (or env var) to a non-zero value but gives no explicit
|
|
55
|
+
digit count. The CLI passes ``10`` explicitly, so this is only the
|
|
56
|
+
fallback used by library callers / tests."""
|
|
57
|
+
|
|
58
|
+
_WARNED_HIGH_PRECISION = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_precision_digits(cli_value: Optional[int]) -> int:
|
|
62
|
+
"""Resolve the effective precision setting.
|
|
63
|
+
|
|
64
|
+
Precedence: explicit CLI value (``--precision-digits N`` or None when
|
|
65
|
+
the flag is not passed) overrides the env var. ``0`` or a negative
|
|
66
|
+
value disables rounding (passthrough). Values > 15 exceed
|
|
67
|
+
``float64`` precision — emits a one-shot warning and returns 0 so
|
|
68
|
+
rounding is a no-op.
|
|
69
|
+
|
|
70
|
+
Returns the effective digit count, or 0 when rounding is disabled.
|
|
71
|
+
"""
|
|
72
|
+
global _WARNED_HIGH_PRECISION
|
|
73
|
+
if cli_value is None:
|
|
74
|
+
raw = os.environ.get(PRECISION_ENV_VAR, "").strip()
|
|
75
|
+
if not raw:
|
|
76
|
+
return 0
|
|
77
|
+
try:
|
|
78
|
+
value = int(raw)
|
|
79
|
+
except ValueError:
|
|
80
|
+
warnings.warn(
|
|
81
|
+
f"{PRECISION_ENV_VAR}={raw!r} is not an integer; "
|
|
82
|
+
f"ignoring and disabling precision rounding.",
|
|
83
|
+
RuntimeWarning,
|
|
84
|
+
stacklevel=2,
|
|
85
|
+
)
|
|
86
|
+
return 0
|
|
87
|
+
else:
|
|
88
|
+
value = int(cli_value)
|
|
89
|
+
if value <= 0:
|
|
90
|
+
return 0
|
|
91
|
+
if value > 15:
|
|
92
|
+
if not _WARNED_HIGH_PRECISION:
|
|
93
|
+
print(
|
|
94
|
+
f"WARNING: precision_digits={value} is higher than "
|
|
95
|
+
f"float64 precision, no-op (passthrough)."
|
|
96
|
+
)
|
|
97
|
+
_WARNED_HIGH_PRECISION = True
|
|
98
|
+
return 0
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_report_near_duplicates(cli_flag: bool) -> bool:
|
|
103
|
+
"""True if the CLI flag is set OR the env var resolves truthy."""
|
|
104
|
+
if cli_flag:
|
|
105
|
+
return True
|
|
106
|
+
raw = os.environ.get(REPORT_ENV_VAR, "").strip().lower()
|
|
107
|
+
return raw in ("1", "true", "yes", "on")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# round_to_sigfigs
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def round_to_sigfigs(value: float, digits: int) -> float:
|
|
116
|
+
"""Round *value* to *digits* significant figures.
|
|
117
|
+
|
|
118
|
+
Edge cases:
|
|
119
|
+
|
|
120
|
+
* ``0.0`` stays ``0.0`` (sign preserved).
|
|
121
|
+
* ``nan`` / ``inf`` / ``-inf`` pass through unchanged.
|
|
122
|
+
* Very small values (``abs(value) < 1e-300``) round to ``0.0`` —
|
|
123
|
+
they would overflow the exponent arithmetic.
|
|
124
|
+
* ``digits <= 0`` returns *value* unchanged (pass-through sentinel).
|
|
125
|
+
* Negative values round symmetrically around 0.
|
|
126
|
+
|
|
127
|
+
The classical formula is
|
|
128
|
+
``scale = 10 ** (floor(log10(|value|)) + 1 - digits)``;
|
|
129
|
+
rounding via Python's builtin ``round(value, ndigits)`` avoids the
|
|
130
|
+
textbook ``round(v/scale)*scale`` double-rounding artefact (e.g.
|
|
131
|
+
``0.3333... @ 4 sig figs`` would otherwise come back as
|
|
132
|
+
``0.33330000000000004`` because ``10**-4`` is not exactly
|
|
133
|
+
representable). ``ndigits`` is negative when rounding above the
|
|
134
|
+
decimal point, which ``round`` handles natively.
|
|
135
|
+
"""
|
|
136
|
+
if digits <= 0:
|
|
137
|
+
return value
|
|
138
|
+
if value == 0.0 or not math.isfinite(value):
|
|
139
|
+
return value
|
|
140
|
+
abs_value = abs(value)
|
|
141
|
+
if abs_value < 1e-300:
|
|
142
|
+
# Sub-denormal — would blow up the exponent below.
|
|
143
|
+
return 0.0
|
|
144
|
+
# Position of the leading significant digit (base 10).
|
|
145
|
+
exponent = math.floor(math.log10(abs_value))
|
|
146
|
+
# Python's builtin ``round(x, ndigits)`` uses decimal-aware rounding
|
|
147
|
+
# that avoids the double-rounding artefact of the textbook
|
|
148
|
+
# ``round(x / scale) * scale`` (where 10**-4 cannot be represented
|
|
149
|
+
# exactly). We compute ``ndigits`` so that after rounding there are
|
|
150
|
+
# ``digits`` significant figures. For 0.3333... with digits=4, that
|
|
151
|
+
# is ``ndigits = digits - exponent - 1 = 4 - (-1) - 1 = 4`` → 0.3333.
|
|
152
|
+
ndigits = digits - exponent - 1
|
|
153
|
+
# ``round`` accepts negative ndigits for rounding to tens, hundreds, …
|
|
154
|
+
return round(value, ndigits)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# CSV-writer hook: format a scalar for the input/ CSV stream
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def format_scalar_for_csv(value, precision_digits: int) -> str:
|
|
163
|
+
"""Return the string form of *value* that should land in the CSV.
|
|
164
|
+
|
|
165
|
+
* When *value* is a float / int (or a string that parses as one)
|
|
166
|
+
and ``precision_digits > 0``, the numeric is rounded to that many
|
|
167
|
+
significant figures first.
|
|
168
|
+
* Integer values pass through untouched (``.is_integer()`` heuristic).
|
|
169
|
+
* Non-numeric strings (entity names, method names, ``"yes"``/``"no"``)
|
|
170
|
+
pass through ``str()`` unchanged.
|
|
171
|
+
|
|
172
|
+
Default behaviour (``precision_digits == 0``) is identical to
|
|
173
|
+
``str(value)`` — the pre-Agent-7 writer convention — so benchmarks
|
|
174
|
+
round-trip byte-for-byte.
|
|
175
|
+
"""
|
|
176
|
+
# Fast path: rounding disabled → exactly the old behaviour.
|
|
177
|
+
if precision_digits <= 0:
|
|
178
|
+
return str(value)
|
|
179
|
+
|
|
180
|
+
# True bools: "True"/"False" — pass through.
|
|
181
|
+
if isinstance(value, bool):
|
|
182
|
+
return str(value)
|
|
183
|
+
|
|
184
|
+
# Native numeric types.
|
|
185
|
+
if isinstance(value, (int, float)):
|
|
186
|
+
f = float(value)
|
|
187
|
+
if not math.isfinite(f):
|
|
188
|
+
return str(value)
|
|
189
|
+
# Integer values sidestep rounding so period indices, year
|
|
190
|
+
# counts, tier numbers etc. are never perturbed.
|
|
191
|
+
if isinstance(value, int) or f.is_integer():
|
|
192
|
+
return str(value)
|
|
193
|
+
rounded = round_to_sigfigs(f, precision_digits)
|
|
194
|
+
return _format_float(rounded)
|
|
195
|
+
|
|
196
|
+
# String that might encode a float (common path for map values
|
|
197
|
+
# flattened via convert_map_to_table).
|
|
198
|
+
if isinstance(value, str):
|
|
199
|
+
s = value.strip()
|
|
200
|
+
if not s:
|
|
201
|
+
return value
|
|
202
|
+
try:
|
|
203
|
+
f = float(s)
|
|
204
|
+
except ValueError:
|
|
205
|
+
return value # entity name, method string, etc.
|
|
206
|
+
if not math.isfinite(f):
|
|
207
|
+
return value
|
|
208
|
+
if f.is_integer() and s.lstrip("+-").isdigit():
|
|
209
|
+
# Pure integer literal — keep original form (no ".0" drift).
|
|
210
|
+
return value
|
|
211
|
+
rounded = round_to_sigfigs(f, precision_digits)
|
|
212
|
+
return _format_float(rounded)
|
|
213
|
+
|
|
214
|
+
return str(value)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _format_float(value: float) -> str:
|
|
218
|
+
"""Render a rounded float compactly.
|
|
219
|
+
|
|
220
|
+
Python's ``repr(float)`` uses the shortest round-trippable form,
|
|
221
|
+
which for a value that has been truncated to N sig figs produces a
|
|
222
|
+
clean N-digit representation (no trailing garbage). We prefer
|
|
223
|
+
``repr`` over ``str`` for Python 2/3 consistency — they are
|
|
224
|
+
identical in Python 3.
|
|
225
|
+
"""
|
|
226
|
+
return repr(value)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Near-duplicate detection
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def find_near_duplicates(
|
|
235
|
+
values: Iterable[float],
|
|
236
|
+
rel_tol: float = 1e-6,
|
|
237
|
+
) -> list[list[float]]:
|
|
238
|
+
"""Cluster *values* by within-cluster relative spread ≤ *rel_tol*.
|
|
239
|
+
|
|
240
|
+
Algorithm:
|
|
241
|
+
|
|
242
|
+
1. Drop NaN / non-finite entries; take absolute magnitude only for
|
|
243
|
+
the clustering distance (sign kept on each value for reporting).
|
|
244
|
+
2. Sort by magnitude.
|
|
245
|
+
3. Sweep: extend the current cluster as long as
|
|
246
|
+
``(v - cluster_min) / max(|cluster_min|, |v|, 1e-300) <= rel_tol``.
|
|
247
|
+
4. Drop singleton clusters; return clusters of size ≥ 2, sorted by
|
|
248
|
+
cluster size descending, then by representative magnitude.
|
|
249
|
+
|
|
250
|
+
The per-parameter scope means we look at e.g. all
|
|
251
|
+
``p_entity_unitsize`` values across entities in one pool; VOM
|
|
252
|
+
values across units in a separate pool. Cross-parameter matching
|
|
253
|
+
(where symmetry actually bites) is left for a future, deeper tool.
|
|
254
|
+
"""
|
|
255
|
+
if rel_tol < 0:
|
|
256
|
+
raise ValueError("rel_tol must be non-negative")
|
|
257
|
+
buckets: list[list[float]] = []
|
|
258
|
+
finite = [float(v) for v in values if math.isfinite(float(v))]
|
|
259
|
+
if not finite:
|
|
260
|
+
return buckets
|
|
261
|
+
# Sort so sign-flips never collapse into a single cluster. Sort by
|
|
262
|
+
# (sign, abs) — negatives together ascending by magnitude, then
|
|
263
|
+
# positives, and a cluster boundary is forced at every sign change.
|
|
264
|
+
def _sk(x: float) -> tuple[int, float]:
|
|
265
|
+
return (1 if x >= 0 else -1, abs(x))
|
|
266
|
+
finite.sort(key=_sk)
|
|
267
|
+
current: list[float] = [finite[0]]
|
|
268
|
+
cluster_min_abs = abs(finite[0])
|
|
269
|
+
cluster_sign = _sk(finite[0])[0]
|
|
270
|
+
for v in finite[1:]:
|
|
271
|
+
va = abs(v)
|
|
272
|
+
vs = _sk(v)[0]
|
|
273
|
+
denom = max(cluster_min_abs, va, 1e-300)
|
|
274
|
+
same_sign = vs == cluster_sign
|
|
275
|
+
if same_sign and (va - cluster_min_abs) / denom <= rel_tol:
|
|
276
|
+
current.append(v)
|
|
277
|
+
else:
|
|
278
|
+
if len(current) >= 2:
|
|
279
|
+
buckets.append(current)
|
|
280
|
+
current = [v]
|
|
281
|
+
cluster_min_abs = va
|
|
282
|
+
cluster_sign = vs
|
|
283
|
+
if len(current) >= 2:
|
|
284
|
+
buckets.append(current)
|
|
285
|
+
buckets.sort(key=lambda b: (-len(b), abs(b[0])))
|
|
286
|
+
return buckets
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Near-duplicate report
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _iter_numeric_columns(path: Path) -> Iterator[tuple[str, list[tuple[str, float]]]]:
|
|
295
|
+
"""Yield ``(column_name, [(entity_label, value), ...])`` per numeric
|
|
296
|
+
column in *path*.
|
|
297
|
+
|
|
298
|
+
Numeric detection: a column is numeric iff every non-empty row in
|
|
299
|
+
it parses as a finite float. Columns that look like period /
|
|
300
|
+
timestep / tier indices (pure integer literals throughout) are
|
|
301
|
+
skipped — symmetry is a numerical-coefficient phenomenon, and
|
|
302
|
+
structural indices would produce noisy "clusters" with zero
|
|
303
|
+
signal.
|
|
304
|
+
"""
|
|
305
|
+
try:
|
|
306
|
+
with path.open(newline="") as fh:
|
|
307
|
+
reader = csv.reader(fh)
|
|
308
|
+
try:
|
|
309
|
+
header = next(reader)
|
|
310
|
+
except StopIteration:
|
|
311
|
+
return
|
|
312
|
+
rows = list(reader)
|
|
313
|
+
except OSError:
|
|
314
|
+
return
|
|
315
|
+
if not header or not rows:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
n_cols = len(header)
|
|
319
|
+
# Per-column parse attempt.
|
|
320
|
+
parsed: list[list[Optional[float]]] = [[] for _ in range(n_cols)]
|
|
321
|
+
for row in rows:
|
|
322
|
+
for i in range(n_cols):
|
|
323
|
+
cell = row[i].strip() if i < len(row) else ""
|
|
324
|
+
if not cell:
|
|
325
|
+
parsed[i].append(None)
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
parsed[i].append(float(cell))
|
|
329
|
+
except ValueError:
|
|
330
|
+
parsed[i].append(None)
|
|
331
|
+
|
|
332
|
+
for i, col_values in enumerate(parsed):
|
|
333
|
+
non_null = [v for v in col_values if v is not None]
|
|
334
|
+
if len(non_null) < 2:
|
|
335
|
+
continue
|
|
336
|
+
# Column is numeric iff *every* non-empty cell parsed.
|
|
337
|
+
n_non_empty = sum(
|
|
338
|
+
1 for row in rows
|
|
339
|
+
if i < len(row) and row[i].strip() != ""
|
|
340
|
+
)
|
|
341
|
+
if len(non_null) != n_non_empty:
|
|
342
|
+
continue
|
|
343
|
+
# Skip pure-integer columns (period ids, tier numbers, etc.).
|
|
344
|
+
if all(float(v).is_integer() for v in non_null):
|
|
345
|
+
continue
|
|
346
|
+
# Build entity label = non-numeric columns joined.
|
|
347
|
+
labelled: list[tuple[str, float]] = []
|
|
348
|
+
for r_idx, row in enumerate(rows):
|
|
349
|
+
if col_values[r_idx] is None:
|
|
350
|
+
continue
|
|
351
|
+
label_parts = []
|
|
352
|
+
for j in range(min(len(row), n_cols)):
|
|
353
|
+
if j == i:
|
|
354
|
+
continue
|
|
355
|
+
cell = row[j].strip()
|
|
356
|
+
try:
|
|
357
|
+
float(cell)
|
|
358
|
+
continue # skip other numeric columns from the label
|
|
359
|
+
except ValueError:
|
|
360
|
+
if cell:
|
|
361
|
+
label_parts.append(cell)
|
|
362
|
+
labelled.append((" | ".join(label_parts) or f"row{r_idx}", col_values[r_idx]))
|
|
363
|
+
yield header[i], labelled
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def report_near_duplicates(
|
|
367
|
+
input_dir: Path,
|
|
368
|
+
top: int = 10,
|
|
369
|
+
rel_tol: float = 1e-6,
|
|
370
|
+
) -> None:
|
|
371
|
+
"""Scan every CSV in *input_dir* and print the top *top* clusters.
|
|
372
|
+
|
|
373
|
+
Output format (one block per cluster)::
|
|
374
|
+
|
|
375
|
+
near-dup [<size>×] <csv>:<column> value=<repr> spread=<Δ> rel=<r>
|
|
376
|
+
entities: <ent1>, <ent2>, <ent3>, ...
|
|
377
|
+
|
|
378
|
+
Never fails the run: any per-file error is caught and logged. A
|
|
379
|
+
final summary line reports the total number of clusters found.
|
|
380
|
+
"""
|
|
381
|
+
input_dir = Path(input_dir)
|
|
382
|
+
if not input_dir.is_dir():
|
|
383
|
+
print(
|
|
384
|
+
f"[precision] report_near_duplicates: {input_dir} is not a "
|
|
385
|
+
f"directory; nothing to scan."
|
|
386
|
+
)
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
all_clusters: list[tuple[int, str, str, list[tuple[str, float]]]] = []
|
|
390
|
+
# (size, csv_file, column, [(entity, value), ...])
|
|
391
|
+
|
|
392
|
+
for csv_path in sorted(input_dir.glob("*.csv")):
|
|
393
|
+
try:
|
|
394
|
+
for column, labelled_values in _iter_numeric_columns(csv_path):
|
|
395
|
+
raw_values = [v for _, v in labelled_values]
|
|
396
|
+
clusters = find_near_duplicates(raw_values, rel_tol=rel_tol)
|
|
397
|
+
if not clusters:
|
|
398
|
+
continue
|
|
399
|
+
# Back-map each cluster value to its (label, value) tuple.
|
|
400
|
+
by_value: dict[float, list[str]] = {}
|
|
401
|
+
for label, v in labelled_values:
|
|
402
|
+
by_value.setdefault(v, []).append(label)
|
|
403
|
+
for cluster in clusters:
|
|
404
|
+
lv: list[tuple[str, float]] = []
|
|
405
|
+
used: dict[float, int] = {}
|
|
406
|
+
for v in cluster:
|
|
407
|
+
labels = by_value.get(v, [])
|
|
408
|
+
idx = used.get(v, 0)
|
|
409
|
+
label = labels[idx] if idx < len(labels) else f"value={v}"
|
|
410
|
+
used[v] = idx + 1
|
|
411
|
+
lv.append((label, v))
|
|
412
|
+
all_clusters.append(
|
|
413
|
+
(len(cluster), csv_path.name, column, lv)
|
|
414
|
+
)
|
|
415
|
+
except Exception as exc: # diagnostic only — never fail the run
|
|
416
|
+
print(
|
|
417
|
+
f"[precision] skipping {csv_path.name}: {exc}"
|
|
418
|
+
)
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
# Sort: largest cluster first, then by CSV/column for stable output.
|
|
422
|
+
all_clusters.sort(key=lambda c: (-c[0], c[1], c[2]))
|
|
423
|
+
if not all_clusters:
|
|
424
|
+
print(
|
|
425
|
+
f"[precision] no near-duplicate clusters found in {input_dir} "
|
|
426
|
+
f"(rel_tol={rel_tol:g})."
|
|
427
|
+
)
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
shown = all_clusters[:top]
|
|
431
|
+
for size, csv_file, column, entries in shown:
|
|
432
|
+
values = [v for _, v in entries]
|
|
433
|
+
lo = min(values)
|
|
434
|
+
hi = max(values)
|
|
435
|
+
spread = hi - lo
|
|
436
|
+
mag = max(abs(lo), abs(hi), 1e-300)
|
|
437
|
+
rel = spread / mag
|
|
438
|
+
rep = entries[0][1]
|
|
439
|
+
sample_names = [label for label, _ in entries[:3]]
|
|
440
|
+
print(
|
|
441
|
+
f"near-dup [{size}x] {csv_file}:{column} "
|
|
442
|
+
f"value={rep!r} spread={spread:g} rel={rel:g}"
|
|
443
|
+
)
|
|
444
|
+
print(f" entities: {', '.join(sample_names)}")
|
|
445
|
+
|
|
446
|
+
total_values_in_clusters = sum(size for size, *_ in all_clusters)
|
|
447
|
+
print(
|
|
448
|
+
f"[precision] {len(all_clusters)} near-duplicate cluster(s) "
|
|
449
|
+
f"covering {total_values_in_clusters} value(s) "
|
|
450
|
+
f"(showing top {len(shown)}, rel_tol={rel_tol:g})."
|
|
451
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Benders region decomposition wrapper.
|
|
2
|
+
|
|
3
|
+
Builds a self-contained ``input_region_<region>/`` directory for one
|
|
4
|
+
decomposition region's standalone solve.
|
|
5
|
+
|
|
6
|
+
Step 2.6 (this commit)
|
|
7
|
+
----------------------
|
|
8
|
+
|
|
9
|
+
The cascade-input :class:`FlexDataProvider` is the single source of
|
|
10
|
+
truth: :func:`flextool.input_derivation.run` populates it with every
|
|
11
|
+
``input/<name>`` frame, and the region filter
|
|
12
|
+
(:func:`flextool.decomposition.region_filter.build_region_provider`)
|
|
13
|
+
derives a region-scoped Provider from it entirely in-memory. The CLI
|
|
14
|
+
deliverable — the on-disk ``input_region_<region>/`` directory — is
|
|
15
|
+
produced by one call to ``region_provider.snapshot_processed_inputs``
|
|
16
|
+
on the filtered Provider; no monolithic ``input/`` is ever written to
|
|
17
|
+
disk by this driver.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from flextool.engine_polars._flex_data_provider import FlexDataProvider
|
|
25
|
+
from flextool.engine_polars._solve_state import FlexToolConfigError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_input_for_region(
|
|
29
|
+
input_db_url: str,
|
|
30
|
+
scenario_name: str | None,
|
|
31
|
+
logger: logging.Logger,
|
|
32
|
+
region_group: str,
|
|
33
|
+
output_dir: Path,
|
|
34
|
+
work_folder: Path | None = None,
|
|
35
|
+
precision_digits: int = 0,
|
|
36
|
+
) -> dict:
|
|
37
|
+
"""Write a self-contained ``input_region_<region>/`` directory for one
|
|
38
|
+
decomposition region's standalone solve.
|
|
39
|
+
|
|
40
|
+
Step 2.6: the pipeline is fully in-memory.
|
|
41
|
+
|
|
42
|
+
1. :func:`flextool.input_derivation.run` populates the cascade-input
|
|
43
|
+
:class:`FlexDataProvider`.
|
|
44
|
+
2. :func:`region_filter.build_region_provider` derives a region-
|
|
45
|
+
scoped Provider with rows filtered to the region's entities and
|
|
46
|
+
virtual half-flow rows injected for cross-region pipelines.
|
|
47
|
+
3. ``region_provider.snapshot_processed_inputs(output_dir)``
|
|
48
|
+
materialises the CLI deliverable.
|
|
49
|
+
4. The coupling manifest is stored in the cascade Provider as
|
|
50
|
+
``solve_data/region_coupling`` and snapshotted to
|
|
51
|
+
``work_folder/solve_data/region_coupling.csv``.
|
|
52
|
+
|
|
53
|
+
No bridge: the cascade-input Provider is NOT snapshotted to disk.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
input_db_url, scenario_name, logger, work_folder, precision_digits
|
|
58
|
+
Standard cascade entry-point inputs.
|
|
59
|
+
region_group
|
|
60
|
+
The group name whose ``decomposition_method`` is
|
|
61
|
+
``benders_regional``. Must exist in the database.
|
|
62
|
+
output_dir
|
|
63
|
+
Destination directory, typically
|
|
64
|
+
``work_folder / "input_region_<region>"``. Created if missing.
|
|
65
|
+
|
|
66
|
+
Returns a dict: ``{"region": ..., "half_flows": [...], "kept_nodes": ...,
|
|
67
|
+
"kept_units": ..., "kept_connections": ...}``.
|
|
68
|
+
"""
|
|
69
|
+
from flextool.decomposition import region_filter
|
|
70
|
+
from flextool.input_derivation import run as _input_derivation_run
|
|
71
|
+
|
|
72
|
+
wf = work_folder if work_folder is not None else Path.cwd()
|
|
73
|
+
|
|
74
|
+
# 1. Populate the cascade-input Provider in-memory. No disk staging.
|
|
75
|
+
provider = FlexDataProvider()
|
|
76
|
+
_input_derivation_run(
|
|
77
|
+
input_db_url,
|
|
78
|
+
provider,
|
|
79
|
+
logger,
|
|
80
|
+
scenario_name=scenario_name,
|
|
81
|
+
work_folder=work_folder,
|
|
82
|
+
precision_digits=precision_digits,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# 2. Validate region membership BEFORE filtering — surface a clear
|
|
86
|
+
# error with the available regions if the caller picked a wrong one.
|
|
87
|
+
all_regions = region_filter.discover_decomposition_regions_from_db(input_db_url)
|
|
88
|
+
if region_group not in all_regions:
|
|
89
|
+
raise FlexToolConfigError(
|
|
90
|
+
f"Region '{region_group}' is not declared with "
|
|
91
|
+
f"decomposition_method='benders_regional' in the database. "
|
|
92
|
+
f"Available regions: {sorted(all_regions) or '(none)'}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# 3. Derive the region-scoped Provider in-memory.
|
|
96
|
+
region_provider, result = region_filter.build_region_provider(
|
|
97
|
+
provider,
|
|
98
|
+
region=region_group,
|
|
99
|
+
all_regions=all_regions,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# 4. Snapshot the filtered Provider to the CLI-facing output dir.
|
|
103
|
+
# ``snapshot_processed_inputs`` writes every ``input/<name>`` frame
|
|
104
|
+
# under ``<output_dir>/input/<name>.csv``. The region directory's
|
|
105
|
+
# contract is the legacy "input/" subfolder, so we snapshot under
|
|
106
|
+
# output_dir directly (the input/ prefix is already part of the key).
|
|
107
|
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
108
|
+
# Strip the leading ``input/`` from every key so the output lands as
|
|
109
|
+
# ``output_dir/<file>.csv`` (matching the historical layout).
|
|
110
|
+
flat_provider = FlexDataProvider()
|
|
111
|
+
for key, frame in region_provider.items():
|
|
112
|
+
if key.startswith("input/"):
|
|
113
|
+
flat_provider.put(key.split("/", 1)[1], frame)
|
|
114
|
+
else:
|
|
115
|
+
# Non-input frames stay under their qualified parent.
|
|
116
|
+
flat_provider.put(key, frame)
|
|
117
|
+
flat_provider.snapshot_processed_inputs(output_dir)
|
|
118
|
+
|
|
119
|
+
# 5. Write the coupling manifest. Routes through the Provider for
|
|
120
|
+
# symmetry with the rest of the pipeline; the snapshot lands at
|
|
121
|
+
# ``work_folder/solve_data/region_coupling.csv``.
|
|
122
|
+
manifest_provider = FlexDataProvider()
|
|
123
|
+
region_filter.write_region_coupling_manifest_to_provider(
|
|
124
|
+
manifest_provider,
|
|
125
|
+
results=[result],
|
|
126
|
+
)
|
|
127
|
+
manifest_provider.snapshot_processed_inputs(wf)
|
|
128
|
+
return result
|