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,1095 @@
|
|
|
1
|
+
import os
|
|
2
|
+
# glibc malloc arena cap — set BEFORE any C-extension import that
|
|
3
|
+
# allocates via malloc. glibc defaults to up to 8 × ncores arenas
|
|
4
|
+
# (≈256 on a 32-core workstation); each arena holds its own
|
|
5
|
+
# freed-but-not-returned-to-OS pages, so worst-case fragmentation
|
|
6
|
+
# scales with core count. Capping to 4 is a precautionary middle
|
|
7
|
+
# ground: ~64× reduction vs the default, while still allowing up to
|
|
8
|
+
# four concurrent allocators (HiGHS parallel presolve, Benders
|
|
9
|
+
# subproblem runs) without serialising every malloc through one heap.
|
|
10
|
+
# No measured benefit on the FlexTool cascade workload as of this
|
|
11
|
+
# writing — FlexTool's hot path is essentially single-threaded
|
|
12
|
+
# (polars pinned to 1 thread, --highs-threads typically 1). Kept
|
|
13
|
+
# only as a cheap precaution against future workloads where arena
|
|
14
|
+
# growth could matter. ``setdefault`` so the shell wins.
|
|
15
|
+
os.environ.setdefault("MALLOC_ARENA_MAX", "4")
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
import logging
|
|
20
|
+
import math
|
|
21
|
+
import shutil
|
|
22
|
+
import traceback
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
# Leave breadcrumbs if a compiled extension (polars / HiGHS / numpy) crashes
|
|
28
|
+
# natively. faulthandler can't stop a segfault, but it dumps the Python
|
|
29
|
+
# traceback of every thread to stderr at fault time — so a crash during, say,
|
|
30
|
+
# the first polars op prints the offending frame instead of nothing. The GUI
|
|
31
|
+
# captures this child's stderr into the job log. See flextool.env_check for
|
|
32
|
+
# the out-of-process probe + auto-remediation that prevents the crash.
|
|
33
|
+
import faulthandler
|
|
34
|
+
try:
|
|
35
|
+
faulthandler.enable()
|
|
36
|
+
except (AttributeError, ValueError, OSError):
|
|
37
|
+
# stderr may be unavailable (e.g. detached / redirected to a closed fd).
|
|
38
|
+
pass
|
|
39
|
+
from flextool._mem_sampler import start_mem_sampler
|
|
40
|
+
from flextool.process_outputs.write_outputs import write_outputs
|
|
41
|
+
from flextool.cli._console import run_tool
|
|
42
|
+
from flextool.cli._timing import TimingRecorder
|
|
43
|
+
from flextool.common_utils.precision import resolve_precision_digits
|
|
44
|
+
from flextool.update_flextool.ensure_settings_db import ensure_settings_db
|
|
45
|
+
from spinedb_api.filters.tools import name_from_dict
|
|
46
|
+
from spinedb_api import DatabaseMapping, to_database, DateTime
|
|
47
|
+
from spinedb_api.exception import NothingToCommit
|
|
48
|
+
|
|
49
|
+
# Start the memory sampler as the first statement after imports. The
|
|
50
|
+
# few hundred ms of import-cascade RSS that precede this point are not
|
|
51
|
+
# captured; the workload-level RSS curve (what the sampler exists to
|
|
52
|
+
# measure) is fully captured. Gated by FLEXTOOL_MEM_SAMPLER=1; no-op
|
|
53
|
+
# when the env var is unset.
|
|
54
|
+
start_mem_sampler()
|
|
55
|
+
|
|
56
|
+
class FlushingStream:
|
|
57
|
+
def __init__(self, stream):
|
|
58
|
+
self.stream = stream
|
|
59
|
+
|
|
60
|
+
def write(self, data):
|
|
61
|
+
self.stream.write(data)
|
|
62
|
+
self.stream.flush()
|
|
63
|
+
|
|
64
|
+
def __getattr__(self, attr):
|
|
65
|
+
return getattr(self.stream, attr)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
sys.stdout = FlushingStream(sys.stdout)
|
|
69
|
+
|
|
70
|
+
#return_codes
|
|
71
|
+
#0 : Success
|
|
72
|
+
#-1: Failure (Defined in the Toolbox)
|
|
73
|
+
#1: Infeasible or unbounded problem (not implemented in the toolbox, functionally same as -1. For a possiblity of a graphical depiction)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_solve(args, scenario_name, work_folder, timing_recorder):
|
|
77
|
+
"""Δ.21 — drive the native polar_high cascade.
|
|
78
|
+
|
|
79
|
+
Returns a tuple ``(return_code, last_step)`` where ``last_step``
|
|
80
|
+
is the :class:`flextool.engine_polars.OrchestrationStep` of the
|
|
81
|
+
final (or only) sub-solve, used by the caller to thread
|
|
82
|
+
``flex_data`` + ``solution`` into ``write_outputs`` for the
|
|
83
|
+
in-memory parameter / set namespace path (Δ.31).
|
|
84
|
+
"""
|
|
85
|
+
if scenario_name:
|
|
86
|
+
timing_recorder.set_scenario(scenario_name)
|
|
87
|
+
|
|
88
|
+
# ``--csv-dump`` is a one-way debug snapshot from the live
|
|
89
|
+
# FlexDataProvider: when on, the cascade dumps both
|
|
90
|
+
# ``flex_data.dump_csvs(work_folder)`` (per-solve) and the Provider's
|
|
91
|
+
# captured derived frames (post-cascade snapshot below). When off
|
|
92
|
+
# the cascade runs purely in-memory — no CSVs hit
|
|
93
|
+
# ``solve_data/`` from the writer-port modules.
|
|
94
|
+
csv_dump_on = bool(getattr(args, 'csv_dump', False))
|
|
95
|
+
|
|
96
|
+
from flextool.engine_polars import run_chain_from_db
|
|
97
|
+
|
|
98
|
+
# Drive the native cascade end-to-end. ``run_chain_from_db``
|
|
99
|
+
# handles flextool's preprocessing (write_input) AND the per-solve
|
|
100
|
+
# LP build+solve+handoff loop in-process.
|
|
101
|
+
t_solve_start = time.perf_counter()
|
|
102
|
+
steps = run_chain_from_db(
|
|
103
|
+
args.input_db_url,
|
|
104
|
+
scenario_name,
|
|
105
|
+
work_folder=work_folder,
|
|
106
|
+
csv_dump=csv_dump_on,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# ``--csv-dump``: snapshot the last sub-solve's Provider to disk.
|
|
110
|
+
# The Provider holds every derived frame the cascade's writers
|
|
111
|
+
# produced; ``snapshot_processed_inputs`` writes them under
|
|
112
|
+
# ``work_folder`` mirroring the cascade's parent-qualified key
|
|
113
|
+
# layout. This is a debug oracle; the cascade itself reads only
|
|
114
|
+
# from the in-memory Provider.
|
|
115
|
+
if csv_dump_on and steps:
|
|
116
|
+
last_step = next(reversed(list(steps.values())))
|
|
117
|
+
provider = getattr(last_step, "flex_data_provider", None)
|
|
118
|
+
if provider is not None:
|
|
119
|
+
try:
|
|
120
|
+
provider.snapshot_processed_inputs(work_folder)
|
|
121
|
+
except Exception as exc: # noqa: BLE001
|
|
122
|
+
logging.warning(
|
|
123
|
+
"--csv-dump: snapshot_processed_inputs failed: %s", exc,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
all_solves_seconds = time.perf_counter() - t_solve_start
|
|
127
|
+
print("--- All Flextool solves time %.4s seconds ---" % all_solves_seconds)
|
|
128
|
+
timing_recorder.record('all_solves', seconds=all_solves_seconds,
|
|
129
|
+
t_start=t_solve_start)
|
|
130
|
+
|
|
131
|
+
if not steps:
|
|
132
|
+
logging.error("Native cascade produced no solve steps; aborting.")
|
|
133
|
+
return 1, None
|
|
134
|
+
return _scan_cascade_optimality(steps)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _scan_cascade_optimality(steps):
|
|
138
|
+
"""Classify the cascade outcome into a ``(exit_code, last_step)`` pair.
|
|
139
|
+
|
|
140
|
+
Scans sub-solves for a non-optimal outcome. Two distinct cases:
|
|
141
|
+
|
|
142
|
+
* A solve accepted as *near-optimal* by
|
|
143
|
+
:func:`classify_acceptance` — a crossover-off interior-point solve
|
|
144
|
+
HiGHS would not certify ``kOptimal`` but whose primal is feasible with
|
|
145
|
+
a small primal--dual gap — already wrote a usable solution and returned
|
|
146
|
+
success from the per-solve driver. Its strict ``optimal`` mirror is
|
|
147
|
+
``False`` (HiGHS status Unknown), so it is recognised here via
|
|
148
|
+
``step.near_optimal`` and must NOT be treated as a failure.
|
|
149
|
+
|
|
150
|
+
* A Benders-decomposed solve that found a FEASIBLE incumbent but did not
|
|
151
|
+
close the optimality gap to tolerance within its iteration cap is NOT
|
|
152
|
+
infeasible — every subproblem/master LP solved to optimality and the
|
|
153
|
+
written outputs are a valid feasible plan, just not certified optimal.
|
|
154
|
+
Surface a loud warning (with the actual gap vs the required tolerance)
|
|
155
|
+
and let the run SUCCEED (exit 0) so the parquet results are consumed
|
|
156
|
+
downstream.
|
|
157
|
+
|
|
158
|
+
* Anything else non-optimal (a Benders solve with no feasible incumbent
|
|
159
|
+
at all, or a step whose solution the cascade never accepted) is a
|
|
160
|
+
genuine failure → exit 1. A monolithic LP that HiGHS genuinely could
|
|
161
|
+
not solve is rejected earlier at the solve site (``classify_acceptance``
|
|
162
|
+
→ ``FlexToolSolveError``, which names the precise cause) and never
|
|
163
|
+
reaches this scan; if one does, it is reported here with the facts the
|
|
164
|
+
slim step carries rather than a guessed "infeasible/unbounded" cause.
|
|
165
|
+
"""
|
|
166
|
+
last_step = None
|
|
167
|
+
for name, step in steps.items():
|
|
168
|
+
last_step = step
|
|
169
|
+
# Phase C.5 — intermediate steps no longer hold ``solution``
|
|
170
|
+
# under default (slim) cascade; read the slim ``optimal``
|
|
171
|
+
# summary instead so the non-optimal check works without
|
|
172
|
+
# ``keep_solutions=True``. ``step.solution`` is only populated
|
|
173
|
+
# for the LAST step (or every step under ``keep_solutions``).
|
|
174
|
+
#
|
|
175
|
+
# ``optimal`` is the STRICT HiGHS verdict; ``near_optimal`` flags a
|
|
176
|
+
# solve ``classify_acceptance`` accepted despite a non-``kOptimal``
|
|
177
|
+
# status (crossover-off interior point). Both mean "usable
|
|
178
|
+
# solution written" — treat either as success.
|
|
179
|
+
if step.optimal or getattr(step, "near_optimal", False):
|
|
180
|
+
continue
|
|
181
|
+
if step.is_benders and step.obj is not None and math.isfinite(step.obj):
|
|
182
|
+
# Non-convergence with a feasible incumbent — warn loudly, keep
|
|
183
|
+
# the written results, do NOT fail the run.
|
|
184
|
+
logging.error(_benders_nonconvergence_banner(name, step))
|
|
185
|
+
continue
|
|
186
|
+
logging.error(
|
|
187
|
+
"Native cascade: solve %r did not solve to optimality; exit=1. "
|
|
188
|
+
"This solve's solution was not accepted for consumption "
|
|
189
|
+
"(strict-optimal=%r, near-optimal-accepted=%r, benders=%r, "
|
|
190
|
+
"objective=%r). A genuine LP failure names its precise cause "
|
|
191
|
+
"(infeasible / unbounded / limit reached) at the solve site; a "
|
|
192
|
+
"Benders solve reaching here found no feasible incumbent. See the "
|
|
193
|
+
"solve log above for the solver's reported status.",
|
|
194
|
+
name,
|
|
195
|
+
step.optimal,
|
|
196
|
+
getattr(step, "near_optimal", False),
|
|
197
|
+
step.is_benders,
|
|
198
|
+
step.obj,
|
|
199
|
+
)
|
|
200
|
+
return 1, step
|
|
201
|
+
return 0, last_step
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _benders_nonconvergence_banner(name, step) -> str:
|
|
205
|
+
"""A loud, plain-English banner for a Benders solve that found a feasible
|
|
206
|
+
incumbent but never met its convergence tolerance.
|
|
207
|
+
|
|
208
|
+
Kept in FlexTool class vocabulary (node group / connection / flow) — never
|
|
209
|
+
model-instance terms — mirroring the ``_benders_failure_message`` contract.
|
|
210
|
+
"""
|
|
211
|
+
def _pct(x):
|
|
212
|
+
return f"{x * 100:.4g}%" if x is not None and math.isfinite(x) else "unknown"
|
|
213
|
+
|
|
214
|
+
gap = getattr(step, "benders_gap", None)
|
|
215
|
+
tol = getattr(step, "benders_tol", None)
|
|
216
|
+
iters = getattr(step, "benders_iterations", None)
|
|
217
|
+
bar = "#" * 76
|
|
218
|
+
return (
|
|
219
|
+
f"\n{bar}\n"
|
|
220
|
+
f"WARNING: decomposed solve {name!r} did NOT meet its convergence "
|
|
221
|
+
f"tolerance.\n"
|
|
222
|
+
f"{bar}\n"
|
|
223
|
+
f" Relative gap reached : {_pct(gap)}\n"
|
|
224
|
+
f" Required tolerance : {_pct(tol)}\n"
|
|
225
|
+
f" Iterations run : {iters if iters is not None else 'unknown'}\n"
|
|
226
|
+
f" Best feasible cost : {step.obj:.6g}\n"
|
|
227
|
+
f"\n"
|
|
228
|
+
f" The results ARE feasible and HAVE been written to the outputs "
|
|
229
|
+
f"(parquet /\n"
|
|
230
|
+
f" results DB), but they are NOT certified optimal: the true optimum "
|
|
231
|
+
f"may be\n"
|
|
232
|
+
f" up to the gap above cheaper than the reported cost.\n"
|
|
233
|
+
f"\n"
|
|
234
|
+
f" To close the gap, try any of: raise the decomposition iteration "
|
|
235
|
+
f"limit,\n"
|
|
236
|
+
f" loosen the convergence tolerance, set the in-out stabilization "
|
|
237
|
+
f"weight\n"
|
|
238
|
+
f" (~0.3-0.7) to break a stalled plateau, or give any under-supplied "
|
|
239
|
+
f"node\n"
|
|
240
|
+
f" group a finite fail-safe import price on its boundary nodes.\n"
|
|
241
|
+
f"{bar}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def resolve_output_path(input_db_url, flextool_location, output_location, cwd,
|
|
246
|
+
project_folder_file=None):
|
|
247
|
+
"""Resolve the TRUE output root for a CLI run (5-tier rule).
|
|
248
|
+
|
|
249
|
+
This is the path that outputs actually land under, so it is what
|
|
250
|
+
gets persisted to the "Output info" DB as ``scenario/output_location``
|
|
251
|
+
(Toolbox's comparison / re-create steps read it back to locate each
|
|
252
|
+
scenario's parquet) AND what is passed as ``fallback_output_location``
|
|
253
|
+
to ``write_outputs`` and used for the timings.csv directory.
|
|
254
|
+
|
|
255
|
+
The five tiers, in precedence order:
|
|
256
|
+
|
|
257
|
+
1. **Explicit ``--output-location``** wins outright. ``write_outputs``
|
|
258
|
+
already honours an explicit ``output_location`` over the
|
|
259
|
+
``fallback_output_location`` (see ``_resolve_settings``), so before
|
|
260
|
+
this change an explicit ``--output-location`` steered where files
|
|
261
|
+
were written but was NOT reflected in the persisted Output-info
|
|
262
|
+
record (which used the fallback ``output_path``). Folding it into
|
|
263
|
+
tier 1 fixes that latent inconsistency: the recorded location now
|
|
264
|
+
matches where the files actually go.
|
|
265
|
+
|
|
266
|
+
2. **Project-folder file (``--project-folder-file``).** This is the
|
|
267
|
+
USER-LOCAL output redirect: the maintainer points Spine Toolbox's
|
|
268
|
+
FlexTool run Tool at a gitignored file (seeded by ``self_update``
|
|
269
|
+
as ``templates/project_folder.txt``) whose CONTENTS name a project
|
|
270
|
+
folder, so a user can redirect every output (``output_parquet/``,
|
|
271
|
+
``results.sqlite``, plots, the per-project ``plot_settings.yaml``)
|
|
272
|
+
into a per-project directory with ZERO git-committed change.
|
|
273
|
+
|
|
274
|
+
The file's first non-empty, non-``#``-comment line is the project
|
|
275
|
+
folder. If that line is an ABSOLUTE path it is used verbatim; if
|
|
276
|
+
RELATIVE it is resolved against the file's repo anchor —
|
|
277
|
+
``file.resolve().parent.parent`` — the SAME anchor the legacy
|
|
278
|
+
``--flextool-location`` walk uses, so a ``templates/
|
|
279
|
+
project_folder.txt`` line of ``projects/Rivendell`` lands the
|
|
280
|
+
output at ``<repo>/projects/Rivendell``.
|
|
281
|
+
|
|
282
|
+
**A supplied ``--project-folder-file`` is a COMPLETE replacement
|
|
283
|
+
for the legacy ``--flextool-location`` and therefore NEVER falls
|
|
284
|
+
through to CWD.** When the file is missing, unreadable, empty, or
|
|
285
|
+
comment-only — i.e. its CONTENTS name no folder — this tier still
|
|
286
|
+
fires, falling back to the FILE'S repo anchor
|
|
287
|
+
(``Path(project_folder_file).resolve().parent.parent``), the same
|
|
288
|
+
FlexTool root the legacy ``--flextool-location`` walk produced.
|
|
289
|
+
This matches the seeded ``templates/project_folder.txt`` whose own
|
|
290
|
+
comment says "Leave blank to use the FlexTool root". Only when
|
|
291
|
+
``--project-folder-file`` was NOT supplied at all (None / empty
|
|
292
|
+
arg) does resolution continue to tiers 3-5.
|
|
293
|
+
|
|
294
|
+
3. **GUI-project layout.** When the input DB file sits directly inside
|
|
295
|
+
a directory named ``input_sources`` (the FlexTool GUI project
|
|
296
|
+
layout, ``<project>/input_sources/<db>.sqlite``), the output root is
|
|
297
|
+
that directory's parent — the project folder. This is
|
|
298
|
+
location-agnostic: it works wherever the project lives on disk.
|
|
299
|
+
The DB filesystem path is recovered from ``input_db_url`` (which may
|
|
300
|
+
be a ``sqlite:///`` URL possibly carrying an appended filter
|
|
301
|
+
query-config) using the same ``sqlite:///`` stripping idiom used
|
|
302
|
+
elsewhere in this file, plus a query-part strip. If the path can't
|
|
303
|
+
be resolved to an existing file, this tier is skipped (no crash).
|
|
304
|
+
|
|
305
|
+
4. **Legacy ``--flextool-location`` bridge.** ``.parent.parent`` of the
|
|
306
|
+
resolved flextool-location path (the historical Spine Toolbox
|
|
307
|
+
anchor; kept for backward compatibility one release).
|
|
308
|
+
|
|
309
|
+
5. **Fallback** to the current working directory.
|
|
310
|
+
|
|
311
|
+
Pure path logic — deterministic, no randomness, no side effects.
|
|
312
|
+
"""
|
|
313
|
+
# Tier 1 — explicit override wins.
|
|
314
|
+
if output_location:
|
|
315
|
+
return Path(output_location)
|
|
316
|
+
|
|
317
|
+
# Tier 2 — project-folder file. A supplied --project-folder-file is a
|
|
318
|
+
# COMPLETE replacement for --flextool-location: it ALWAYS yields an
|
|
319
|
+
# output root and never falls through to CWD. Its CONTENTS name the
|
|
320
|
+
# project folder when present; otherwise (blank / comment-only /
|
|
321
|
+
# missing / unreadable) we fall back to the FILE'S repo anchor
|
|
322
|
+
# (.parent.parent), the same FlexTool root the legacy
|
|
323
|
+
# --flextool-location walk produced. Only an unsupplied (None / empty)
|
|
324
|
+
# arg lets resolution continue to tiers 3-5.
|
|
325
|
+
if project_folder_file:
|
|
326
|
+
project_folder = _read_project_folder_file(project_folder_file)
|
|
327
|
+
if project_folder is not None:
|
|
328
|
+
return project_folder
|
|
329
|
+
# CONTENTS name no folder — anchor at the file's repo root.
|
|
330
|
+
try:
|
|
331
|
+
return Path(project_folder_file).resolve().parent.parent
|
|
332
|
+
except OSError:
|
|
333
|
+
# ``resolve()`` should not raise for a plain path on POSIX even
|
|
334
|
+
# when it doesn't exist, but degrade without crashing if it
|
|
335
|
+
# ever does: anchor at the un-resolved path's .parent.parent.
|
|
336
|
+
return Path(project_folder_file).parent.parent
|
|
337
|
+
|
|
338
|
+
# Tier 3 — GUI project layout: <project>/input_sources/<db>.sqlite.
|
|
339
|
+
db_fs_path = _input_db_filesystem_path(input_db_url)
|
|
340
|
+
if db_fs_path is not None:
|
|
341
|
+
try:
|
|
342
|
+
resolved = db_fs_path.resolve()
|
|
343
|
+
except OSError:
|
|
344
|
+
resolved = None
|
|
345
|
+
if resolved is not None and resolved.is_file() \
|
|
346
|
+
and resolved.parent.name == "input_sources":
|
|
347
|
+
return resolved.parent.parent
|
|
348
|
+
|
|
349
|
+
# Tier 4 — legacy flextool-location anchor walk.
|
|
350
|
+
if flextool_location:
|
|
351
|
+
return Path(flextool_location).resolve().parent.parent
|
|
352
|
+
|
|
353
|
+
# Tier 5 — fallback to CWD.
|
|
354
|
+
return Path(cwd)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _read_project_folder_file(project_folder_file):
|
|
358
|
+
"""Resolve a project-folder redirect from a ``--project-folder-file``.
|
|
359
|
+
|
|
360
|
+
The file's CONTENTS name the project folder: the first non-empty,
|
|
361
|
+
non-``#``-comment line is taken (whitespace-stripped). An ABSOLUTE
|
|
362
|
+
line is returned verbatim; a RELATIVE line is resolved against the
|
|
363
|
+
file's repo anchor (``file.resolve().parent.parent``, the same anchor
|
|
364
|
+
the legacy ``--flextool-location`` walk uses), so a ``templates/
|
|
365
|
+
project_folder.txt`` line of ``projects/Rivendell`` maps to
|
|
366
|
+
``<repo>/projects/Rivendell``.
|
|
367
|
+
|
|
368
|
+
Returns a :class:`~pathlib.Path` when the file's CONTENTS name a
|
|
369
|
+
usable project folder, or ``None`` when the path arg is empty, the
|
|
370
|
+
file is missing / unreadable, or it has no non-comment content. A
|
|
371
|
+
``None`` return does NOT mean "fall through to CWD": the caller
|
|
372
|
+
(``resolve_output_path``) treats a supplied-but-content-less
|
|
373
|
+
``--project-folder-file`` as the FlexTool root by anchoring at the
|
|
374
|
+
file's ``.parent.parent`` — so this tier never reaches CWD once the
|
|
375
|
+
arg is supplied. Robust: any read error → ``None`` (never raises).
|
|
376
|
+
"""
|
|
377
|
+
if not project_folder_file:
|
|
378
|
+
return None
|
|
379
|
+
file_path = Path(project_folder_file)
|
|
380
|
+
try:
|
|
381
|
+
text = file_path.read_text(encoding="utf-8")
|
|
382
|
+
except OSError:
|
|
383
|
+
# Missing / unreadable / not a file — skip this tier silently.
|
|
384
|
+
return None
|
|
385
|
+
line = None
|
|
386
|
+
for raw in text.splitlines():
|
|
387
|
+
stripped = raw.strip()
|
|
388
|
+
if not stripped or stripped.startswith("#"):
|
|
389
|
+
continue
|
|
390
|
+
line = stripped
|
|
391
|
+
break
|
|
392
|
+
if line is None:
|
|
393
|
+
# Empty or comment-only — fall through.
|
|
394
|
+
return None
|
|
395
|
+
candidate = Path(line)
|
|
396
|
+
if candidate.is_absolute():
|
|
397
|
+
return candidate
|
|
398
|
+
# Relative → resolve against the file's repo anchor (parent.parent),
|
|
399
|
+
# matching the legacy --flextool-location walk so a templates/-anchored
|
|
400
|
+
# relative line roots at the repo root.
|
|
401
|
+
try:
|
|
402
|
+
anchor = file_path.resolve().parent.parent
|
|
403
|
+
except OSError:
|
|
404
|
+
return None
|
|
405
|
+
return anchor / candidate
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _input_db_filesystem_path(input_db_url):
|
|
409
|
+
"""Best-effort filesystem path for a (possibly sqlite) input DB URL.
|
|
410
|
+
|
|
411
|
+
Returns a :class:`~pathlib.Path` for ``sqlite:///`` URLs and bare
|
|
412
|
+
filesystem paths, stripping any appended filter query-config
|
|
413
|
+
(``?spinedbfilter=...``); returns ``None`` for non-sqlite URLs (e.g.
|
|
414
|
+
``mysql://``) or when the value is empty. Does not touch the
|
|
415
|
+
filesystem — purely string → path.
|
|
416
|
+
"""
|
|
417
|
+
if not input_db_url:
|
|
418
|
+
return None
|
|
419
|
+
# A non-sqlite scheme (mysql, postgresql, …) is not a local file.
|
|
420
|
+
if "://" in input_db_url and not input_db_url.startswith("sqlite:"):
|
|
421
|
+
return None
|
|
422
|
+
# Strip an appended Spine filter query-config, e.g.
|
|
423
|
+
# ``sqlite:///proj/input_sources/db.sqlite?spinedbfilter=...``.
|
|
424
|
+
# ``urlsplit`` keeps everything before ``?`` in ``.path`` for URLs and
|
|
425
|
+
# leaves a bare path untouched in ``.path`` too, but to stay aligned
|
|
426
|
+
# with the file-local ``.replace('sqlite:///', '')`` idiom we strip the
|
|
427
|
+
# scheme prefix first, then split off the query manually.
|
|
428
|
+
no_scheme = input_db_url.replace("sqlite:///", "", 1)
|
|
429
|
+
no_query = no_scheme.split("?", 1)[0]
|
|
430
|
+
if not no_query:
|
|
431
|
+
return None
|
|
432
|
+
return Path(no_query)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def main():
|
|
436
|
+
parser = argparse.ArgumentParser()
|
|
437
|
+
parser.description = "Run flextool using the specified database URL. Return codes are 0: success, 1: infeasible or unbounded, -1: failure."
|
|
438
|
+
parser.add_argument('input_db_url', help='Database URL to connect to (can be copied from Toolbox workflow db item')
|
|
439
|
+
parser.add_argument('output_db_url', metavar='DB_URL', nargs='?', default=None, help='Save information about result location to database for post-processing')
|
|
440
|
+
parser.add_argument('--settings-db-url', help='Settings for post-processing')
|
|
441
|
+
parser.add_argument('--scenario-name', help='Name for the scenario in the database that should be executed', nargs='?', default=None)
|
|
442
|
+
parser.add_argument(
|
|
443
|
+
'--debug',
|
|
444
|
+
nargs='?',
|
|
445
|
+
const='basic',
|
|
446
|
+
default='off',
|
|
447
|
+
choices=['off', 'basic', 'full'],
|
|
448
|
+
metavar='LEVEL',
|
|
449
|
+
help='Diagnostic verbosity level (default: off). '
|
|
450
|
+
'``off`` — quiet; only user-facing INFO and WARNING. '
|
|
451
|
+
'``basic`` — verbose memory checkpoint trace + DEBUG log '
|
|
452
|
+
'level; no tracemalloc, no perf overhead beyond extra '
|
|
453
|
+
'stdout. Bare ``--debug`` selects this level. '
|
|
454
|
+
'``full`` — basic plus tracemalloc-backed memory '
|
|
455
|
+
'diagnostics CSV. Tracemalloc instruments every Python '
|
|
456
|
+
'allocation and typically slows allocation-heavy phases '
|
|
457
|
+
'(input_derivation, cascade rolls) by 2-5×; use only '
|
|
458
|
+
'when investigating Python-side allocation regressions.',
|
|
459
|
+
)
|
|
460
|
+
parser.add_argument(
|
|
461
|
+
'--save-memory', action='store_true',
|
|
462
|
+
help='Trade wall time for peak memory: build the LP, write it to '
|
|
463
|
+
'a temp MPS file, drop everything Python-side AND the live '
|
|
464
|
+
'HiGHS instance, then spawn a separate subprocess to solve '
|
|
465
|
+
'the MPS in a clean address space. The parent process '
|
|
466
|
+
'(holding ~7-11 GB of polars frames + FlexData) sits idle '
|
|
467
|
+
'while the child does its ~50 GB active-solve work, so the '
|
|
468
|
+
'two never compound in the same process heap. Adds ~+30-60 s '
|
|
469
|
+
'I/O per sub-solve. Also disables warm-LP reuse across '
|
|
470
|
+
'cascade iterations (the Problem is released after MPS '
|
|
471
|
+
'write). Off by default; use when models OOM on the default '
|
|
472
|
+
'in-process path.',
|
|
473
|
+
)
|
|
474
|
+
parser.add_argument(
|
|
475
|
+
'--warm-start', action='store_true',
|
|
476
|
+
help='Reuse a cached HiGHS basis across solves of the same '
|
|
477
|
+
'structural model (save-memory subprocess path only); safe '
|
|
478
|
+
'cold fallback on any mismatch.',
|
|
479
|
+
)
|
|
480
|
+
parser.add_argument('--output-spreadsheet', metavar='PATH', help='Save results to spreadsheet file')
|
|
481
|
+
parser.add_argument('--write-methods', type=str, nargs='+', default=None,
|
|
482
|
+
choices=['plot', 'parquet', 'excel', 'csv', 'spinedb'],
|
|
483
|
+
help='Output methods to use (default: plot parquet)')
|
|
484
|
+
parser.add_argument('--results-db-url', type=str, default=None,
|
|
485
|
+
help='Target SpineDB URL for the spinedb write-method (default: <output-dir>/results.sqlite)')
|
|
486
|
+
parser.add_argument('--output-config', metavar='PATH',
|
|
487
|
+
default=None,
|
|
488
|
+
help='Path to output configuration file (default: templates/default_plots.yaml)')
|
|
489
|
+
parser.add_argument('--active-configs', type=str, nargs='+', default=None,
|
|
490
|
+
help='Active output configurations to use (default: default)')
|
|
491
|
+
parser.add_argument('--plot-rows', type=int, nargs=2, default=None, metavar=('FIRST', 'LAST'),
|
|
492
|
+
help='First and last row to plot in time series (default: 0 167)')
|
|
493
|
+
parser.add_argument('--output-location', metavar='PATH', default=None,
|
|
494
|
+
help='Override output location path')
|
|
495
|
+
parser.add_argument('--output-subdir', metavar='NAME', default=None,
|
|
496
|
+
help='Subdirectory name under output_parquet/ (and the '
|
|
497
|
+
'other output dirs). Defaults to the scenario '
|
|
498
|
+
'name for backward compatibility.')
|
|
499
|
+
parser.add_argument('--flextool-location', nargs='?', default=None, const=None,
|
|
500
|
+
help='When running in Spine Toolbox, this argument provides the location of FlexTool so outputs can be directed there (instead of work directories). Defaults to the user\'s current working directory. The value may be omitted (Spine Toolbox sometimes passes the bare flag) — in that case the default is used. Legacy bridge: kept for backward compatibility; prefer --project-folder-file.')
|
|
501
|
+
parser.add_argument('--project-folder-file', metavar='PATH', default=None,
|
|
502
|
+
help='Path to a USER-LOCAL file whose CONTENTS name '
|
|
503
|
+
'the project folder to direct outputs into '
|
|
504
|
+
'(output_parquet/, results.sqlite, plots, and '
|
|
505
|
+
'the per-project plot_settings.yaml). The '
|
|
506
|
+
'first non-empty, non-#-comment line is the '
|
|
507
|
+
'project folder: an absolute path is used as-is; '
|
|
508
|
+
'a relative path is resolved against the file\'s '
|
|
509
|
+
'repo anchor (its .parent.parent). Spine Toolbox '
|
|
510
|
+
'points this at templates/project_folder.txt '
|
|
511
|
+
'(gitignored, seeded by flextool-update). This '
|
|
512
|
+
'flag is a COMPLETE replacement for '
|
|
513
|
+
'--flextool-location: a missing / empty / '
|
|
514
|
+
'comment-only file does NOT fall through to the '
|
|
515
|
+
'work dir but anchors at the file\'s repo root '
|
|
516
|
+
'(its .parent.parent), so a supplied '
|
|
517
|
+
'--project-folder-file never lands outputs in '
|
|
518
|
+
'the CWD. Lower precedence than '
|
|
519
|
+
'--output-location, higher than the '
|
|
520
|
+
'input_sources/ layout and --flextool-location.')
|
|
521
|
+
parser.add_argument('--work-folder', metavar='PATH', default=None,
|
|
522
|
+
help='Working directory for intermediate files (default: current directory). '
|
|
523
|
+
'Enables parallel scenario execution by isolating each run.')
|
|
524
|
+
parser.add_argument('--only-first-file-per-plot', action='store_true', default=False,
|
|
525
|
+
help='Only produce the first file for each plot (quick overview mode)')
|
|
526
|
+
parser.add_argument('--precision-digits', metavar='N', type=int, default=None,
|
|
527
|
+
help='Round every numeric input parameter to N significant '
|
|
528
|
+
'figures before writing CSVs (typical: 10). '
|
|
529
|
+
'Collapses accumulated float-noise so HiGHS '
|
|
530
|
+
'mip_detect_symmetry can aggregate structurally-identical '
|
|
531
|
+
'coefficients. 0 or unset disables rounding (default). '
|
|
532
|
+
'Overrides the FLEXTOOL_PRECISION_DIGITS env var.')
|
|
533
|
+
parser.add_argument('--region', metavar='GROUP_NAME', default=None,
|
|
534
|
+
help='Produce a filtered per-region input directory '
|
|
535
|
+
'``input_region_<GROUP_NAME>/`` for Benders '
|
|
536
|
+
'decomposition (Agent 3.1). The group must have '
|
|
537
|
+
'``decomposition_method=benders_regional`` in '
|
|
538
|
+
'the DB. Cross-region processes are replaced '
|
|
539
|
+
'with import/export half-flows; the coupling '
|
|
540
|
+
'variables are listed in '
|
|
541
|
+
'``solve_data/region_coupling.csv``. When this '
|
|
542
|
+
'flag is set, no solve runs — this is the '
|
|
543
|
+
'filter-only entry point used by the coordinator.')
|
|
544
|
+
# Decomposition is DB-driven and per-solve (v62): set
|
|
545
|
+
# ``solve.decomposition = benders`` plus the per-solve
|
|
546
|
+
# ``solve.benders_max_iter`` / ``benders_tolerance`` /
|
|
547
|
+
# ``benders_in_out_weight`` knobs in the database. The old global ``--decomposition`` / ``--lagrangian-*``
|
|
548
|
+
# CLI flags were removed — the orchestrator reads the scheme per solve
|
|
549
|
+
# so a single chain can mix monolithic and Benders solves. See
|
|
550
|
+
# docs/dev/decomposition.md.
|
|
551
|
+
parser.add_argument('--highs-threads', type=int, default=1,
|
|
552
|
+
help='Number of HiGHS solver threads. Default 1. '
|
|
553
|
+
'Values > 1 enable HiGHS parallel mode and trade '
|
|
554
|
+
'determinism for wall-clock speedup; goldens are '
|
|
555
|
+
'not guaranteed to reproduce across runs in that '
|
|
556
|
+
'mode.')
|
|
557
|
+
parser.add_argument(
|
|
558
|
+
'--scaling',
|
|
559
|
+
choices=['off', 'solver_only', 'basic', 'full'],
|
|
560
|
+
default=None,
|
|
561
|
+
help=(
|
|
562
|
+
"Choose FlexTool's autoscaler strategy. HiGHS' internal matrix "
|
|
563
|
+
"equilibration (simplex_scale_strategy) is unaffected by this "
|
|
564
|
+
"flag EXCEPT when --scaling=off, where it is forced to 0. To "
|
|
565
|
+
"tune HiGHS-internal options, use the solver config file.\n"
|
|
566
|
+
"\n"
|
|
567
|
+
" off Disable ALL scaling, including HiGHS' internal "
|
|
568
|
+
"matrix equilibration (forces simplex_scale_strategy=0). Use "
|
|
569
|
+
"this if you want raw numerics or to export the truly unscaled "
|
|
570
|
+
"LP. Expect HiGHS warnings.\n"
|
|
571
|
+
" solver_only Disable the FlexTool autoscaler. HiGHS still "
|
|
572
|
+
"scales the matrix internally per its own default "
|
|
573
|
+
"(simplex_scale_strategy=2, equilibration). Useful when "
|
|
574
|
+
"exporting MPS for an external solver.\n"
|
|
575
|
+
" basic Compute LP ranges (Layer 1) and recommend "
|
|
576
|
+
"power-of-two user_objective_scale + user_bound_scale to HiGHS "
|
|
577
|
+
"(Layer 3). No LP-array mutation; MPS exports reflect the "
|
|
578
|
+
"unscaled model. HiGHS' own matrix equilibration runs per its "
|
|
579
|
+
"default.\n"
|
|
580
|
+
" full The full autoscaler: range detection (Layer 1), "
|
|
581
|
+
"semantic per-type column/row/cost scaling of the LP arrays "
|
|
582
|
+
"(Layer 2), and HiGHS user_*_scale recommendation (Layer 3). "
|
|
583
|
+
"Produces the most robust conditioning. Default.\n"
|
|
584
|
+
"\n"
|
|
585
|
+
"Precedence for user_objective_scale and user_bound_scale:\n"
|
|
586
|
+
" 1. --user-bound-scale N (CLI override)\n"
|
|
587
|
+
" 2. user_*_scale set via solver config file\n"
|
|
588
|
+
" 3. Layer 3 autoscaler recommendation\n"
|
|
589
|
+
" 4. HiGHS default (0)\n"
|
|
590
|
+
"\n"
|
|
591
|
+
"Env fallback: FLEXTOOL_SCALING."
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
parser.add_argument('--user-bound-scale', type=int, default=None,
|
|
595
|
+
metavar='N',
|
|
596
|
+
help='HiGHS ``user_bound_scale`` override (power of '
|
|
597
|
+
'two: multiplies all col bounds and RHS by '
|
|
598
|
+
'2**N). When HiGHS prints '
|
|
599
|
+
'"Consider setting the user_bound_scale option '
|
|
600
|
+
'to <N>" in its scaling warning, pass that '
|
|
601
|
+
'<N> here. Clamped to [-10, 0]. Overrides '
|
|
602
|
+
'any DB value; falls through to the '
|
|
603
|
+
'input-data heuristic when unset.')
|
|
604
|
+
parser.add_argument('--presolve', choices=['on', 'off', 'choose'],
|
|
605
|
+
default=None,
|
|
606
|
+
help='HiGHS ``presolve`` override. Default '
|
|
607
|
+
'(unset) keeps the determinism-pinned '
|
|
608
|
+
'"on" setting from '
|
|
609
|
+
'``DETERMINISM_OPTIONS``. ``off`` disables '
|
|
610
|
+
'presolve entirely (much slower but useful '
|
|
611
|
+
'for memory or numerical diagnostics).')
|
|
612
|
+
parser.add_argument('--solver-log-level',
|
|
613
|
+
choices=['silent', 'normal', 'verbose'],
|
|
614
|
+
default=None,
|
|
615
|
+
help='HiGHS log verbosity. ``silent`` sets '
|
|
616
|
+
'``output_flag=false`` (suppress HiGHS '
|
|
617
|
+
'console output). ``normal`` (default) '
|
|
618
|
+
'and ``verbose`` both set '
|
|
619
|
+
'``output_flag=true``; ``verbose`` also '
|
|
620
|
+
'bumps ``log_dev_level=2`` for per-'
|
|
621
|
+
'iteration solver telemetry. Replaces '
|
|
622
|
+
'the v55-era DB-stored solver_log_level '
|
|
623
|
+
'knob (removed in Batch C.7).')
|
|
624
|
+
parser.add_argument('--solver-time-limit', type=float, default=None,
|
|
625
|
+
metavar='SECONDS',
|
|
626
|
+
help='HiGHS wall-clock time limit '
|
|
627
|
+
'(``time_limit`` option, seconds). '
|
|
628
|
+
'Unset (default) means no limit. '
|
|
629
|
+
'Replaces the v55-era DB-stored '
|
|
630
|
+
'solver_time_limit knob (removed in '
|
|
631
|
+
'Batch C.8). Routed through the '
|
|
632
|
+
'effective-options resolver as a CLI '
|
|
633
|
+
'override (highest precedence).')
|
|
634
|
+
parser.add_argument('--solver-mip-gap', type=float, default=None,
|
|
635
|
+
metavar='GAP',
|
|
636
|
+
help='HiGHS MIP relative optimality gap '
|
|
637
|
+
'(``mip_rel_gap`` option). Unset (default) '
|
|
638
|
+
'keeps HiGHS\' built-in 1e-4. Only affects '
|
|
639
|
+
'MIP solves (integer investments, '
|
|
640
|
+
'unit-commitment / online variables); '
|
|
641
|
+
'pure-LP solves ignore it. Routed through '
|
|
642
|
+
'the effective-options resolver as a CLI '
|
|
643
|
+
'override (highest precedence).')
|
|
644
|
+
parser.add_argument('--matrix-file-format',
|
|
645
|
+
choices=['mps', 'lp'],
|
|
646
|
+
default=None,
|
|
647
|
+
help='On-disk format used when the solver is '
|
|
648
|
+
'dispatched via a matrix file: ``mps`` '
|
|
649
|
+
'(default) or ``lp``. The in-process '
|
|
650
|
+
'vs. file decision is implicit:\n'
|
|
651
|
+
'* HiGHS + no ``--save-memory`` -> direct '
|
|
652
|
+
'(in-process binding, fastest).\n'
|
|
653
|
+
'* HiGHS + ``--save-memory`` -> file write '
|
|
654
|
+
'(polar-high round-trips through MPS '
|
|
655
|
+
'internally; this flag has no effect).\n'
|
|
656
|
+
'* Commercial solver (gurobi / cplex / '
|
|
657
|
+
'xpress / copt) -> file write using the '
|
|
658
|
+
'chosen format.\n'
|
|
659
|
+
'Replaces the v55-era ``--solver-io-api`` '
|
|
660
|
+
'flag; the engine still uses '
|
|
661
|
+
'``direct|mps|lp`` internally for '
|
|
662
|
+
'``SolverConfig.io_api``.')
|
|
663
|
+
parser.add_argument('--csv-dump', action='store_true',
|
|
664
|
+
default=False,
|
|
665
|
+
help='Debug visibility for cascade-internal '
|
|
666
|
+
'artefacts. Default: the cascade keeps '
|
|
667
|
+
'input/, solve_data/, cross_solve/, and '
|
|
668
|
+
'output_raw/ off disk in the final work '
|
|
669
|
+
'folder, leaving only the user-facing '
|
|
670
|
+
'output_parquet/<scenario>/ tree (plus any '
|
|
671
|
+
'output_csv/, output_excel/, output_plots/ '
|
|
672
|
+
'requested via --write-methods). With the '
|
|
673
|
+
'flag set, every intermediate directory '
|
|
674
|
+
'survives the run for inspection.')
|
|
675
|
+
|
|
676
|
+
args = parser.parse_args()
|
|
677
|
+
# --user-bound-scale / --presolve are surfaced through env vars
|
|
678
|
+
# read by ``_orchestration._finalise_highs_options`` and the
|
|
679
|
+
# cascade's user_bound_scale resolution. Env vars keep the
|
|
680
|
+
# threading shallow: no new kwargs on run_chain_from_db /
|
|
681
|
+
# run_orchestration / _drive_cascade required.
|
|
682
|
+
if args.user_bound_scale is not None:
|
|
683
|
+
os.environ['FLEXTOOL_USER_BOUND_SCALE'] = str(args.user_bound_scale)
|
|
684
|
+
if args.presolve is not None:
|
|
685
|
+
os.environ['FLEXTOOL_HIGHS_PRESOLVE'] = args.presolve
|
|
686
|
+
if args.highs_threads is not None and args.highs_threads >= 1:
|
|
687
|
+
os.environ['FLEXTOOL_HIGHS_THREADS'] = str(args.highs_threads)
|
|
688
|
+
if args.solver_log_level is not None:
|
|
689
|
+
os.environ['FLEXTOOL_SOLVER_LOG_LEVEL'] = args.solver_log_level
|
|
690
|
+
if args.solver_time_limit is not None:
|
|
691
|
+
# Use the existing FLEXTOOL_HIGHS_TIME_LIMIT env var which the
|
|
692
|
+
# orchestrator's CLI-overrides builder already consults; the
|
|
693
|
+
# name is a historical artefact from the diagnostic shim that
|
|
694
|
+
# predated the resolver but the semantics are identical.
|
|
695
|
+
os.environ['FLEXTOOL_HIGHS_TIME_LIMIT'] = str(args.solver_time_limit)
|
|
696
|
+
if args.solver_mip_gap is not None:
|
|
697
|
+
os.environ['FLEXTOOL_HIGHS_MIP_GAP'] = str(args.solver_mip_gap)
|
|
698
|
+
if args.matrix_file_format is not None:
|
|
699
|
+
os.environ['FLEXTOOL_MATRIX_FILE_FORMAT'] = args.matrix_file_format
|
|
700
|
+
# ``--scaling`` (off/solver_only/basic/full) — CLI > env > default-full.
|
|
701
|
+
# Surfacing via the same ``FLEXTOOL_SCALING`` env var that
|
|
702
|
+
# ``resolve_scaling_config`` already consults keeps the threading
|
|
703
|
+
# shallow (no new kwargs on run_chain_from_db / run_orchestration /
|
|
704
|
+
# _drive_cascade). When the flag is unset (``args.scaling is None``)
|
|
705
|
+
# the existing env value — if any — survives untouched, preserving
|
|
706
|
+
# the env-fallback contract.
|
|
707
|
+
if args.scaling is not None:
|
|
708
|
+
os.environ['FLEXTOOL_SCALING'] = args.scaling
|
|
709
|
+
# ``--save-memory`` — opt-in peak-RSS reduction at solve time.
|
|
710
|
+
# Plumbed via env var so the orchestrator picks it up without an
|
|
711
|
+
# extra kwarg on ``run_chain_from_db`` / ``_drive_cascade``.
|
|
712
|
+
if args.save_memory:
|
|
713
|
+
os.environ['FLEXTOOL_SAVE_MEMORY'] = '1'
|
|
714
|
+
# ``--warm-start`` — opt-in HiGHS basis reuse across structurally
|
|
715
|
+
# identical solves (save-memory subprocess path only). Plumbed via
|
|
716
|
+
# env var like ``--save-memory``; the subprocess solver reads it
|
|
717
|
+
# directly and fails safe to a cold solve on any mismatch.
|
|
718
|
+
if args.warm_start:
|
|
719
|
+
os.environ['FLEXTOOL_WARM_START'] = '1'
|
|
720
|
+
|
|
721
|
+
# Accept either a SQLAlchemy URL ("sqlite:///path") or a bare
|
|
722
|
+
# filesystem path ("path/to.sqlite") for any DB argument. Downstream
|
|
723
|
+
# readers (SpineDbReader) already do this, but DatabaseMapping calls
|
|
724
|
+
# in this file consume the args directly, so normalise once here.
|
|
725
|
+
def _as_db_url(value):
|
|
726
|
+
if value is None:
|
|
727
|
+
return None
|
|
728
|
+
return value if "://" in value else f"sqlite:///{value}"
|
|
729
|
+
|
|
730
|
+
args.input_db_url = _as_db_url(args.input_db_url)
|
|
731
|
+
args.output_db_url = _as_db_url(args.output_db_url)
|
|
732
|
+
args.settings_db_url = _as_db_url(args.settings_db_url)
|
|
733
|
+
|
|
734
|
+
input_db_url = args.input_db_url
|
|
735
|
+
settings_db_url = args.settings_db_url
|
|
736
|
+
scenario_name = args.scenario_name
|
|
737
|
+
debug_level = args.debug # 'off' | 'basic' | 'full'
|
|
738
|
+
DEBUG = debug_level != 'off'
|
|
739
|
+
# ``--debug=basic`` widens stdout to include the full per-checkpoint
|
|
740
|
+
# phase-progress trace (every memory recorder event, not just the
|
|
741
|
+
# six whitelisted phase labels). ``--debug=full`` additionally
|
|
742
|
+
# enables tracemalloc-backed diagnostics that write the
|
|
743
|
+
# per-checkpoint CSV to ``solve_data/memory_diagnostics.csv`` — the
|
|
744
|
+
# tracemalloc instrumentation typically slows allocation-heavy
|
|
745
|
+
# phases by 2-5×, so it is gated to the explicit ``full`` opt-in.
|
|
746
|
+
# ``setdefault`` lets a caller still override either env var.
|
|
747
|
+
if debug_level in ('basic', 'full'):
|
|
748
|
+
os.environ.setdefault('FLEXTOOL_MEMORY_VERBOSE', '1')
|
|
749
|
+
if debug_level == 'full':
|
|
750
|
+
os.environ.setdefault('FLEXTOOL_MEMORY_DIAGNOSTICS', '1')
|
|
751
|
+
# The TRUE output root (where outputs land, and what is persisted to
|
|
752
|
+
# the "Output info" DB as scenario/output_location) is resolved by a
|
|
753
|
+
# 5-tier rule (see ``resolve_output_path`` for the full rationale):
|
|
754
|
+
# 1. ``--output-location`` (explicit wins),
|
|
755
|
+
# 2. ``--project-folder-file`` (user-local
|
|
756
|
+
# CONTENTS name a project folder; when blank/ redirect; a
|
|
757
|
+
# missing, the file's .parent.parent repo root) supplied file
|
|
758
|
+
# NEVER falls
|
|
759
|
+
# through to CWD),
|
|
760
|
+
# 3. ``<project>`` when the input DB sits in an (GUI project
|
|
761
|
+
# ``input_sources/`` dir layout),
|
|
762
|
+
# 4. ``--flextool-location``.parent.parent (legacy bridge),
|
|
763
|
+
# 5. CWD (fallback).
|
|
764
|
+
# Tiers 3-5 are only reached when NO --project-folder-file is supplied.
|
|
765
|
+
output_path = resolve_output_path(
|
|
766
|
+
input_db_url=args.input_db_url,
|
|
767
|
+
flextool_location=args.flextool_location,
|
|
768
|
+
output_location=args.output_location,
|
|
769
|
+
cwd=Path.cwd(),
|
|
770
|
+
project_folder_file=args.project_folder_file,
|
|
771
|
+
)
|
|
772
|
+
work_folder = Path(args.work_folder) if args.work_folder else Path.cwd()
|
|
773
|
+
work_folder.mkdir(parents=True, exist_ok=True)
|
|
774
|
+
wf = work_folder
|
|
775
|
+
|
|
776
|
+
# Default formatter strips the ``INFO:<file>:<line>:`` preamble so
|
|
777
|
+
# user-facing INFO lines (license status, solver progress, etc.)
|
|
778
|
+
# read as plain prose. WARNING / ERROR carry their level via the
|
|
779
|
+
# message body of the ``logging.warning(...)`` calls themselves
|
|
780
|
+
# ("Failed to ...", etc.), so dropping ``%(levelname)s`` here
|
|
781
|
+
# doesn't hide the severity. ``--debug`` restores the full
|
|
782
|
+
# prefix for diagnosis.
|
|
783
|
+
logging.basicConfig(
|
|
784
|
+
level=logging.DEBUG if DEBUG else logging.INFO,
|
|
785
|
+
format=(
|
|
786
|
+
'%(levelname)s:%(filename)s:%(lineno)d:%(message)s'
|
|
787
|
+
if DEBUG else '%(message)s'
|
|
788
|
+
),
|
|
789
|
+
handlers=[logging.StreamHandler(sys.stdout)]
|
|
790
|
+
)
|
|
791
|
+
if not DEBUG:
|
|
792
|
+
# Silence routine "wrote …" / "Wrote N output variables" INFO
|
|
793
|
+
# chatter from the per-solve output + handoff writers in regular
|
|
794
|
+
# mode. These fire on every sub-solve and tell the user
|
|
795
|
+
# nothing they can't infer from "Solver" + the parquet
|
|
796
|
+
# contents. WARNINGs (failed writes, missing files, etc.)
|
|
797
|
+
# still surface because we only raise the writer-module
|
|
798
|
+
# thresholds to WARNING. --debug restores the full chatter.
|
|
799
|
+
for _noisy in (
|
|
800
|
+
"flextool.process_outputs.handoff_writers",
|
|
801
|
+
"flextool.process_outputs.read_highs_solution",
|
|
802
|
+
"flextool.engine_polars.handoff_writers",
|
|
803
|
+
):
|
|
804
|
+
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
|
805
|
+
|
|
806
|
+
# Self-heal missing lightweight settings DBs so fresh clones don't
|
|
807
|
+
# fail opaquely when the user forgot to run `flextool-update`. Only
|
|
808
|
+
# seeds output_info / output_settings / comparison_settings by
|
|
809
|
+
# basename; other paths are left untouched.
|
|
810
|
+
for _candidate in (args.output_db_url, args.settings_db_url):
|
|
811
|
+
try:
|
|
812
|
+
ensure_settings_db(_candidate)
|
|
813
|
+
except Exception as _exc:
|
|
814
|
+
logging.warning("Failed to auto-seed %s: %s", _candidate, _exc)
|
|
815
|
+
|
|
816
|
+
# Phase-timing recorder: constructed once per CLI invocation, lives
|
|
817
|
+
# on ``runner.state.timing_recorder``, writes a structured timings.csv
|
|
818
|
+
# at <work_folder>/solve_data/timings.csv (one row per phase, atomic
|
|
819
|
+
# append style so a crash mid-run still leaves usable data).
|
|
820
|
+
timing_recorder = TimingRecorder(work_folder=wf, scenario=scenario_name)
|
|
821
|
+
t_total_start = time.perf_counter()
|
|
822
|
+
|
|
823
|
+
# resolve_precision_digits respects FLEXTOOL_PRECISION_DIGITS env override.
|
|
824
|
+
effective_precision = resolve_precision_digits(args.precision_digits)
|
|
825
|
+
|
|
826
|
+
# --- Regional filter mode (Agent 3.1) --------------------------------
|
|
827
|
+
# ``--region GROUP`` produces ``input_region_<GROUP>/`` and exits
|
|
828
|
+
# without invoking the solver. The Benders coordinator (Agent
|
|
829
|
+
# 3.2) then orchestrates multiple region solves itself.
|
|
830
|
+
if args.region:
|
|
831
|
+
from flextool.decomposition.region_decomposition import (
|
|
832
|
+
write_input_for_region as _write_input_for_region,
|
|
833
|
+
)
|
|
834
|
+
_region_output = wf / f"input_region_{args.region}"
|
|
835
|
+
try:
|
|
836
|
+
result = _write_input_for_region(
|
|
837
|
+
input_db_url=input_db_url,
|
|
838
|
+
scenario_name=scenario_name,
|
|
839
|
+
logger=logging.getLogger("flextool.region_filter"),
|
|
840
|
+
region_group=args.region,
|
|
841
|
+
output_dir=_region_output,
|
|
842
|
+
work_folder=work_folder,
|
|
843
|
+
precision_digits=effective_precision,
|
|
844
|
+
)
|
|
845
|
+
except Exception as exc:
|
|
846
|
+
logging.error("Regional filter failed: %s", exc, exc_info=True)
|
|
847
|
+
sys.exit(-1)
|
|
848
|
+
print(f"Wrote filtered region inputs to {_region_output}")
|
|
849
|
+
print(
|
|
850
|
+
f"Coupling variables ({len(result['half_flows'])}): "
|
|
851
|
+
f"{[hf.virtual_node for hf in result['half_flows']]}"
|
|
852
|
+
)
|
|
853
|
+
sys.exit(0)
|
|
854
|
+
|
|
855
|
+
# Benders decomposition is now DB-driven and per-solve: the
|
|
856
|
+
# orchestrator reads ``solve.decomposition`` for each solve and runs
|
|
857
|
+
# the Benders region driver for the ones set to ``benders``
|
|
858
|
+
# (see engine_polars._orchestration / docs/dev/decomposition.md). The
|
|
859
|
+
# old global ``--decomposition lagrangian`` standalone path was
|
|
860
|
+
# removed; nothing special happens here — the normal run path below
|
|
861
|
+
# handles every scheme.
|
|
862
|
+
|
|
863
|
+
# Resolve scenario_name when omitted: pull it from the DB's
|
|
864
|
+
# active filter. ``run_chain_from_db`` accepts a None scenario
|
|
865
|
+
# but downstream ``SolveConfig.load_from_db_url`` requires a
|
|
866
|
+
# concrete name, so fix it up here.
|
|
867
|
+
if not scenario_name:
|
|
868
|
+
with DatabaseMapping(input_db_url) as db_map:
|
|
869
|
+
_filters = db_map.get_filter_configs()
|
|
870
|
+
if _filters:
|
|
871
|
+
scenario_name = name_from_dict(_filters[0])
|
|
872
|
+
|
|
873
|
+
# Header block — one aligned key/value pair per line, blank lines
|
|
874
|
+
# before and after, so the user has a self-contained summary of
|
|
875
|
+
# what's being run.
|
|
876
|
+
_header_pairs = [
|
|
877
|
+
("Work dir", str(work_folder)),
|
|
878
|
+
("DB URL", str(input_db_url)),
|
|
879
|
+
("Scenario", scenario_name if scenario_name else "(unresolved)"),
|
|
880
|
+
("Output", str(args.output_location or output_path)),
|
|
881
|
+
]
|
|
882
|
+
_header_keyw = max(len(k) for k, _ in _header_pairs) + 2
|
|
883
|
+
print("")
|
|
884
|
+
for _k, _v in _header_pairs:
|
|
885
|
+
print(f"{(_k + ':').ljust(_header_keyw)}{_v}")
|
|
886
|
+
# No trailing blank line here -- the "Available solvers:" log emits
|
|
887
|
+
# its own trailing newline so the blank lands AFTER the licence line,
|
|
888
|
+
# not before it.
|
|
889
|
+
|
|
890
|
+
try:
|
|
891
|
+
return_code, last_step = _run_solve(
|
|
892
|
+
args, scenario_name, work_folder, timing_recorder,
|
|
893
|
+
)
|
|
894
|
+
except Exception as e:
|
|
895
|
+
# FlexToolUserError signals a user-visible configuration problem
|
|
896
|
+
# (unknown solver, missing license, model-level solver error).
|
|
897
|
+
# The message is already human-readable; logging the traceback
|
|
898
|
+
# on top is just noise. Other exceptions get the full
|
|
899
|
+
# traceback because they're (probably) bugs in flextool.
|
|
900
|
+
try:
|
|
901
|
+
from flextool.engine_polars._solver_dispatch import (
|
|
902
|
+
FlexToolUserError,
|
|
903
|
+
)
|
|
904
|
+
except Exception: # noqa: BLE001
|
|
905
|
+
FlexToolUserError = () # type: ignore[assignment]
|
|
906
|
+
if isinstance(e, FlexToolUserError):
|
|
907
|
+
logging.error(str(e))
|
|
908
|
+
else:
|
|
909
|
+
logging.error(
|
|
910
|
+
f"Native cascade failed: {str(e)}\n"
|
|
911
|
+
f"Traceback:\n{traceback.format_exc()}"
|
|
912
|
+
)
|
|
913
|
+
sys.exit(1)
|
|
914
|
+
|
|
915
|
+
# If successful and requested, write outputs
|
|
916
|
+
output_subdir = args.output_subdir or scenario_name
|
|
917
|
+
if return_code == 0:
|
|
918
|
+
t_write_outputs = time.perf_counter()
|
|
919
|
+
# Δ.31 — pass the last step's flex_data + solution so
|
|
920
|
+
# write_outputs can build par/s in memory. ``solve_name``
|
|
921
|
+
# is the complete sub-solve identifier (e.g. ``y2025_5week``
|
|
922
|
+
# for a roll, or just the scenario name for a single solve).
|
|
923
|
+
wo_solve_name = (
|
|
924
|
+
last_step.solve_name if last_step else None
|
|
925
|
+
) or scenario_name
|
|
926
|
+
# A standalone Benders-only final solve carries only a
|
|
927
|
+
# SnapshotSolution invest carrier (not a full Solution), so it
|
|
928
|
+
# cannot yet drive processed outputs (TIER 2, planned follow-up).
|
|
929
|
+
# Emit a clear, targeted notice and SKIP write_outputs entirely
|
|
930
|
+
# rather than letting it fail and degrade to a generic warning.
|
|
931
|
+
# The invest→dispatch chain ends on a real dispatch Solution
|
|
932
|
+
# (is_benders=False) and is unaffected.
|
|
933
|
+
if last_step is not None and getattr(
|
|
934
|
+
last_step, "is_benders", False
|
|
935
|
+
):
|
|
936
|
+
logging.info(
|
|
937
|
+
"Final solve '%s' ran under decomposition=benders and "
|
|
938
|
+
"does not yet produce processed outputs on its own. The "
|
|
939
|
+
"decomposition objective/region summary was logged above. "
|
|
940
|
+
"To get output files, add a downstream dispatch solve to "
|
|
941
|
+
"the chain (model.solves = [%s, <dispatch solve>]); the "
|
|
942
|
+
"dispatch solve produces the outputs. (Standalone "
|
|
943
|
+
"Benders output processing is a planned follow-up.)",
|
|
944
|
+
wo_solve_name,
|
|
945
|
+
wo_solve_name,
|
|
946
|
+
)
|
|
947
|
+
else:
|
|
948
|
+
try:
|
|
949
|
+
# Multi-solve (rolling) note: the last step alone would
|
|
950
|
+
# collapse par/s to the final roll's (d,t). write_outputs
|
|
951
|
+
# detects the per-roll realized slices persisted under
|
|
952
|
+
# ``output_raw/`` (``has_persisted_slices``) and unions them
|
|
953
|
+
# into the full-timeline par/s; ``last_step`` then only
|
|
954
|
+
# supplies the static (solve-invariant) attrs + the per-attr
|
|
955
|
+
# shape template. We deliberately do NOT pass ``solve_steps``
|
|
956
|
+
# here — the union activates on persisted-parquet presence and
|
|
957
|
+
# carries solve labels via parquet filenames + the
|
|
958
|
+
# ``output_raw/_solve_order.txt`` creation-order manifest.
|
|
959
|
+
wo_flex_data = last_step.flex_data if last_step else None
|
|
960
|
+
wo_solution = last_step.solution if last_step else None
|
|
961
|
+
write_outputs(
|
|
962
|
+
scenario_name=scenario_name,
|
|
963
|
+
output_location=args.output_location,
|
|
964
|
+
subdir=output_subdir,
|
|
965
|
+
output_config_path=args.output_config,
|
|
966
|
+
active_configs=args.active_configs,
|
|
967
|
+
write_methods=args.write_methods,
|
|
968
|
+
plot_rows=(
|
|
969
|
+
tuple(args.plot_rows) if args.plot_rows else None
|
|
970
|
+
),
|
|
971
|
+
settings_db_url=settings_db_url,
|
|
972
|
+
fallback_output_location=str(output_path),
|
|
973
|
+
raw_output_dir=str(wf / 'output_raw'),
|
|
974
|
+
only_first_file=args.only_first_file_per_plot,
|
|
975
|
+
timing_recorder=timing_recorder,
|
|
976
|
+
flex_data=wo_flex_data,
|
|
977
|
+
solution=wo_solution,
|
|
978
|
+
solve_name=wo_solve_name,
|
|
979
|
+
flex_data_provider=getattr(
|
|
980
|
+
last_step, "flex_data_provider", None
|
|
981
|
+
),
|
|
982
|
+
results_db_url=args.results_db_url,
|
|
983
|
+
)
|
|
984
|
+
except FileNotFoundError as exc:
|
|
985
|
+
# The in-memory parameter / set path doesn't read
|
|
986
|
+
# ``solve_data/`` CSVs, but ``read_variables`` still
|
|
987
|
+
# reads ``output_raw/`` parquets. Catch missing-parquet
|
|
988
|
+
# cases here (rare) and exit cleanly.
|
|
989
|
+
logging.warning(
|
|
990
|
+
"write_outputs failed (%s). output_raw/ artefacts "
|
|
991
|
+
"ARE produced; downstream output_csv/, output_parquet/, "
|
|
992
|
+
"output_excel/, output_plots/ are skipped on this run.",
|
|
993
|
+
exc,
|
|
994
|
+
)
|
|
995
|
+
timing_recorder.record('write_outputs', subphase='total',
|
|
996
|
+
seconds=time.perf_counter() - t_write_outputs,
|
|
997
|
+
t_start=t_write_outputs)
|
|
998
|
+
|
|
999
|
+
# output_raw/ is the cascade's intermediate parquet stash for
|
|
1000
|
+
# write_outputs to consume. Keep it on disk only when the user
|
|
1001
|
+
# opted in via --csv-dump (debug). On a normal run the user only
|
|
1002
|
+
# wants the canonical output_parquet/<scenario>/ tree.
|
|
1003
|
+
if not args.csv_dump:
|
|
1004
|
+
raw_dir = wf / 'output_raw'
|
|
1005
|
+
if raw_dir.exists():
|
|
1006
|
+
shutil.rmtree(raw_dir, ignore_errors=True)
|
|
1007
|
+
|
|
1008
|
+
full_seconds = time.perf_counter() - t_total_start
|
|
1009
|
+
print("\n--- Full execution time %.4s seconds ---------------------------------------" % full_seconds)
|
|
1010
|
+
print("--------------------------------------------------------------------------\n")
|
|
1011
|
+
timing_recorder.record('total', seconds=full_seconds, t_start=t_total_start)
|
|
1012
|
+
|
|
1013
|
+
# Move timings.csv into the per-scenario output dir alongside
|
|
1014
|
+
# summary_solve.csv. Mirror write_outputs's resolution of the output
|
|
1015
|
+
# location so the file lands in the same parent regardless of whether
|
|
1016
|
+
# output_location was supplied via the CLI / env / settings DB.
|
|
1017
|
+
try:
|
|
1018
|
+
_resolved_output_location = args.output_location or str(output_path) or ''
|
|
1019
|
+
_final_csv_dir = (
|
|
1020
|
+
Path(_resolved_output_location) / 'output_csv' / output_subdir
|
|
1021
|
+
if output_subdir else
|
|
1022
|
+
Path(_resolved_output_location) / 'output_csv'
|
|
1023
|
+
)
|
|
1024
|
+
timing_recorder.finalize(_final_csv_dir)
|
|
1025
|
+
except Exception as _exc:
|
|
1026
|
+
logging.warning("Failed to copy timings.csv to output dir: %s", _exc)
|
|
1027
|
+
|
|
1028
|
+
# Write scenario information to output database if provided
|
|
1029
|
+
if args.output_db_url:
|
|
1030
|
+
# Check if database exists
|
|
1031
|
+
db_exists = os.path.exists(args.output_db_url.replace('sqlite:///', ''))
|
|
1032
|
+
|
|
1033
|
+
with DatabaseMapping(args.output_db_url, create=not db_exists) as output_db:
|
|
1034
|
+
# Create/update scenario class if it doesn't exist
|
|
1035
|
+
output_db.add_or_update_entity_class(name="scenario")
|
|
1036
|
+
|
|
1037
|
+
# Create/update parameter definition for 'output_location'
|
|
1038
|
+
output_db.add_or_update_parameter_definition(
|
|
1039
|
+
entity_class_name="scenario",
|
|
1040
|
+
name="output_location",
|
|
1041
|
+
description="Full path to the working directory"
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
# Add/update scenario entity
|
|
1045
|
+
output_db.add_or_update_entity(
|
|
1046
|
+
entity_class_name="scenario",
|
|
1047
|
+
name=scenario_name
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
output_db.add_or_update_alternative(name=scenario_name)
|
|
1051
|
+
|
|
1052
|
+
# Convert folder path to database representation
|
|
1053
|
+
value, type_ = to_database(str(output_path))
|
|
1054
|
+
|
|
1055
|
+
# Add/update folder infio
|
|
1056
|
+
output_db.add_or_update_parameter_value(
|
|
1057
|
+
entity_class_name="scenario",
|
|
1058
|
+
entity_byname=(scenario_name,),
|
|
1059
|
+
parameter_definition_name="output_location",
|
|
1060
|
+
alternative_name=scenario_name,
|
|
1061
|
+
value=value,
|
|
1062
|
+
type=type_
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
output_db.add_or_update_parameter_definition(
|
|
1066
|
+
entity_class_name="scenario",
|
|
1067
|
+
name="finish_time",
|
|
1068
|
+
description="Timestamp when the scenario run finished"
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
dt_value = DateTime(datetime.now())
|
|
1072
|
+
value, type_ = to_database(dt_value)
|
|
1073
|
+
|
|
1074
|
+
# Add/update execution time
|
|
1075
|
+
output_db.add_or_update_parameter_value(
|
|
1076
|
+
entity_class_name="scenario",
|
|
1077
|
+
entity_byname=(scenario_name,),
|
|
1078
|
+
parameter_definition_name="finish_time",
|
|
1079
|
+
alternative_name=scenario_name,
|
|
1080
|
+
value=value,
|
|
1081
|
+
type=type_
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
try:
|
|
1085
|
+
output_db.commit_session("Added/updated scenario information")
|
|
1086
|
+
except NothingToCommit:
|
|
1087
|
+
pass
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
# Debug flag
|
|
1092
|
+
DEBUG = False # Set via environment variable or config
|
|
1093
|
+
|
|
1094
|
+
if __name__ == '__main__':
|
|
1095
|
+
run_tool(main)
|