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,331 @@
|
|
|
1
|
+
"""Accept/reject policy for a completed top-level cascade solve.
|
|
2
|
+
|
|
3
|
+
FlexTool historically treated any HiGHS model status other than
|
|
4
|
+
``kOptimal`` as a hard failure: it logged "non-optimal solve", printed a
|
|
5
|
+
scaling hint, and aborted the cascade before writing any output. That is
|
|
6
|
+
wrong for one common, legitimate case.
|
|
7
|
+
|
|
8
|
+
An interior-point solve run **without crossover** (a choice the model
|
|
9
|
+
generator makes via the ``run_crossover`` solver option) returns the raw
|
|
10
|
+
interior point rather than a basic vertex. On an aggressive presolve, the
|
|
11
|
+
HiGHS post-solve step can leave the *dual* objective slightly inconsistent
|
|
12
|
+
even though the *primal* solution is genuinely feasible and — because the
|
|
13
|
+
pre-post-solve duality gap was ~0 — in-practice optimal. HiGHS reports
|
|
14
|
+
this as ``kUnknown`` (it cannot *certify* optimality), not as a failure.
|
|
15
|
+
FlexTool consumes the primal solution for every output, so such a solve is
|
|
16
|
+
usable.
|
|
17
|
+
|
|
18
|
+
This module decides, from the solver's own diagnostics, whether a
|
|
19
|
+
non-``kOptimal`` solve is safe to accept:
|
|
20
|
+
|
|
21
|
+
* ``kOptimal`` → accept (unchanged).
|
|
22
|
+
* a genuine failure status → reject, naming the precise cause.
|
|
23
|
+
* ``kUnknown`` that is primal-feasible with a small primal--dual objective
|
|
24
|
+
gap → accept as *near-optimal* (use the primal solution), else reject.
|
|
25
|
+
|
|
26
|
+
The predicate is conjunctive and derives its primal-feasibility margin
|
|
27
|
+
from the solver's own ``primal_feasibility_tolerance`` rather than a magic
|
|
28
|
+
constant, so it cannot silently wave through an infeasible solution. All
|
|
29
|
+
policy and thresholds live here; polar-high only supplies the facts
|
|
30
|
+
(:meth:`polar_high.Solution.solve_diagnostics`).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
from flextool.engine_polars.autoscale._report import (
|
|
40
|
+
format_nonoptimal_hint as _format_nonoptimal_hint,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
44
|
+
from polar_high import Solution
|
|
45
|
+
from polar_high.autoscale import RangeReport
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- thresholds (env-overridable, deterministic defaults) -------------------
|
|
49
|
+
#
|
|
50
|
+
# Defaults are justified against the observed failure and the tolerances the
|
|
51
|
+
# model already declares acceptable; see module docstring and the PR notes.
|
|
52
|
+
|
|
53
|
+
# Upper bound on the accepted primal--dual objective gap for a near-optimal
|
|
54
|
+
# ``kUnknown``. ``primal_dual_objective_error`` is an upper bound on how far
|
|
55
|
+
# the primal could be from optimal if the (inconsistent) duals were taken at
|
|
56
|
+
# face value. The observed crossover-off roll had 0.00477 (0.477%); models
|
|
57
|
+
# routinely carry ``mip_rel_gap=0.01`` (1%), i.e. a 1% optimality gap is
|
|
58
|
+
# already deemed acceptable. 1e-2 accepts the observed case with headroom
|
|
59
|
+
# while rejecting the ~3% stalls seen in decomposition subproblems.
|
|
60
|
+
_ACCEPT_PD_GAP_DEFAULT = 1e-2
|
|
61
|
+
|
|
62
|
+
# Scale-invariant primal-feasibility ceiling: two decades above the default
|
|
63
|
+
# ``primal_feasibility_tolerance`` (1e-7) but far below any physically
|
|
64
|
+
# meaningful constraint violation.
|
|
65
|
+
_ACCEPT_PRIMAL_REL_DEFAULT = 1e-6
|
|
66
|
+
|
|
67
|
+
# Absolute backstop: HiGHS enforces feasibility on the internally-scaled LP,
|
|
68
|
+
# so the unscaled slack can exceed the nominal tolerance. One decade of
|
|
69
|
+
# un-scaling headroom over the solver's own tolerance.
|
|
70
|
+
_ACCEPT_PRIMAL_ABS_MARGIN_DEFAULT = 10.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _env_float(name: str, default: float) -> float:
|
|
74
|
+
"""Read a positive float from ``os.environ[name]`` or return default.
|
|
75
|
+
|
|
76
|
+
A malformed or non-positive value falls back to the default rather than
|
|
77
|
+
silently disabling a guard.
|
|
78
|
+
"""
|
|
79
|
+
raw = os.environ.get(name)
|
|
80
|
+
if raw is None:
|
|
81
|
+
return default
|
|
82
|
+
try:
|
|
83
|
+
val = float(raw)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return default
|
|
86
|
+
return val if val > 0.0 else default
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _near_optimal_enabled() -> bool:
|
|
90
|
+
"""Whether the conditional near-optimal accept is active.
|
|
91
|
+
|
|
92
|
+
Set ``FLEXTOOL_ACCEPT_NEAR_OPTIMAL=0`` to restore the legacy
|
|
93
|
+
hard-fail-on-non-kOptimal behaviour (e.g. for strict test runs).
|
|
94
|
+
"""
|
|
95
|
+
return os.environ.get("FLEXTOOL_ACCEPT_NEAR_OPTIMAL", "1") != "0"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Model statuses that are never acceptable, mapped to an honest, cause-naming
|
|
99
|
+
# message body. Keyed by ``HighsModelStatus`` enum *name* so the module never
|
|
100
|
+
# imports highspy.
|
|
101
|
+
_FAILURE_MESSAGES: dict[str, str] = {
|
|
102
|
+
"kInfeasible": (
|
|
103
|
+
"is infeasible: no assignment of the variables satisfies all "
|
|
104
|
+
"constraints"
|
|
105
|
+
),
|
|
106
|
+
"kUnbounded": (
|
|
107
|
+
"is unbounded: the objective improves without limit, so a cost or "
|
|
108
|
+
"bound is missing"
|
|
109
|
+
),
|
|
110
|
+
"kUnboundedOrInfeasible": (
|
|
111
|
+
"is unbounded or infeasible (presolve could not distinguish the "
|
|
112
|
+
"two); re-run with presolve off to disambiguate"
|
|
113
|
+
),
|
|
114
|
+
"kTimeLimit": "hit the time limit before proving optimality",
|
|
115
|
+
"kIterationLimit": "hit the iteration limit before proving optimality",
|
|
116
|
+
"kMemoryLimit": "hit the memory limit before proving optimality",
|
|
117
|
+
"kObjectiveBound": (
|
|
118
|
+
"stopped at an objective bound, not a proven optimum"
|
|
119
|
+
),
|
|
120
|
+
"kObjectiveTarget": (
|
|
121
|
+
"stopped at an objective target, not a proven optimum"
|
|
122
|
+
),
|
|
123
|
+
"kSolveError": (
|
|
124
|
+
"failed inside the solver (solve stage); the solution is not usable"
|
|
125
|
+
),
|
|
126
|
+
"kPresolveError": (
|
|
127
|
+
"failed inside the solver (presolve stage); the solution is not "
|
|
128
|
+
"usable"
|
|
129
|
+
),
|
|
130
|
+
"kPostsolveError": (
|
|
131
|
+
"failed inside the solver (postsolve stage); the solution is not "
|
|
132
|
+
"usable"
|
|
133
|
+
),
|
|
134
|
+
"kModelEmpty": "has no variables or constraints",
|
|
135
|
+
"kNotset": "returned no model status",
|
|
136
|
+
"kLoadError": "could not be loaded by the solver",
|
|
137
|
+
"kModelError": "was rejected as malformed by the solver",
|
|
138
|
+
"kInterrupt": "was interrupted before proving optimality",
|
|
139
|
+
"kHighsInterrupt": "was interrupted before proving optimality",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Acceptance:
|
|
145
|
+
"""Outcome of :func:`classify_acceptance`.
|
|
146
|
+
|
|
147
|
+
``accepted`` — whether the cascade may consume this solve's solution.
|
|
148
|
+
``near_optimal`` — accepted despite a non-``kOptimal`` status (log INFO,
|
|
149
|
+
not silently).
|
|
150
|
+
``message`` — human-readable line describing the decision.
|
|
151
|
+
``scaling_hint`` — optional multi-line remediation hint, populated only
|
|
152
|
+
when scaling is genuinely implicated in a *reject*.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
accepted: bool
|
|
156
|
+
near_optimal: bool
|
|
157
|
+
message: str
|
|
158
|
+
scaling_hint: str | None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _scaling_hint_for_reject(
|
|
162
|
+
ranges_post: "RangeReport | None",
|
|
163
|
+
) -> str | None:
|
|
164
|
+
"""Return the scaling remediation hint only when the *actually solved*
|
|
165
|
+
(post-autoscale) LP is still ill-conditioned.
|
|
166
|
+
|
|
167
|
+
The historical bug keyed this off the raw, pre-autoscale ranges — always
|
|
168
|
+
wide for FlexTool commodity ladders — so the hint fired on essentially
|
|
169
|
+
every non-optimal solve regardless of cause. Keying off ``ranges_post``
|
|
170
|
+
(the post-Layer-2 ranges, computed only when the pre-ranges tripped the
|
|
171
|
+
detector) means the hint appears only when the autoscaler could NOT tame
|
|
172
|
+
the range spread, i.e. when scaling is a plausible culprit.
|
|
173
|
+
"""
|
|
174
|
+
if ranges_post is None or not ranges_post.trigger:
|
|
175
|
+
return None
|
|
176
|
+
hint = _format_nonoptimal_hint(ranges_post)
|
|
177
|
+
return hint or None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def classify_acceptance(
|
|
181
|
+
sol: "Solution",
|
|
182
|
+
*,
|
|
183
|
+
ranges_post: "RangeReport | None",
|
|
184
|
+
solve_name: str,
|
|
185
|
+
) -> Acceptance:
|
|
186
|
+
"""Decide whether *sol* is safe for the cascade to consume.
|
|
187
|
+
|
|
188
|
+
See the module docstring for the policy. ``ranges_post`` is the
|
|
189
|
+
post-autoscale :class:`RangeReport` for this solve (``None`` when the raw
|
|
190
|
+
LP never tripped the scaling detector); it gates the reject-path scaling
|
|
191
|
+
hint only.
|
|
192
|
+
"""
|
|
193
|
+
# Fast path: HiGHS certified optimality — nothing to decide.
|
|
194
|
+
if sol.optimal:
|
|
195
|
+
return Acceptance(
|
|
196
|
+
accepted=True, near_optimal=False, message="", scaling_hint=None,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# ``solve_diagnostics`` landed in polar-high 3.7.0 (pyproject pins it).
|
|
200
|
+
# Guard the call so an environment that somehow has an older polar-high
|
|
201
|
+
# degrades to the honest "cannot diagnose → reject" path below instead of
|
|
202
|
+
# crashing the whole cascade with an AttributeError.
|
|
203
|
+
diag_fn = getattr(sol, "solve_diagnostics", None)
|
|
204
|
+
diag = diag_fn() if callable(diag_fn) else None
|
|
205
|
+
|
|
206
|
+
# No queryable solver handle (synthesised Solution, or the read-only
|
|
207
|
+
# subprocess/commercial shim), or a polar-high too old to diagnose: we
|
|
208
|
+
# cannot verify the solution, so we must NOT accept it. Reject with an
|
|
209
|
+
# honest "cannot diagnose" message.
|
|
210
|
+
if diag is None:
|
|
211
|
+
return Acceptance(
|
|
212
|
+
accepted=False,
|
|
213
|
+
near_optimal=False,
|
|
214
|
+
message=(
|
|
215
|
+
f"non-optimal solve for {solve_name}: the solver did not "
|
|
216
|
+
"certify optimality and no solver diagnostics are available "
|
|
217
|
+
"to assess the solution"
|
|
218
|
+
),
|
|
219
|
+
scaling_hint=None,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
status = diag.model_status_name
|
|
223
|
+
|
|
224
|
+
# A named failure status is never acceptable — report the precise cause.
|
|
225
|
+
if status in _FAILURE_MESSAGES:
|
|
226
|
+
return Acceptance(
|
|
227
|
+
accepted=False,
|
|
228
|
+
near_optimal=False,
|
|
229
|
+
message=f"solve for {solve_name} {_FAILURE_MESSAGES[status]}",
|
|
230
|
+
scaling_hint=_scaling_hint_for_reject(ranges_post),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Anything that is neither kOptimal (handled above) nor a known failure
|
|
234
|
+
# nor kUnknown is an unrecognised status: reject and say we don't know.
|
|
235
|
+
if status != "kUnknown":
|
|
236
|
+
return Acceptance(
|
|
237
|
+
accepted=False,
|
|
238
|
+
near_optimal=False,
|
|
239
|
+
message=(
|
|
240
|
+
f"non-optimal solve for {solve_name}: the solver returned an "
|
|
241
|
+
f"unrecognised status ({status}); the cause could not be "
|
|
242
|
+
"determined from the available solver diagnostics"
|
|
243
|
+
),
|
|
244
|
+
scaling_hint=_scaling_hint_for_reject(ranges_post),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# --- kUnknown: the crossover-off / post-solve-uncertified case ----------
|
|
248
|
+
# The kill-switch restores the legacy hard-fail.
|
|
249
|
+
if not _near_optimal_enabled():
|
|
250
|
+
return Acceptance(
|
|
251
|
+
accepted=False,
|
|
252
|
+
near_optimal=False,
|
|
253
|
+
message=(
|
|
254
|
+
f"non-optimal solve for {solve_name}: the solver could not "
|
|
255
|
+
"certify optimality (status Unknown) and near-optimal "
|
|
256
|
+
"acceptance is disabled (FLEXTOOL_ACCEPT_NEAR_OPTIMAL=0)"
|
|
257
|
+
),
|
|
258
|
+
scaling_hint=_scaling_hint_for_reject(ranges_post),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
primal_rel_tol = _env_float(
|
|
262
|
+
"FLEXTOOL_ACCEPT_PRIMAL_REL", _ACCEPT_PRIMAL_REL_DEFAULT
|
|
263
|
+
)
|
|
264
|
+
primal_abs_margin = _env_float(
|
|
265
|
+
"FLEXTOOL_ACCEPT_PRIMAL_ABS_MARGIN", _ACCEPT_PRIMAL_ABS_MARGIN_DEFAULT
|
|
266
|
+
)
|
|
267
|
+
pd_gap_tol = _env_float("FLEXTOOL_ACCEPT_PD_GAP", _ACCEPT_PD_GAP_DEFAULT)
|
|
268
|
+
|
|
269
|
+
# Non-negotiable: the primal solution — the thing the cascade consumes —
|
|
270
|
+
# must actually be feasible. Use HiGHS' own verdict plus a scale-
|
|
271
|
+
# invariant relative check and a tolerance-derived absolute backstop.
|
|
272
|
+
primal_ok = (
|
|
273
|
+
diag.primal_feasible
|
|
274
|
+
and diag.num_primal_infeasibilities == 0
|
|
275
|
+
and diag.max_relative_primal_infeasibility <= primal_rel_tol
|
|
276
|
+
and diag.max_primal_infeasibility
|
|
277
|
+
<= sol.primal_feasibility_tolerance * primal_abs_margin
|
|
278
|
+
)
|
|
279
|
+
if not primal_ok:
|
|
280
|
+
return Acceptance(
|
|
281
|
+
accepted=False,
|
|
282
|
+
near_optimal=False,
|
|
283
|
+
message=(
|
|
284
|
+
f"non-optimal solve for {solve_name}: the solver could not "
|
|
285
|
+
"certify optimality and the returned primal solution is NOT "
|
|
286
|
+
"feasible (max relative primal infeasibility "
|
|
287
|
+
f"{diag.max_relative_primal_infeasibility:.2e} > "
|
|
288
|
+
f"{primal_rel_tol:.0e}; "
|
|
289
|
+
f"{diag.num_primal_infeasibilities} infeasibilities). The "
|
|
290
|
+
"solution cannot be used"
|
|
291
|
+
),
|
|
292
|
+
scaling_hint=_scaling_hint_for_reject(ranges_post),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Second requirement: a bounded optimality gap.
|
|
296
|
+
if diag.primal_dual_objective_error > pd_gap_tol:
|
|
297
|
+
return Acceptance(
|
|
298
|
+
accepted=False,
|
|
299
|
+
near_optimal=False,
|
|
300
|
+
message=(
|
|
301
|
+
f"non-optimal solve for {solve_name}: the primal solution is "
|
|
302
|
+
"feasible but the primal-dual objective error "
|
|
303
|
+
f"{diag.primal_dual_objective_error:.3%} exceeds the accepted "
|
|
304
|
+
f"optimality gap {pd_gap_tol:.2%}; optimality cannot be "
|
|
305
|
+
"certified"
|
|
306
|
+
),
|
|
307
|
+
scaling_hint=_scaling_hint_for_reject(ranges_post),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# Accept as near-optimal: feasible primal, small gap.
|
|
311
|
+
msg = (
|
|
312
|
+
f"Accepted near-optimal solve for {solve_name}: HiGHS could not "
|
|
313
|
+
"certify optimality after postsolve (status Unknown), but the primal "
|
|
314
|
+
"solution is feasible (max relative primal infeasibility "
|
|
315
|
+
f"{diag.max_relative_primal_infeasibility:.2e} <= {primal_rel_tol:.0e}"
|
|
316
|
+
f"; {diag.num_primal_infeasibilities} infeasibilities) and the "
|
|
317
|
+
f"primal-dual objective error {diag.primal_dual_objective_error:.3%} "
|
|
318
|
+
f"is within the accepted optimality gap {pd_gap_tol:.2%}. Using the "
|
|
319
|
+
"primal solution."
|
|
320
|
+
)
|
|
321
|
+
if diag.dual_feasible:
|
|
322
|
+
msg += (
|
|
323
|
+
" The dual solution is also feasible; only the post-solve dual "
|
|
324
|
+
"objective certificate is inconsistent."
|
|
325
|
+
)
|
|
326
|
+
return Acceptance(
|
|
327
|
+
accepted=True, near_optimal=True, message=msg, scaling_hint=None,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
__all__ = ["Acceptance", "classify_acceptance"]
|