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,1838 @@
|
|
|
1
|
+
"""Subprocess solver driver — the only cold-solve path in FlexTool.
|
|
2
|
+
|
|
3
|
+
After the in-process cold-solve retirement, this module is the *single*
|
|
4
|
+
entry point for any solve that isn't going through the warm-active
|
|
5
|
+
HiGHS path:
|
|
6
|
+
|
|
7
|
+
* HiGHS with ``--save-memory`` (or with ``warm=False`` and the
|
|
8
|
+
soft-promote rule fired in ``_orchestration.py``) — written via
|
|
9
|
+
:meth:`polar_high.Problem.write_mps`, solved by
|
|
10
|
+
:mod:`flextool.cli.cmd_solve_mps`.
|
|
11
|
+
* Non-HiGHS solvers (``gurobi`` / ``cplex`` / ``xpress`` / ``copt``) —
|
|
12
|
+
written via the same :meth:`Problem.write_mps`, solved by the
|
|
13
|
+
solver's CLI binary discovered on ``$PATH`` (with a small set of
|
|
14
|
+
conventional install dirs as fallback).
|
|
15
|
+
|
|
16
|
+
The default HiGHS path in ``_orchestration.py`` (warm-active
|
|
17
|
+
:class:`polar_high.WarmProblem`) does **not** call this module.
|
|
18
|
+
|
|
19
|
+
**Non-HiGHS contract**: commercial solvers (Gurobi/CPLEX/Xpress/COPT)
|
|
20
|
+
*always* go through subprocess regardless of ``--save-memory``. This
|
|
21
|
+
keeps every cold solve at the bound peak-memory footprint
|
|
22
|
+
:meth:`Problem.write_mps` was designed to deliver (~2-3 GB on a 9.9 M
|
|
23
|
+
row LP vs ~45 GB for HiGHS' own ``writeModel``). The corollary: there
|
|
24
|
+
is no in-process Gurobi/CPLEX/Xpress/COPT dispatch in FlexTool at all.
|
|
25
|
+
|
|
26
|
+
The child process has a clean address space — none of FlexTool's
|
|
27
|
+
~7-11 GB of polars frames, no glibc fragmentation from upstream
|
|
28
|
+
preprocessing. When it finishes, the parent reads the solution back:
|
|
29
|
+
|
|
30
|
+
* HiGHS path parses the subprocess-written .sol directly
|
|
31
|
+
(:func:`_parse_highs_sol`) — the file already carries names + primal
|
|
32
|
+
+ duals.
|
|
33
|
+
* Commercial path writes the MPS with GENERIC ``C…``/``R…`` names
|
|
34
|
+
(``emit_names=False``) so real entity names — which routinely contain
|
|
35
|
+
spaces, illegal in whitespace-delimited free-format MPS — never
|
|
36
|
+
corrupt the file. It parses the solver-native .sol with the parsers
|
|
37
|
+
imported from polar-high's ``_mps_fallback`` (copied verbatim so the
|
|
38
|
+
parsers stay free of the ``LpView`` materialisation polar-high uses
|
|
39
|
+
upstream of them), then maps the generic-named primal/dual back by
|
|
40
|
+
*index* onto the real names rebuilt from the Problem's surviving
|
|
41
|
+
``_vars`` (:func:`_col_names_from_vars`) and pre-release ``_cstrs``
|
|
42
|
+
(:func:`_row_names_from_cstrs`).
|
|
43
|
+
|
|
44
|
+
Neither path re-reads the MPS through :meth:`highspy.Highs.readModel`
|
|
45
|
+
in the parent: that used to spike tens of GB of RSS on large LPs, and
|
|
46
|
+
hard-failed on FlexTool's own free-format MPS whenever an entity name
|
|
47
|
+
contained a space. Instead both wrap the parsed arrays in a
|
|
48
|
+
:class:`_SolHighsShim`, giving downstream output writers a uniform
|
|
49
|
+
``Solution.highs`` shape — they keep doing
|
|
50
|
+
``h.allVariableNames() + h.getSolution()`` regardless of which solver
|
|
51
|
+
produced the result.
|
|
52
|
+
|
|
53
|
+
Loses warm-LP reuse for the cascade — ``write_mps(release=True)``
|
|
54
|
+
puts the Problem in ``_released`` state and it can't be resolved.
|
|
55
|
+
Already documented on the ``--save-memory`` flag.
|
|
56
|
+
"""
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import json
|
|
60
|
+
import logging
|
|
61
|
+
import os
|
|
62
|
+
import re
|
|
63
|
+
import shutil
|
|
64
|
+
import subprocess
|
|
65
|
+
import sys
|
|
66
|
+
import tempfile
|
|
67
|
+
import xml.etree.ElementTree as ET
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
from typing import TYPE_CHECKING, Any
|
|
70
|
+
|
|
71
|
+
import numpy as np
|
|
72
|
+
|
|
73
|
+
if TYPE_CHECKING:
|
|
74
|
+
from polar_high import Problem, Solution
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Warm-start first-transfer A/B margin. A supplied basis disables HiGHS'
|
|
78
|
+
# presolve, so a *far* / stale seed can make the warm solve SLOWER than a
|
|
79
|
+
# cold (presolve-on) solve (Landmine B). On a family's first warm
|
|
80
|
+
# transfer we time a cold reference run and only keep injecting the basis
|
|
81
|
+
# when the warm run's wall time is within ``(1 + _WARM_AB_MARGIN)`` of the
|
|
82
|
+
# cold time; otherwise the fingerprint is marked ``.nowarm`` and future
|
|
83
|
+
# solves go cold. Env-tunable; a NEGATIVE value forces a regression
|
|
84
|
+
# verdict (used by tests to exercise the disable path deterministically).
|
|
85
|
+
_WARM_AB_MARGIN = float(os.environ.get("FLEXTOOL_WARM_AB_MARGIN", "0.10"))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _warm_ab_margin() -> float:
|
|
89
|
+
"""Effective A/B margin, re-read from the environment at decision time.
|
|
90
|
+
|
|
91
|
+
Falls back to the import-time :data:`_WARM_AB_MARGIN` default when the
|
|
92
|
+
env var is unset or unparseable. Read at runtime (not once at import)
|
|
93
|
+
so ``FLEXTOOL_WARM_AB_MARGIN`` stays tunable within a live process —
|
|
94
|
+
the gate fires at most once per fingerprint, so the cost is trivial.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
return float(
|
|
98
|
+
os.environ.get("FLEXTOOL_WARM_AB_MARGIN", str(_WARM_AB_MARGIN))
|
|
99
|
+
)
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
return _WARM_AB_MARGIN
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _touch_marker(path: Path, logger: logging.Logger | None) -> None:
|
|
105
|
+
"""Best-effort create an empty warm-start decision marker file.
|
|
106
|
+
|
|
107
|
+
Markers (``<fp>.nowarm`` / ``<fp>.abtested``) live in the persistent
|
|
108
|
+
basis cache dir and record the first-transfer A/B verdict. A write
|
|
109
|
+
failure is logged and non-fatal — the gate degrades to re-probing on
|
|
110
|
+
the next solve, never breaking the solve itself.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
path.write_text("")
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
if logger is not None:
|
|
116
|
+
logger.warning(
|
|
117
|
+
"save_memory: warm-start marker write failed for %s (%s)",
|
|
118
|
+
path, exc,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_cold_probe(
|
|
123
|
+
*,
|
|
124
|
+
mps_path: Path,
|
|
125
|
+
opts_path: Path,
|
|
126
|
+
probe_sol: Path,
|
|
127
|
+
probe_stats: Path,
|
|
128
|
+
logger: logging.Logger | None,
|
|
129
|
+
) -> float | None:
|
|
130
|
+
"""Spawn a throwaway COLD child solve purely to measure its wall time.
|
|
131
|
+
|
|
132
|
+
Used by the first-transfer A/B gate (see :data:`_WARM_AB_MARGIN`).
|
|
133
|
+
The child runs with the SAME ``--mps`` / ``--options`` but a throwaway
|
|
134
|
+
``--solution`` and ``--stats`` and NO ``--warm-basis`` — a pure cold
|
|
135
|
+
reference solve. Its solution is discarded; only ``run_time`` matters.
|
|
136
|
+
|
|
137
|
+
Returns the cold ``run_time`` in seconds, or ``None`` when the probe
|
|
138
|
+
could not run or produced no usable time (caller then skips gating).
|
|
139
|
+
Fully defensive — never raises.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
probe_cmd = [
|
|
143
|
+
sys.executable, "-m", "flextool.cli.cmd_solve_mps",
|
|
144
|
+
"--mps", str(mps_path),
|
|
145
|
+
"--solution", str(probe_sol),
|
|
146
|
+
"--options", str(opts_path),
|
|
147
|
+
"--stats", str(probe_stats),
|
|
148
|
+
]
|
|
149
|
+
subprocess.run(probe_cmd)
|
|
150
|
+
if not probe_stats.exists():
|
|
151
|
+
return None
|
|
152
|
+
pstats = json.loads(probe_stats.read_text())
|
|
153
|
+
rt = pstats.get("run_time")
|
|
154
|
+
if rt is None:
|
|
155
|
+
return None
|
|
156
|
+
return float(rt)
|
|
157
|
+
except Exception as exc: # noqa: BLE001 - probe is measurement-only
|
|
158
|
+
if logger is not None:
|
|
159
|
+
logger.debug("save_memory: cold A/B probe failed (%s)", exc)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# HiGHS .opt formatting / .sol parsing helpers (existing)
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _format_opt_value(v: object) -> str:
|
|
169
|
+
"""Render *v* in HiGHS .opt file syntax (``key=value`` per line)."""
|
|
170
|
+
if isinstance(v, bool):
|
|
171
|
+
return "true" if v else "false"
|
|
172
|
+
return str(v)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _read_objective_from_sol(sol_path: Path) -> float:
|
|
176
|
+
"""Parse the ``Objective <value>`` line from a HiGHS style=0 sol file.
|
|
177
|
+
|
|
178
|
+
``highspy.Highs.getObjectiveValue()`` only reflects the most recent
|
|
179
|
+
``run()`` and is zero after a bare ``readModel + readSolution``, so
|
|
180
|
+
we read the value the subprocess wrote directly. Returns ``0.0``
|
|
181
|
+
when the line is absent (caller should treat as non-optimal).
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
with open(sol_path) as f:
|
|
185
|
+
for line in f:
|
|
186
|
+
if line.startswith("Objective "):
|
|
187
|
+
return float(line.split(None, 1)[1])
|
|
188
|
+
except (OSError, ValueError):
|
|
189
|
+
pass
|
|
190
|
+
return 0.0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# HiGHS .sol direct parser + lightweight Highs-shim
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
#
|
|
197
|
+
# The cold (``--save-memory``) path used to re-load the *entire* MPS via
|
|
198
|
+
# ``highspy.Highs.readModel`` in the parent process — purely to satisfy
|
|
199
|
+
# downstream writers that call ``h.allVariableNames()`` / ``h.getSolution()``
|
|
200
|
+
# / ``h.getLp().row_names_``. On large LPs (DES: ~10 M rows, 7 M cols)
|
|
201
|
+
# that ``readModel`` accounts for the +33 GB RSS spike at the per-solve
|
|
202
|
+
# ``Solver`` checkpoint — and immediately gets thrown away after the
|
|
203
|
+
# writers finish. We sidestep it: parse the HiGHS style=0 .sol file
|
|
204
|
+
# directly (it already carries column / row names + primal + dual values)
|
|
205
|
+
# and wrap the arrays in a duck-typed shim that exposes exactly the API
|
|
206
|
+
# surface the writers use. No 33 GB sidecar Highs instance.
|
|
207
|
+
|
|
208
|
+
def _parse_mps_row_names(mps_path: Path) -> list[str]:
|
|
209
|
+
"""Return constraint row names (in MPS ROWS order, objective excluded).
|
|
210
|
+
|
|
211
|
+
HiGHS' ``writeSolution`` periodically rewrites row names to
|
|
212
|
+
``r0, r1, ...`` (warning: ``Row names are not present, or contain
|
|
213
|
+
duplicates: using names with prefix "r"``) when the LP's row name
|
|
214
|
+
array survives ``readModel`` in a degraded state — observed with
|
|
215
|
+
free-format MPS row names that contain bracket / comma characters
|
|
216
|
+
(``nodeBalance_eq[battery,p2020,t0001]``). The MPS file itself
|
|
217
|
+
carries the canonical names; this helper recovers them so the
|
|
218
|
+
downstream output writers' name-based row lookups (``row_dual``
|
|
219
|
+
for ``nodeBalance_eq`` / ``reserveBalance_*_eq`` / ``co2_max_*``)
|
|
220
|
+
keep working through the subprocess-MPS path.
|
|
221
|
+
|
|
222
|
+
The objective row (``N cost`` in the polar-high MPS) is excluded
|
|
223
|
+
so the returned list aligns with HiGHS' ``row_dual`` array, which
|
|
224
|
+
only covers constraint rows (objective dual is meaningless).
|
|
225
|
+
|
|
226
|
+
Parser: a tiny state machine over the ROWS section. Lines look
|
|
227
|
+
like ``" E nodeBalance_eq[...]"`` — leading whitespace + 1-char
|
|
228
|
+
sense + whitespace + name. Anything else terminates the section.
|
|
229
|
+
"""
|
|
230
|
+
names: list[str] = []
|
|
231
|
+
try:
|
|
232
|
+
with open(mps_path) as fh:
|
|
233
|
+
in_rows = False
|
|
234
|
+
for raw in fh:
|
|
235
|
+
line = raw.rstrip("\n")
|
|
236
|
+
if not in_rows:
|
|
237
|
+
if line.strip().upper() == "ROWS":
|
|
238
|
+
in_rows = True
|
|
239
|
+
continue
|
|
240
|
+
# Detect end of ROWS section (next header line: COLUMNS,
|
|
241
|
+
# RHS, RANGES, BOUNDS, ENDATA, ...). Header lines are
|
|
242
|
+
# left-flush; data lines start with whitespace.
|
|
243
|
+
if line and not line[0].isspace():
|
|
244
|
+
break
|
|
245
|
+
parts = line.split()
|
|
246
|
+
if len(parts) < 2:
|
|
247
|
+
continue
|
|
248
|
+
sense = parts[0].upper()
|
|
249
|
+
name = parts[1]
|
|
250
|
+
# Skip the objective row — ``row_dual`` covers
|
|
251
|
+
# constraints only.
|
|
252
|
+
if sense == "N":
|
|
253
|
+
continue
|
|
254
|
+
if sense in ("E", "L", "G"):
|
|
255
|
+
names.append(name)
|
|
256
|
+
except OSError:
|
|
257
|
+
pass
|
|
258
|
+
return names
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _col_names_from_vars(problem: "Problem") -> list[str]:
|
|
262
|
+
"""Rebuild the dense, ``col_id``-indexed column-name list from a
|
|
263
|
+
(possibly released) :class:`polar_high.Problem`.
|
|
264
|
+
|
|
265
|
+
``Problem.write_mps(release=True)`` keeps ``self._vars`` alive
|
|
266
|
+
precisely so an external solver's solution can be mapped back to
|
|
267
|
+
user-space names. This helper reproduces the *exact* naming
|
|
268
|
+
polar-high itself emits for ``Solution.col_names`` —
|
|
269
|
+
``"<family>[<d0>,<d1>,…]"`` for dimensioned variables, the bare
|
|
270
|
+
family name for scalar ones — from those surviving
|
|
271
|
+
``Var.frame['col_id']`` columns (mirrors the canonical-matrix name
|
|
272
|
+
pass in ``polar_high.engine.Problem._canonicalise``).
|
|
273
|
+
|
|
274
|
+
Preferred over re-reading the MPS through
|
|
275
|
+
``highspy.Highs.readModel`` on the commercial-solver path: the result
|
|
276
|
+
is **complete** (columns the MPS COLUMNS section elides because they
|
|
277
|
+
carry no objective / matrix entry are still present here),
|
|
278
|
+
``col_id``-aligned by construction, and immune to the free-format-MPS
|
|
279
|
+
whitespace fragility a text round-trip suffers when an entity name
|
|
280
|
+
contains a space.
|
|
281
|
+
"""
|
|
282
|
+
import polars as pl
|
|
283
|
+
|
|
284
|
+
vars_map = getattr(problem, "_vars", {}) or {}
|
|
285
|
+
# Every column is created by exactly one ``add_var`` and lands in that
|
|
286
|
+
# variable's frame, so the max ``col_id`` across all frames + 1 is the
|
|
287
|
+
# exact dense column count — no dependence on the internal counter
|
|
288
|
+
# (which differs between ``Problem`` and ``WarmProblem``).
|
|
289
|
+
max_cid = -1
|
|
290
|
+
for v in vars_map.values():
|
|
291
|
+
ids = v.frame["col_id"].to_numpy()
|
|
292
|
+
if ids.size:
|
|
293
|
+
max_cid = max(max_cid, int(ids.max()))
|
|
294
|
+
n_cols = max_cid + 1
|
|
295
|
+
|
|
296
|
+
col_names: list[str] = [""] * n_cols
|
|
297
|
+
for v in vars_map.values():
|
|
298
|
+
ids = v.frame["col_id"].to_numpy()
|
|
299
|
+
if v.dims:
|
|
300
|
+
tagged = v.frame.select(
|
|
301
|
+
pl.format(
|
|
302
|
+
"{}[{}]",
|
|
303
|
+
pl.lit(v.name),
|
|
304
|
+
pl.concat_str(
|
|
305
|
+
[pl.col(d).cast(pl.String) for d in v.dims],
|
|
306
|
+
separator=",",
|
|
307
|
+
),
|
|
308
|
+
).alias("__name")
|
|
309
|
+
)["__name"].to_list()
|
|
310
|
+
for cid, nm in zip(ids.tolist(), tagged):
|
|
311
|
+
col_names[cid] = nm
|
|
312
|
+
elif ids.size:
|
|
313
|
+
col_names[int(ids[0])] = v.name
|
|
314
|
+
return col_names
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _row_names_from_cstrs(problem: "Problem") -> list[str]:
|
|
318
|
+
"""Rebuild the ``row_id``-ordered constraint-name list from a Problem's
|
|
319
|
+
``_cstrs`` families — the row analogue of :func:`_col_names_from_vars`.
|
|
320
|
+
|
|
321
|
+
Same ``"<family>[<d0>,<d1>,…]"`` format polar-high emits for
|
|
322
|
+
``Solution.row_names`` (bare family name for a scalar constraint),
|
|
323
|
+
walking ``_cstrs`` in declaration order — which is the exact order the
|
|
324
|
+
canonical matrix assigns row ids (mirrors
|
|
325
|
+
``polar_high.engine.Problem._canonicalise``'s row-name pass).
|
|
326
|
+
|
|
327
|
+
Unlike ``_vars``, ``_cstrs`` is **dropped** by
|
|
328
|
+
``write_mps(release=True)``, so callers that need the real constraint
|
|
329
|
+
names (to map a solver's ``.sol`` duals back by name) must call this
|
|
330
|
+
*before* releasing. Only the dual-returning commercial parsers
|
|
331
|
+
(:data:`_DUAL_CAPABLE_SOLVERS`) need it; Gurobi / COPT / Xpress ``.sol``
|
|
332
|
+
files carry no duals, so their path skips this entirely.
|
|
333
|
+
"""
|
|
334
|
+
import polars as pl
|
|
335
|
+
|
|
336
|
+
names: list[str] = []
|
|
337
|
+
for cname, _proto, over in getattr(problem, "_cstrs", []) or []:
|
|
338
|
+
if over is None:
|
|
339
|
+
names.append(cname)
|
|
340
|
+
continue
|
|
341
|
+
axis_cols = list(over.columns)
|
|
342
|
+
names.extend(
|
|
343
|
+
over.select(
|
|
344
|
+
pl.format(
|
|
345
|
+
"{}[{}]",
|
|
346
|
+
pl.lit(cname),
|
|
347
|
+
pl.concat_str(
|
|
348
|
+
[pl.col(d).cast(pl.String) for d in axis_cols],
|
|
349
|
+
separator=",",
|
|
350
|
+
),
|
|
351
|
+
).alias("__rn")
|
|
352
|
+
)["__rn"].to_list()
|
|
353
|
+
)
|
|
354
|
+
return names
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# polar-high's ``write_mps(emit_names=False)`` emits generic, whitespace-safe
|
|
358
|
+
# column / row names in strict id order: column ``col_id=j`` → ``f"C{j+1:07d}"``
|
|
359
|
+
# and constraint ``row_id=i`` → ``f"R{i+2:07d}"`` (the objective row is the
|
|
360
|
+
# reserved name ``cost`` and never appears in a solver ``.sol``'s variable or
|
|
361
|
+
# dual list). These regexes recover the id from such a name; the ``0*`` +
|
|
362
|
+
# open ``[0-9]+`` tolerates the field widening past 7 digits on >10 M-col LPs.
|
|
363
|
+
_GENERIC_COL_RE = re.compile(r"^C0*([0-9]+)$")
|
|
364
|
+
_GENERIC_ROW_RE = re.compile(r"^R0*([0-9]+)$")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _generic_col_id(name: str) -> int | None:
|
|
368
|
+
"""``"C0000001"`` → ``0``; ``None`` when *name* isn't a generic col id."""
|
|
369
|
+
m = _GENERIC_COL_RE.match(name)
|
|
370
|
+
return int(m.group(1)) - 1 if m else None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _generic_row_id(name: str) -> int | None:
|
|
374
|
+
"""``"R0000002"`` → ``0``; ``None`` when *name* isn't a generic row id."""
|
|
375
|
+
m = _GENERIC_ROW_RE.match(name)
|
|
376
|
+
return int(m.group(1)) - 2 if m else None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# Commercial ``.sol`` parsers that can return constraint duals. Only these
|
|
380
|
+
# pay the cost of rebuilding real row names before ``write_mps`` releases;
|
|
381
|
+
# Gurobi / COPT / Xpress ResultFiles carry primal values only.
|
|
382
|
+
_DUAL_CAPABLE_SOLVERS = frozenset({"cplex"})
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _parse_highs_sol(
|
|
386
|
+
sol_path: Path,
|
|
387
|
+
) -> tuple[
|
|
388
|
+
list[str], list[str],
|
|
389
|
+
np.ndarray, np.ndarray, np.ndarray,
|
|
390
|
+
]:
|
|
391
|
+
"""Parse a HiGHS style=0 ``.sol`` file into the writer-facing arrays.
|
|
392
|
+
|
|
393
|
+
Returns ``(col_names, row_names, col_value, col_dual, row_dual)``.
|
|
394
|
+
All five live in-process at numpy / list scale — typically a few
|
|
395
|
+
hundred MB even on the DES-scale LP, vs the +33 GB the equivalent
|
|
396
|
+
``Highs.readModel(mps)`` parent re-read used to cost.
|
|
397
|
+
|
|
398
|
+
The HiGHS style=0 file format (see ``Highs::writeSolution``):
|
|
399
|
+
|
|
400
|
+
Model status
|
|
401
|
+
<status>
|
|
402
|
+
|
|
403
|
+
# Primal solution values
|
|
404
|
+
Feasible | Infeasible
|
|
405
|
+
Objective <value>
|
|
406
|
+
# Columns <N>
|
|
407
|
+
<name> <value>
|
|
408
|
+
...
|
|
409
|
+
# Rows <M>
|
|
410
|
+
<name> <value>
|
|
411
|
+
...
|
|
412
|
+
|
|
413
|
+
# Dual solution values
|
|
414
|
+
Feasible | Infeasible
|
|
415
|
+
# Columns <N>
|
|
416
|
+
<name> <dual>
|
|
417
|
+
...
|
|
418
|
+
# Rows <M>
|
|
419
|
+
<name> <dual>
|
|
420
|
+
...
|
|
421
|
+
|
|
422
|
+
# Basis ... (ignored — basis statuses are not needed by the
|
|
423
|
+
parent-side output writers)
|
|
424
|
+
|
|
425
|
+
Each ``<name> <value>`` line splits on whitespace; names with
|
|
426
|
+
embedded spaces are not produced by HiGHS so the simple split is
|
|
427
|
+
safe.
|
|
428
|
+
"""
|
|
429
|
+
col_names: list[str] = []
|
|
430
|
+
row_names: list[str] = []
|
|
431
|
+
col_value_list: list[float] = []
|
|
432
|
+
col_dual_list: list[float] = []
|
|
433
|
+
row_value_list: list[float] = [] # not actually exposed; parse but discard
|
|
434
|
+
row_dual_list: list[float] = []
|
|
435
|
+
|
|
436
|
+
# Three-state parser:
|
|
437
|
+
# section ∈ {"primal", "dual", None}
|
|
438
|
+
# bucket ∈ {"col", "row", None}
|
|
439
|
+
section: str | None = None
|
|
440
|
+
bucket: str | None = None
|
|
441
|
+
|
|
442
|
+
with open(sol_path) as fh:
|
|
443
|
+
for raw in fh:
|
|
444
|
+
line = raw.rstrip("\n")
|
|
445
|
+
if not line.strip():
|
|
446
|
+
continue
|
|
447
|
+
if line.startswith("# Primal solution"):
|
|
448
|
+
section, bucket = "primal", None
|
|
449
|
+
continue
|
|
450
|
+
if line.startswith("# Dual solution"):
|
|
451
|
+
section, bucket = "dual", None
|
|
452
|
+
continue
|
|
453
|
+
if line.startswith("# Basis"):
|
|
454
|
+
# Basis section ends the data we care about.
|
|
455
|
+
break
|
|
456
|
+
if line.startswith("# Columns"):
|
|
457
|
+
bucket = "col"
|
|
458
|
+
continue
|
|
459
|
+
if line.startswith("# Rows"):
|
|
460
|
+
bucket = "row"
|
|
461
|
+
continue
|
|
462
|
+
if line.startswith("#"):
|
|
463
|
+
# Unrecognised comment header — keep current state.
|
|
464
|
+
continue
|
|
465
|
+
if (
|
|
466
|
+
line.startswith("Model status")
|
|
467
|
+
or line.startswith("Objective ")
|
|
468
|
+
or line in ("Feasible", "Infeasible", "Unknown")
|
|
469
|
+
or line.startswith("HiGHS_basis_file")
|
|
470
|
+
or line in ("Valid", "None")
|
|
471
|
+
):
|
|
472
|
+
continue
|
|
473
|
+
# Data row: "<name> <value>"
|
|
474
|
+
sp = line.rsplit(None, 1)
|
|
475
|
+
if len(sp) != 2:
|
|
476
|
+
continue
|
|
477
|
+
name, val_s = sp
|
|
478
|
+
try:
|
|
479
|
+
val = float(val_s)
|
|
480
|
+
except ValueError:
|
|
481
|
+
continue
|
|
482
|
+
if section == "primal" and bucket == "col":
|
|
483
|
+
col_names.append(name)
|
|
484
|
+
col_value_list.append(val)
|
|
485
|
+
elif section == "primal" and bucket == "row":
|
|
486
|
+
row_names.append(name)
|
|
487
|
+
row_value_list.append(val)
|
|
488
|
+
elif section == "dual" and bucket == "col":
|
|
489
|
+
col_dual_list.append(val)
|
|
490
|
+
elif section == "dual" and bucket == "row":
|
|
491
|
+
row_dual_list.append(val)
|
|
492
|
+
|
|
493
|
+
col_value = np.asarray(col_value_list, dtype=np.float64)
|
|
494
|
+
col_dual = (
|
|
495
|
+
np.asarray(col_dual_list, dtype=np.float64)
|
|
496
|
+
if col_dual_list else np.zeros(len(col_value), dtype=np.float64)
|
|
497
|
+
)
|
|
498
|
+
row_dual = (
|
|
499
|
+
np.asarray(row_dual_list, dtype=np.float64)
|
|
500
|
+
if row_dual_list else np.zeros(len(row_names), dtype=np.float64)
|
|
501
|
+
)
|
|
502
|
+
return col_names, row_names, col_value, col_dual, row_dual
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class _SolHighsShim:
|
|
506
|
+
"""Duck-typed stand-in for ``highspy.Highs`` for the cold-path writers.
|
|
507
|
+
|
|
508
|
+
The flextool writers under ``process_outputs/`` consume the solver
|
|
509
|
+
instance via a small, stable surface:
|
|
510
|
+
|
|
511
|
+
* ``allVariableNames()`` — list[str] of column names.
|
|
512
|
+
* ``getSolution()`` — object with ``col_value`` / ``col_dual``
|
|
513
|
+
/ ``row_dual`` attributes (numpy arrays).
|
|
514
|
+
* ``getLp().row_names_`` — list[str] of constraint names.
|
|
515
|
+
* ``passColName(cid, name)`` — in-place rename of a column.
|
|
516
|
+
|
|
517
|
+
This shim wraps the arrays parsed from the subprocess ``.sol`` file
|
|
518
|
+
and exposes exactly those four entry points. Sidesteps the parent-
|
|
519
|
+
side ``highspy.Highs.readModel`` whose +33 GB RSS bump used to spike
|
|
520
|
+
at the per-solve ``Solver`` checkpoint on large LPs.
|
|
521
|
+
|
|
522
|
+
Memory cost: O(n_cols + n_rows) Python strings + the four numpy
|
|
523
|
+
arrays — typically a few hundred MB on a 10 M-cell LP vs the tens
|
|
524
|
+
of GB the full Highs sidecar took.
|
|
525
|
+
"""
|
|
526
|
+
|
|
527
|
+
__slots__ = ("_col_names", "_row_names", "_solution", "_lp", "_obj")
|
|
528
|
+
|
|
529
|
+
class _SolutionView:
|
|
530
|
+
__slots__ = ("col_value", "col_dual", "row_dual")
|
|
531
|
+
|
|
532
|
+
def __init__(
|
|
533
|
+
self,
|
|
534
|
+
col_value: np.ndarray,
|
|
535
|
+
col_dual: np.ndarray,
|
|
536
|
+
row_dual: np.ndarray,
|
|
537
|
+
):
|
|
538
|
+
self.col_value = col_value
|
|
539
|
+
self.col_dual = col_dual
|
|
540
|
+
self.row_dual = row_dual
|
|
541
|
+
|
|
542
|
+
class _LpView:
|
|
543
|
+
__slots__ = ("row_names_",)
|
|
544
|
+
|
|
545
|
+
def __init__(self, row_names: list[str]):
|
|
546
|
+
self.row_names_ = row_names
|
|
547
|
+
|
|
548
|
+
def __init__(
|
|
549
|
+
self,
|
|
550
|
+
*,
|
|
551
|
+
col_names: list[str],
|
|
552
|
+
row_names: list[str],
|
|
553
|
+
col_value: np.ndarray,
|
|
554
|
+
col_dual: np.ndarray,
|
|
555
|
+
row_dual: np.ndarray,
|
|
556
|
+
objective: float = 0.0,
|
|
557
|
+
):
|
|
558
|
+
self._col_names = col_names
|
|
559
|
+
self._row_names = row_names
|
|
560
|
+
self._solution = self._SolutionView(col_value, col_dual, row_dual)
|
|
561
|
+
self._lp = self._LpView(row_names)
|
|
562
|
+
self._obj = objective
|
|
563
|
+
|
|
564
|
+
# --- writer-facing API ------------------------------------------------
|
|
565
|
+
|
|
566
|
+
def allVariableNames(self) -> list[str]:
|
|
567
|
+
return self._col_names
|
|
568
|
+
|
|
569
|
+
def getSolution(self) -> "_SolHighsShim._SolutionView":
|
|
570
|
+
return self._solution
|
|
571
|
+
|
|
572
|
+
def getLp(self) -> "_SolHighsShim._LpView":
|
|
573
|
+
return self._lp
|
|
574
|
+
|
|
575
|
+
def passColName(self, col_id: int, new_name: str) -> None:
|
|
576
|
+
# Mirror highspy's in-place rename onto the shim's name array.
|
|
577
|
+
self._col_names[col_id] = new_name
|
|
578
|
+
|
|
579
|
+
def getNumCol(self) -> int:
|
|
580
|
+
return len(self._col_names)
|
|
581
|
+
|
|
582
|
+
def getNumRow(self) -> int:
|
|
583
|
+
return len(self._row_names)
|
|
584
|
+
|
|
585
|
+
def getObjectiveValue(self) -> float:
|
|
586
|
+
# Used by ``write_v_obj`` (scaled value, the writer un-scales).
|
|
587
|
+
# Backed by the value we parsed from the ``Objective`` line in
|
|
588
|
+
# the .sol file.
|
|
589
|
+
return self._obj
|
|
590
|
+
|
|
591
|
+
# Silence / status helpers a few code paths invoke defensively.
|
|
592
|
+
def silent(self) -> None: # pragma: no cover - trivial
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# ---------------------------------------------------------------------------
|
|
597
|
+
# Per-solver CLI dispatch (copied from polar-high's _mps_fallback.py)
|
|
598
|
+
# ---------------------------------------------------------------------------
|
|
599
|
+
# Source: ``polar-high-opt/src/polar_high/solvers/_mps_fallback.py``.
|
|
600
|
+
# We copy verbatim (with imports adapted) rather than reuse the upstream
|
|
601
|
+
# ``run_via_file`` because that entry point materialises an ``LpView`` —
|
|
602
|
+
# the memory-greedy path we spent days bounding via ``Problem.write_mps``.
|
|
603
|
+
# The duplication keeps both repos independent of each other on this
|
|
604
|
+
# memory-critical code path.
|
|
605
|
+
|
|
606
|
+
_BINARY_NAMES: dict[str, str] = {
|
|
607
|
+
"gurobi": "gurobi_cl",
|
|
608
|
+
"cplex": "cplex",
|
|
609
|
+
"xpress": "optimizer",
|
|
610
|
+
"copt": "copt_cmd",
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
_POSIX_INSTALL_DIRS: dict[str, list[str]] = {
|
|
614
|
+
"gurobi": [
|
|
615
|
+
"/opt/gurobi/bin",
|
|
616
|
+
"/opt/gurobi/linux64/bin",
|
|
617
|
+
"/Library/gurobi/bin",
|
|
618
|
+
os.path.expanduser("~/gurobi/bin"),
|
|
619
|
+
],
|
|
620
|
+
"cplex": [
|
|
621
|
+
"/opt/ibm/ILOG/CPLEX_Studio/cplex/bin/x86-64_linux",
|
|
622
|
+
"/opt/ibm/ILOG/CPLEX_Studio/cplex/bin",
|
|
623
|
+
"/opt/cplex/bin",
|
|
624
|
+
os.path.expanduser("~/cplex/bin"),
|
|
625
|
+
],
|
|
626
|
+
"xpress": [
|
|
627
|
+
"/opt/xpressmp/bin",
|
|
628
|
+
"/opt/fico/xpress/bin",
|
|
629
|
+
os.path.expanduser("~/xpressmp/bin"),
|
|
630
|
+
],
|
|
631
|
+
"copt": [
|
|
632
|
+
"/opt/copt/bin",
|
|
633
|
+
"/opt/copt71/bin",
|
|
634
|
+
os.path.expanduser("~/copt/bin"),
|
|
635
|
+
],
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _find_solver_binary(solver_name: str) -> Path | None:
|
|
640
|
+
"""Return the absolute path to the solver's CLI binary, or ``None``.
|
|
641
|
+
|
|
642
|
+
Lookup order: ``$PATH`` via :func:`shutil.which`, then a small set
|
|
643
|
+
of conventional POSIX install dirs. Returns ``None`` on miss; the
|
|
644
|
+
caller raises a user-actionable error.
|
|
645
|
+
"""
|
|
646
|
+
bin_name = _BINARY_NAMES.get(solver_name)
|
|
647
|
+
if bin_name is None:
|
|
648
|
+
return None
|
|
649
|
+
found = shutil.which(bin_name)
|
|
650
|
+
if found is not None:
|
|
651
|
+
return Path(found)
|
|
652
|
+
if os.name == "posix":
|
|
653
|
+
for d in _POSIX_INSTALL_DIRS.get(solver_name, []):
|
|
654
|
+
candidate = Path(d) / bin_name
|
|
655
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
656
|
+
return candidate
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _gurobi_script(
|
|
661
|
+
binary: Path, mps_path: Path, sol_path: Path, opt_path: Path | None,
|
|
662
|
+
) -> tuple[list[str], str | None]:
|
|
663
|
+
"""``gurobi_cl [ReadParams=<opt>] ResultFile=<sol> <mps>``.
|
|
664
|
+
|
|
665
|
+
When *opt_path* is given, Gurobi's native ``ReadParams=<file>`` slot
|
|
666
|
+
loads our merged baseline+overlay parameters before the solve.
|
|
667
|
+
"""
|
|
668
|
+
argv: list[str] = [str(binary)]
|
|
669
|
+
if opt_path is not None:
|
|
670
|
+
argv.append(f"ReadParams={opt_path}")
|
|
671
|
+
argv.append(f"ResultFile={sol_path}")
|
|
672
|
+
argv.append(str(mps_path))
|
|
673
|
+
return argv, None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _cplex_script(
|
|
677
|
+
binary: Path, mps_path: Path, sol_path: Path, opt_path: Path | None,
|
|
678
|
+
) -> tuple[list[str], str]:
|
|
679
|
+
"""CPLEX interactive optimizer: pipe commands on stdin.
|
|
680
|
+
|
|
681
|
+
When *opt_path* is given, each non-comment line is translated to
|
|
682
|
+
``set <name> <value>`` and emitted *before* the ``read``/``optimize``
|
|
683
|
+
pair so parameter values are in effect when the model loads.
|
|
684
|
+
"""
|
|
685
|
+
pre: list[str] = []
|
|
686
|
+
if opt_path is not None:
|
|
687
|
+
for name, value in _parse_native_opt_file(opt_path).items():
|
|
688
|
+
pre.append(f"set {name} {value}")
|
|
689
|
+
cmds = "\n".join(
|
|
690
|
+
pre
|
|
691
|
+
+ [
|
|
692
|
+
f"read {mps_path}",
|
|
693
|
+
"optimize",
|
|
694
|
+
f"write {sol_path} sol",
|
|
695
|
+
"quit",
|
|
696
|
+
"",
|
|
697
|
+
]
|
|
698
|
+
)
|
|
699
|
+
return [str(binary)], cmds
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _xpress_script(
|
|
703
|
+
binary: Path, mps_path: Path, sol_path: Path, opt_path: Path | None,
|
|
704
|
+
) -> tuple[list[str], str]:
|
|
705
|
+
"""Xpress optimizer console script via stdin.
|
|
706
|
+
|
|
707
|
+
When *opt_path* is given, each non-comment line is translated to
|
|
708
|
+
``setControl <NAME> <value>`` and emitted *before* ``readprob``/
|
|
709
|
+
``lpoptimize`` so controls are in effect for the solve.
|
|
710
|
+
"""
|
|
711
|
+
pre: list[str] = []
|
|
712
|
+
if opt_path is not None:
|
|
713
|
+
for name, value in _parse_native_opt_file(opt_path).items():
|
|
714
|
+
pre.append(f"setControl {name} {value}")
|
|
715
|
+
cmds = "\n".join(
|
|
716
|
+
pre
|
|
717
|
+
+ [
|
|
718
|
+
f"readprob {mps_path}",
|
|
719
|
+
"lpoptimize",
|
|
720
|
+
# Force the MPS-like **SLX** solution (name-based, whitespace-
|
|
721
|
+
# safe) for the primal — Xpress' default ``writesol`` emits an
|
|
722
|
+
# index-based ``.asc``/``.hdr`` CSV pair with no usable
|
|
723
|
+
# name→value mapping. ``writeprtsol`` carries the objective
|
|
724
|
+
# value SLX omits. Xpress appends ``.slx`` / ``.prt`` to the
|
|
725
|
+
# base path; :func:`_parse_xpress_sol` looks for those.
|
|
726
|
+
f"writeslxsol {sol_path}",
|
|
727
|
+
f"writeprtsol {sol_path}",
|
|
728
|
+
"quit",
|
|
729
|
+
"",
|
|
730
|
+
]
|
|
731
|
+
)
|
|
732
|
+
return [str(binary)], cmds
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _copt_script(
|
|
736
|
+
binary: Path, mps_path: Path, sol_path: Path, opt_path: Path | None,
|
|
737
|
+
) -> tuple[list[str], str]:
|
|
738
|
+
"""COPT's ``copt_cmd`` script via stdin.
|
|
739
|
+
|
|
740
|
+
When *opt_path* is given, each non-comment line is translated to
|
|
741
|
+
``set <ParamName> <value>`` and emitted *before* ``read``/``optimize``.
|
|
742
|
+
"""
|
|
743
|
+
pre: list[str] = []
|
|
744
|
+
if opt_path is not None:
|
|
745
|
+
for name, value in _parse_native_opt_file(opt_path).items():
|
|
746
|
+
pre.append(f"set {name} {value}")
|
|
747
|
+
cmds = "\n".join(
|
|
748
|
+
pre
|
|
749
|
+
+ [
|
|
750
|
+
f"read {mps_path}",
|
|
751
|
+
"optimize",
|
|
752
|
+
f"write {sol_path}",
|
|
753
|
+
"quit",
|
|
754
|
+
"",
|
|
755
|
+
]
|
|
756
|
+
)
|
|
757
|
+
return [str(binary)], cmds
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
_SCRIPTS = {
|
|
761
|
+
"gurobi": _gurobi_script,
|
|
762
|
+
"cplex": _cplex_script,
|
|
763
|
+
"xpress": _xpress_script,
|
|
764
|
+
"copt": _copt_script,
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# ---------------------------------------------------------------------------
|
|
769
|
+
# Per-solver baseline option-file handling
|
|
770
|
+
# ---------------------------------------------------------------------------
|
|
771
|
+
# Each commercial solver gets a baseline ``solver_config/<solver>.opt``
|
|
772
|
+
# (shipped with the FlexTool repo, user-editable). The file is written
|
|
773
|
+
# in the *solver's own* parameter-file syntax — see the comment header
|
|
774
|
+
# in each baseline file for the exact format. The scenario's
|
|
775
|
+
# ``solver_options`` dict (already keyed by native parameter names by
|
|
776
|
+
# ``build_solver_options`` in ``_solver_dispatch.py``) is then overlaid
|
|
777
|
+
# on top, taking precedence per key. The merged dict is materialised
|
|
778
|
+
# to a temp file next to the MPS in the per-solver native format and
|
|
779
|
+
# fed to the CLI via the per-solver mechanism (Gurobi: ReadParams=
|
|
780
|
+
# argv slot; CPLEX/Xpress/COPT: inline set/setControl commands piped
|
|
781
|
+
# on stdin). Format helpers below.
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _resolve_solver_config_dir() -> Path:
|
|
785
|
+
"""Resolve the directory holding ``<solver>.opt`` baseline files.
|
|
786
|
+
|
|
787
|
+
Lookup order:
|
|
788
|
+
|
|
789
|
+
1. ``$FLEXTOOL_SOLVER_CONFIG_DIR`` environment variable (matches the
|
|
790
|
+
override hook used by the existing ``highs.opt`` resolution path
|
|
791
|
+
in tests / CI).
|
|
792
|
+
2. ``<cwd>/solver_config`` — the same default that
|
|
793
|
+
:func:`flextool.engine_polars._orchestration.run_chain_from_db`
|
|
794
|
+
uses for ``highs.opt``.
|
|
795
|
+
|
|
796
|
+
Returns the resolved :class:`Path` regardless of whether it exists;
|
|
797
|
+
callers check ``.is_file()`` on the specific solver file before
|
|
798
|
+
parsing.
|
|
799
|
+
"""
|
|
800
|
+
env = os.environ.get("FLEXTOOL_SOLVER_CONFIG_DIR")
|
|
801
|
+
if env:
|
|
802
|
+
return Path(env)
|
|
803
|
+
return Path.cwd() / "solver_config"
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _parse_native_opt_file(path: Path) -> dict[str, str]:
|
|
807
|
+
"""Parse a native solver ``.opt`` file into a name → value dict.
|
|
808
|
+
|
|
809
|
+
Format (shared across all four commercial baselines):
|
|
810
|
+
|
|
811
|
+
* ``#`` introduces a comment line; blank lines are ignored.
|
|
812
|
+
* Every other line is split on the *first* whitespace run into a
|
|
813
|
+
``(name, value)`` pair. The name may contain a single internal
|
|
814
|
+
space when needed (CPLEX uses dotted-or-spaced names like
|
|
815
|
+
``mip tolerances mipgap``) — the parser uses :func:`str.rsplit`
|
|
816
|
+
with ``maxsplit=1`` so the **last** whitespace-separated token is
|
|
817
|
+
the value and everything before is the name.
|
|
818
|
+
|
|
819
|
+
Returns an empty dict when the file is missing or unreadable.
|
|
820
|
+
"""
|
|
821
|
+
out: dict[str, str] = {}
|
|
822
|
+
if not path.is_file():
|
|
823
|
+
return out
|
|
824
|
+
try:
|
|
825
|
+
with path.open("r") as fh:
|
|
826
|
+
for raw in fh:
|
|
827
|
+
line = raw.strip()
|
|
828
|
+
if not line or line.startswith("#"):
|
|
829
|
+
continue
|
|
830
|
+
if " " not in line and "\t" not in line:
|
|
831
|
+
continue
|
|
832
|
+
name, value = line.rsplit(maxsplit=1)
|
|
833
|
+
out[name.strip()] = value.strip()
|
|
834
|
+
except OSError:
|
|
835
|
+
return {}
|
|
836
|
+
return out
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _format_native_opt_value(v: object) -> str:
|
|
840
|
+
"""Render *v* for a native solver opt-file line.
|
|
841
|
+
|
|
842
|
+
Booleans become ``1`` / ``0`` (Gurobi/COPT accept either word or
|
|
843
|
+
int; the int form is portable across CPLEX/Xpress too).
|
|
844
|
+
"""
|
|
845
|
+
if isinstance(v, bool):
|
|
846
|
+
return "1" if v else "0"
|
|
847
|
+
return str(v)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _format_gurobi_opt(merged: dict[str, str]) -> str:
|
|
851
|
+
"""Format *merged* as a Gurobi ``.prm`` file body.
|
|
852
|
+
|
|
853
|
+
One ``<ParamName> <value>`` per line. Same syntax produced by
|
|
854
|
+
``gurobi_cl`` 's own ``-w`` parameter dump.
|
|
855
|
+
"""
|
|
856
|
+
return "".join(
|
|
857
|
+
f"{name} {_format_native_opt_value(value)}\n"
|
|
858
|
+
for name, value in merged.items()
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _format_cplex_opt(merged: dict[str, str]) -> str:
|
|
863
|
+
"""Format *merged* as our CPLEX baseline body.
|
|
864
|
+
|
|
865
|
+
Same layout as the input file: ``<name> <value>`` per line, where
|
|
866
|
+
``<name>`` uses CPLEX' interactive ``set`` syntax (e.g.
|
|
867
|
+
``mip tolerances mipgap``). The per-solver script builder translates
|
|
868
|
+
each line into ``set <name> <value>`` for piping on stdin.
|
|
869
|
+
"""
|
|
870
|
+
return "".join(
|
|
871
|
+
f"{name} {_format_native_opt_value(value)}\n"
|
|
872
|
+
for name, value in merged.items()
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _format_xpress_opt(merged: dict[str, str]) -> str:
|
|
877
|
+
"""Format *merged* as our Xpress controls baseline body."""
|
|
878
|
+
return "".join(
|
|
879
|
+
f"{name} {_format_native_opt_value(value)}\n"
|
|
880
|
+
for name, value in merged.items()
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _format_copt_opt(merged: dict[str, str]) -> str:
|
|
885
|
+
"""Format *merged* as our COPT baseline body."""
|
|
886
|
+
return "".join(
|
|
887
|
+
f"{name} {_format_native_opt_value(value)}\n"
|
|
888
|
+
for name, value in merged.items()
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
_OPT_FORMATTERS = {
|
|
893
|
+
"gurobi": _format_gurobi_opt,
|
|
894
|
+
"cplex": _format_cplex_opt,
|
|
895
|
+
"xpress": _format_xpress_opt,
|
|
896
|
+
"copt": _format_copt_opt,
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _build_commercial_opt_file(
|
|
901
|
+
solver_name: str,
|
|
902
|
+
options: dict[str, Any] | None,
|
|
903
|
+
out_path: Path,
|
|
904
|
+
*,
|
|
905
|
+
config_dir: Path | None = None,
|
|
906
|
+
) -> Path | None:
|
|
907
|
+
"""Merge baseline ``<solver>.opt`` with scenario *options* and write
|
|
908
|
+
the result to *out_path*.
|
|
909
|
+
|
|
910
|
+
Returns the path to the merged file when it has at least one entry,
|
|
911
|
+
or ``None`` when both the baseline file is missing and the scenario
|
|
912
|
+
*options* dict is empty (caller skips the per-solver opt-file slot
|
|
913
|
+
entirely so the CLI doesn't get a ``ReadParams=`` to a no-op file).
|
|
914
|
+
"""
|
|
915
|
+
config_dir = config_dir or _resolve_solver_config_dir()
|
|
916
|
+
baseline_path = config_dir / f"{solver_name}.opt"
|
|
917
|
+
merged: dict[str, str] = {
|
|
918
|
+
k: str(v) for k, v in _parse_native_opt_file(baseline_path).items()
|
|
919
|
+
}
|
|
920
|
+
if options:
|
|
921
|
+
for key, value in options.items():
|
|
922
|
+
# Skip the FlexTool-internal ``time_limit`` knob — that's
|
|
923
|
+
# forwarded as the subprocess timeout, not as a solver
|
|
924
|
+
# parameter (Gurobi accepts ``TimeLimit`` which IS in the
|
|
925
|
+
# native-name overlay; the friendly ``time_limit`` key never
|
|
926
|
+
# appears here for the commercial path because
|
|
927
|
+
# ``build_solver_options`` translates it first).
|
|
928
|
+
if value is None:
|
|
929
|
+
continue
|
|
930
|
+
merged[str(key)] = _format_native_opt_value(value)
|
|
931
|
+
if not merged:
|
|
932
|
+
return None
|
|
933
|
+
formatter = _OPT_FORMATTERS[solver_name]
|
|
934
|
+
out_path.write_text(formatter(merged))
|
|
935
|
+
return out_path
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _parse_gurobi_sol(
|
|
939
|
+
path: Path,
|
|
940
|
+
) -> tuple[str, float | None, dict[str, float] | None, dict[str, float] | None]:
|
|
941
|
+
"""Parse a Gurobi ``.sol`` (key=value text).
|
|
942
|
+
|
|
943
|
+
Returns ``(status_str, objective, primal_dict, dual_dict)`` where
|
|
944
|
+
``status_str`` is one of ``"OPTIMAL"`` / ``"OTHER"``.
|
|
945
|
+
"""
|
|
946
|
+
if not path.is_file():
|
|
947
|
+
return "OTHER", None, None, None
|
|
948
|
+
objective: float | None = None
|
|
949
|
+
primal: dict[str, float] = {}
|
|
950
|
+
with path.open("r") as fh:
|
|
951
|
+
for raw in fh:
|
|
952
|
+
line = raw.strip()
|
|
953
|
+
if not line:
|
|
954
|
+
continue
|
|
955
|
+
if line.startswith("#"):
|
|
956
|
+
# Gurobi writes ``# Objective value = <v>``; COPT writes
|
|
957
|
+
# ``# Objective value <v>`` (no ``=``) — accept either.
|
|
958
|
+
m = re.search(r"[Oo]bjective\s+value\s*=?\s*([-\d.eE+inf]+)", line)
|
|
959
|
+
if m:
|
|
960
|
+
try:
|
|
961
|
+
objective = float(m.group(1))
|
|
962
|
+
except ValueError:
|
|
963
|
+
objective = None
|
|
964
|
+
continue
|
|
965
|
+
parts = line.split()
|
|
966
|
+
if len(parts) >= 2:
|
|
967
|
+
try:
|
|
968
|
+
primal[parts[0]] = float(parts[1])
|
|
969
|
+
except ValueError:
|
|
970
|
+
continue
|
|
971
|
+
status = "OPTIMAL" if primal else "OTHER"
|
|
972
|
+
return status, objective, (primal or None), None
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _parse_copt_sol(path: Path):
|
|
976
|
+
"""COPT's ``.sol`` matches Gurobi's key=value layout."""
|
|
977
|
+
return _parse_gurobi_sol(path)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _parse_cplex_sol(
|
|
981
|
+
path: Path,
|
|
982
|
+
) -> tuple[str, float | None, dict[str, float] | None, dict[str, float] | None]:
|
|
983
|
+
"""Parse CPLEX XML ``.sol`` via :mod:`xml.etree.ElementTree`."""
|
|
984
|
+
if not path.is_file():
|
|
985
|
+
return "OTHER", None, None, None
|
|
986
|
+
try:
|
|
987
|
+
tree = ET.parse(path)
|
|
988
|
+
except ET.ParseError:
|
|
989
|
+
return "OTHER", None, None, None
|
|
990
|
+
root = tree.getroot()
|
|
991
|
+
if root.tag == "CPLEXSolutions":
|
|
992
|
+
children = list(root)
|
|
993
|
+
if not children:
|
|
994
|
+
return "OTHER", None, None, None
|
|
995
|
+
root = children[0]
|
|
996
|
+
objective: float | None = None
|
|
997
|
+
status = "OTHER"
|
|
998
|
+
header = root.find("header")
|
|
999
|
+
if header is not None:
|
|
1000
|
+
obj_str = header.get("objectiveValue")
|
|
1001
|
+
if obj_str is not None:
|
|
1002
|
+
try:
|
|
1003
|
+
objective = float(obj_str)
|
|
1004
|
+
except ValueError:
|
|
1005
|
+
pass
|
|
1006
|
+
status_str = (header.get("solutionStatusString") or "").lower()
|
|
1007
|
+
if "optimal" in status_str:
|
|
1008
|
+
status = "OPTIMAL"
|
|
1009
|
+
elif "infeasible" in status_str:
|
|
1010
|
+
status = "INFEASIBLE"
|
|
1011
|
+
elif "unbounded" in status_str:
|
|
1012
|
+
status = "UNBOUNDED"
|
|
1013
|
+
elif "time" in status_str:
|
|
1014
|
+
status = "TIME_LIMIT"
|
|
1015
|
+
primal: dict[str, float] = {}
|
|
1016
|
+
vars_el = root.find("variables")
|
|
1017
|
+
if vars_el is not None:
|
|
1018
|
+
for v in vars_el.findall("variable"):
|
|
1019
|
+
name = v.get("name")
|
|
1020
|
+
val_str = v.get("value")
|
|
1021
|
+
if name is None or val_str is None:
|
|
1022
|
+
continue
|
|
1023
|
+
try:
|
|
1024
|
+
primal[name] = float(val_str)
|
|
1025
|
+
except ValueError:
|
|
1026
|
+
continue
|
|
1027
|
+
dual: dict[str, float] = {}
|
|
1028
|
+
cons_el = root.find("linearConstraints")
|
|
1029
|
+
if cons_el is not None:
|
|
1030
|
+
for c in cons_el.findall("constraint"):
|
|
1031
|
+
name = c.get("name")
|
|
1032
|
+
dual_str = c.get("dual")
|
|
1033
|
+
if name is None or dual_str is None:
|
|
1034
|
+
continue
|
|
1035
|
+
try:
|
|
1036
|
+
dual[name] = float(dual_str)
|
|
1037
|
+
except ValueError:
|
|
1038
|
+
continue
|
|
1039
|
+
if status == "OTHER" and primal:
|
|
1040
|
+
status = "OPTIMAL"
|
|
1041
|
+
return status, objective, (primal or None), (dual or None)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _parse_xpress_sol(
|
|
1045
|
+
path: Path,
|
|
1046
|
+
) -> tuple[str, float | None, dict[str, float] | None, dict[str, float] | None]:
|
|
1047
|
+
"""Parse the Xpress solution written by :func:`_xpress_script`.
|
|
1048
|
+
|
|
1049
|
+
We force Xpress to emit the MPS-like **SLX** solution (name-based,
|
|
1050
|
+
whitespace-safe) for the primal plus the **print** solution for the
|
|
1051
|
+
objective — rather than the default ``writesol`` output, which is an
|
|
1052
|
+
index-based ``.asc``/``.hdr`` CSV pair carrying no usable name→value
|
|
1053
|
+
mapping. Xpress appends ``.slx`` / ``.prt`` to the base path passed
|
|
1054
|
+
to ``writeslxsol`` / ``writeprtsol``, so look for those next to
|
|
1055
|
+
*path* (with a couple of fallbacks for console-version differences).
|
|
1056
|
+
|
|
1057
|
+
SLX body lines look like `` C C0000001 10`` (indicator, name,
|
|
1058
|
+
value); the print solution carries one
|
|
1059
|
+
``Objective function value is <v>`` line.
|
|
1060
|
+
"""
|
|
1061
|
+
def _first_existing(cands: list[Path]) -> Path | None:
|
|
1062
|
+
for c in cands:
|
|
1063
|
+
if c.is_file():
|
|
1064
|
+
return c
|
|
1065
|
+
return None
|
|
1066
|
+
|
|
1067
|
+
primal: dict[str, float] = {}
|
|
1068
|
+
slx = _first_existing(
|
|
1069
|
+
[Path(str(path) + ".slx"), path, path.with_suffix(".slx")]
|
|
1070
|
+
)
|
|
1071
|
+
if slx is not None:
|
|
1072
|
+
for raw in slx.read_text().splitlines():
|
|
1073
|
+
parts = raw.split()
|
|
1074
|
+
# Data rows carry a trailing numeric value and a name just
|
|
1075
|
+
# before it; header lines (NAME/ENDATA/OBJSENSE) fail the
|
|
1076
|
+
# float() and are skipped. ``_generic_col_id`` downstream
|
|
1077
|
+
# keeps only the ``C…`` column names.
|
|
1078
|
+
if len(parts) < 2:
|
|
1079
|
+
continue
|
|
1080
|
+
try:
|
|
1081
|
+
val = float(parts[-1])
|
|
1082
|
+
except ValueError:
|
|
1083
|
+
continue
|
|
1084
|
+
primal[parts[-2]] = val
|
|
1085
|
+
|
|
1086
|
+
objective: float | None = None
|
|
1087
|
+
prt = _first_existing(
|
|
1088
|
+
[Path(str(path) + ".prt"), path.with_suffix(".prt")]
|
|
1089
|
+
)
|
|
1090
|
+
if prt is not None:
|
|
1091
|
+
m = re.search(
|
|
1092
|
+
r"[Oo]bjective\s+function\s+value\s+is\s+([-\d.eE+]+)",
|
|
1093
|
+
prt.read_text(),
|
|
1094
|
+
)
|
|
1095
|
+
if m:
|
|
1096
|
+
try:
|
|
1097
|
+
objective = float(m.group(1))
|
|
1098
|
+
except ValueError:
|
|
1099
|
+
objective = None
|
|
1100
|
+
|
|
1101
|
+
status = "OPTIMAL" if primal else "OTHER"
|
|
1102
|
+
return status, objective, (primal or None), None
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
_PARSERS = {
|
|
1106
|
+
"gurobi": _parse_gurobi_sol,
|
|
1107
|
+
"cplex": _parse_cplex_sol,
|
|
1108
|
+
"xpress": _parse_xpress_sol,
|
|
1109
|
+
"copt": _parse_copt_sol,
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
_LICENSE_HINTS = (
|
|
1114
|
+
"license",
|
|
1115
|
+
"licence",
|
|
1116
|
+
"no token",
|
|
1117
|
+
"token server",
|
|
1118
|
+
"wls",
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _looks_like_license_error(*texts: str) -> bool:
|
|
1123
|
+
blob = "\n".join(t for t in texts if t).lower()
|
|
1124
|
+
return any(h in blob for h in _LICENSE_HINTS)
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
# ---------------------------------------------------------------------------
|
|
1128
|
+
# Public entry point
|
|
1129
|
+
# ---------------------------------------------------------------------------
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def solve_via_subprocess(
|
|
1133
|
+
problem: "Problem",
|
|
1134
|
+
solver_name: str,
|
|
1135
|
+
options: dict[str, Any] | None,
|
|
1136
|
+
*,
|
|
1137
|
+
solve_name: str,
|
|
1138
|
+
logger: logging.Logger | None = None,
|
|
1139
|
+
work_folder: Path | None = None,
|
|
1140
|
+
) -> "Solution":
|
|
1141
|
+
"""Solve *problem* via the appropriate CLI subprocess; return a Solution.
|
|
1142
|
+
|
|
1143
|
+
Parameters
|
|
1144
|
+
----------
|
|
1145
|
+
problem
|
|
1146
|
+
The :class:`polar_high.Problem` to solve. Its LP source is
|
|
1147
|
+
written to MPS via :meth:`Problem.write_mps(release=True)`,
|
|
1148
|
+
leaving the Problem in ``_released`` state.
|
|
1149
|
+
solver_name
|
|
1150
|
+
``"highs"`` for HiGHS (routes to ``flextool.cli.cmd_solve_mps``)
|
|
1151
|
+
or one of ``"gurobi"`` / ``"cplex"`` / ``"xpress"`` / ``"copt"``
|
|
1152
|
+
for the commercial CLIs.
|
|
1153
|
+
options
|
|
1154
|
+
Effective solver options. For HiGHS, written as a HiGHS ``.opt``
|
|
1155
|
+
file ingested by the subprocess. For commercial solvers,
|
|
1156
|
+
overlaid on top of the per-solver baseline at
|
|
1157
|
+
``solver_config/<solver>.opt`` and fed to the CLI via the
|
|
1158
|
+
per-solver mechanism (see
|
|
1159
|
+
:func:`_build_commercial_opt_file`). Raw options win on key
|
|
1160
|
+
collision. ``time_limit`` is additionally honoured as the
|
|
1161
|
+
subprocess timeout.
|
|
1162
|
+
solve_name
|
|
1163
|
+
Used to name the MPS / .opt / .sol files.
|
|
1164
|
+
logger
|
|
1165
|
+
Optional :class:`logging.Logger`.
|
|
1166
|
+
work_folder
|
|
1167
|
+
When given, intermediate files live under
|
|
1168
|
+
``<work_folder>/solve_data/subprocess/`` for post-mortem.
|
|
1169
|
+
``None`` uses a self-cleaning temp dir.
|
|
1170
|
+
|
|
1171
|
+
Returns
|
|
1172
|
+
-------
|
|
1173
|
+
polar_high.Solution
|
|
1174
|
+
Carries a :class:`_SolHighsShim` bound to the parsed
|
|
1175
|
+
primal/dual arrays and the recovered column/row names.
|
|
1176
|
+
Downstream writers see a uniform ``sol.highs`` shape regardless
|
|
1177
|
+
of which solver actually ran.
|
|
1178
|
+
"""
|
|
1179
|
+
if solver_name == "highs":
|
|
1180
|
+
return _solve_highs_subprocess(
|
|
1181
|
+
problem, options, solve_name=solve_name, logger=logger,
|
|
1182
|
+
work_folder=work_folder,
|
|
1183
|
+
)
|
|
1184
|
+
if solver_name in _SCRIPTS:
|
|
1185
|
+
return _solve_commercial_subprocess(
|
|
1186
|
+
problem, solver_name, options, solve_name=solve_name,
|
|
1187
|
+
logger=logger, work_folder=work_folder,
|
|
1188
|
+
)
|
|
1189
|
+
raise ValueError(
|
|
1190
|
+
f"solve_via_subprocess: unknown solver_name={solver_name!r}; "
|
|
1191
|
+
f"expected 'highs' or one of {sorted(_SCRIPTS)}"
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
# ---------------------------------------------------------------------------
|
|
1196
|
+
# HiGHS subprocess path (the original ``solve_via_subprocess`` body)
|
|
1197
|
+
# ---------------------------------------------------------------------------
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _solve_highs_subprocess(
|
|
1201
|
+
problem: "Problem",
|
|
1202
|
+
options: dict[str, Any] | None,
|
|
1203
|
+
*,
|
|
1204
|
+
solve_name: str,
|
|
1205
|
+
logger: logging.Logger | None,
|
|
1206
|
+
work_folder: Path | None,
|
|
1207
|
+
) -> "Solution":
|
|
1208
|
+
"""HiGHS-specific subprocess path (unchanged contract).
|
|
1209
|
+
|
|
1210
|
+
Writes MPS via :meth:`Problem.write_mps`, spawns
|
|
1211
|
+
:mod:`flextool.cli.cmd_solve_mps`, parses the .sol back via
|
|
1212
|
+
:func:`_parse_highs_sol` + :class:`_SolHighsShim` — no parent-side
|
|
1213
|
+
``highspy.Highs.readModel`` (which used to dominate cold-path RSS).
|
|
1214
|
+
|
|
1215
|
+
**Warm-start arm** (opt-in, active only when the save-memory
|
|
1216
|
+
subprocess path runs *and* ``FLEXTOOL_WARM_START == "1"``): a native
|
|
1217
|
+
HiGHS ``.bas`` basis is cached in a persistent directory that
|
|
1218
|
+
survives the per-solve ``out_dir`` cleanup, keyed by the MPS
|
|
1219
|
+
structural fingerprint (``problem._last_mps_fingerprint``). On a
|
|
1220
|
+
subsequent solve of the same structural model the cached basis is
|
|
1221
|
+
injected via the child's ``--warm-basis`` (readBasis); every
|
|
1222
|
+
warm-start run also captures a fresh basis (``--basis`` to a unique
|
|
1223
|
+
tmp, atomically published via ``os.replace`` on success) and a stats
|
|
1224
|
+
sidecar (``--stats``) for later measurement. Every step is
|
|
1225
|
+
defensive: a missing fingerprint, a rejected/garbage basis, or any
|
|
1226
|
+
exception falls back to a correct cold solve. Warm-start never
|
|
1227
|
+
breaks a solve. This ``.bas`` cache is a separate branch from the
|
|
1228
|
+
in-memory ``WarmProblem`` reuse gate in ``_orchestration.py``.
|
|
1229
|
+
|
|
1230
|
+
**First-transfer A/B gate** (Landmine B): a supplied basis disables
|
|
1231
|
+
HiGHS' presolve, so a stale/far seed can make the warm solve SLOWER
|
|
1232
|
+
than a cold (presolve-on) one. On a fingerprint's FIRST warm
|
|
1233
|
+
transfer (cache hit, no verdict marker yet) we run one extra cold
|
|
1234
|
+
timing PROBE child (throwaway ``.probe.sol`` / ``.probe.stats.json``,
|
|
1235
|
+
no ``--warm-basis``) alongside the normal warm main run, then compare
|
|
1236
|
+
wall times (``run_time`` from each stats sidecar). Two marker files
|
|
1237
|
+
in the cache dir record the verdict: ``<fp>.abtested`` (warm within
|
|
1238
|
+
``(1 + _WARM_AB_MARGIN)`` of cold → keep injecting, never re-probe)
|
|
1239
|
+
or ``<fp>.nowarm`` (warm regressed → solve cold from now on, still
|
|
1240
|
+
refreshing the captured basis). If the probe can't produce a time we
|
|
1241
|
+
write ``.abtested`` and proceed warm (no perpetual re-probing). The
|
|
1242
|
+
main run's Solution is always returned unchanged — the probe only
|
|
1243
|
+
informs the decision, so the returned result is correct regardless of
|
|
1244
|
+
the timing verdict. Marker writes are best-effort and non-fatal.
|
|
1245
|
+
"""
|
|
1246
|
+
from polar_high import Solution
|
|
1247
|
+
|
|
1248
|
+
cleanup = work_folder is None
|
|
1249
|
+
if work_folder is not None:
|
|
1250
|
+
out_dir = Path(work_folder) / "solve_data" / "subprocess"
|
|
1251
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1252
|
+
else:
|
|
1253
|
+
out_dir = Path(tempfile.mkdtemp(prefix="flextool_subprocess_"))
|
|
1254
|
+
|
|
1255
|
+
safe_name = solve_name.replace("/", "_").replace(" ", "_") or "solve"
|
|
1256
|
+
mps_path = out_dir / f"{safe_name}.mps"
|
|
1257
|
+
sol_path = out_dir / f"{safe_name}.sol"
|
|
1258
|
+
opts_path = out_dir / f"{safe_name}.opt"
|
|
1259
|
+
|
|
1260
|
+
# Warm-start basis-cache slots (populated only under FLEXTOOL_WARM_START;
|
|
1261
|
+
# declared here so the ``finally`` cleanup can reference them safely).
|
|
1262
|
+
warm_cached: Path | None = None
|
|
1263
|
+
warm_tmp_bas: Path | None = None
|
|
1264
|
+
warm_stats_path: Path | None = None
|
|
1265
|
+
# First-transfer A/B gate slots.
|
|
1266
|
+
warm_probe_sol: Path | None = None
|
|
1267
|
+
warm_probe_stats: Path | None = None
|
|
1268
|
+
warm_nowarm_marker: Path | None = None
|
|
1269
|
+
warm_abtested_marker: Path | None = None
|
|
1270
|
+
warm_first_transfer = False
|
|
1271
|
+
warm_cold_time: float | None = None
|
|
1272
|
+
warm_run_time: float | None = None
|
|
1273
|
+
|
|
1274
|
+
try:
|
|
1275
|
+
opts = options or {}
|
|
1276
|
+
with open(opts_path, "w") as f:
|
|
1277
|
+
for k, v in opts.items():
|
|
1278
|
+
f.write(f"{k}={_format_opt_value(v)}\n")
|
|
1279
|
+
|
|
1280
|
+
if logger is not None:
|
|
1281
|
+
logger.info(
|
|
1282
|
+
"save_memory: building LP for %r, writing MPS to %s",
|
|
1283
|
+
solve_name, mps_path,
|
|
1284
|
+
)
|
|
1285
|
+
|
|
1286
|
+
problem.write_mps(str(mps_path), release=True)
|
|
1287
|
+
|
|
1288
|
+
cmd = [
|
|
1289
|
+
sys.executable, "-m", "flextool.cli.cmd_solve_mps",
|
|
1290
|
+
"--mps", str(mps_path),
|
|
1291
|
+
"--solution", str(sol_path),
|
|
1292
|
+
"--options", str(opts_path),
|
|
1293
|
+
]
|
|
1294
|
+
|
|
1295
|
+
# --- Warm-start arm (opt-in) ------------------------------------
|
|
1296
|
+
# Keyed by the MPS structural fingerprint. Everything here is
|
|
1297
|
+
# defensive: on any hiccup we drop the warm slots and solve cold.
|
|
1298
|
+
if os.environ.get("FLEXTOOL_WARM_START") == "1":
|
|
1299
|
+
try:
|
|
1300
|
+
fp = getattr(problem, "_last_mps_fingerprint", None)
|
|
1301
|
+
if fp is None:
|
|
1302
|
+
if logger is not None:
|
|
1303
|
+
logger.debug(
|
|
1304
|
+
"save_memory: no MPS fingerprint — warm-start "
|
|
1305
|
+
"skipped for %r", solve_name,
|
|
1306
|
+
)
|
|
1307
|
+
else:
|
|
1308
|
+
cache_env = os.environ.get("FLEXTOOL_BASIS_CACHE_DIR")
|
|
1309
|
+
if cache_env:
|
|
1310
|
+
cache_dir = Path(cache_env)
|
|
1311
|
+
elif work_folder is not None:
|
|
1312
|
+
cache_dir = Path(work_folder) / "basis_cache"
|
|
1313
|
+
else:
|
|
1314
|
+
cache_dir = (
|
|
1315
|
+
Path(tempfile.gettempdir())
|
|
1316
|
+
/ "flextool_basis_cache"
|
|
1317
|
+
)
|
|
1318
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
1319
|
+
warm_cached = cache_dir / f"{fp}.bas"
|
|
1320
|
+
warm_nowarm_marker = cache_dir / f"{fp}.nowarm"
|
|
1321
|
+
warm_abtested_marker = cache_dir / f"{fp}.abtested"
|
|
1322
|
+
warm_stats_path = out_dir / f"{safe_name}.stats.json"
|
|
1323
|
+
|
|
1324
|
+
if warm_cached.exists():
|
|
1325
|
+
if warm_nowarm_marker.exists():
|
|
1326
|
+
# This family regressed on its first A/B —
|
|
1327
|
+
# never inject the basis (solve cold); still
|
|
1328
|
+
# refresh the captured basis below.
|
|
1329
|
+
if logger is not None:
|
|
1330
|
+
logger.info(
|
|
1331
|
+
"save_memory: warm-start disabled "
|
|
1332
|
+
"(.nowarm) for %s — solving cold", fp,
|
|
1333
|
+
)
|
|
1334
|
+
elif warm_abtested_marker.exists():
|
|
1335
|
+
# Verdict already recorded as beneficial —
|
|
1336
|
+
# trust it, inject warm, no A/B probe.
|
|
1337
|
+
cmd += ["--warm-basis", str(warm_cached)]
|
|
1338
|
+
if logger is not None:
|
|
1339
|
+
logger.info(
|
|
1340
|
+
"save_memory: warm-basis cache hit %s "
|
|
1341
|
+
"(.abtested — trusting warm)", fp,
|
|
1342
|
+
)
|
|
1343
|
+
else:
|
|
1344
|
+
# FIRST warm transfer for this fingerprint: run
|
|
1345
|
+
# a cold timing probe now, then inject warm for
|
|
1346
|
+
# the main run and decide the verdict afterwards.
|
|
1347
|
+
warm_first_transfer = True
|
|
1348
|
+
warm_probe_sol = (
|
|
1349
|
+
out_dir / f"{safe_name}.probe.sol"
|
|
1350
|
+
)
|
|
1351
|
+
warm_probe_stats = (
|
|
1352
|
+
out_dir / f"{safe_name}.probe.stats.json"
|
|
1353
|
+
)
|
|
1354
|
+
warm_cold_time = _run_cold_probe(
|
|
1355
|
+
mps_path=mps_path,
|
|
1356
|
+
opts_path=opts_path,
|
|
1357
|
+
probe_sol=warm_probe_sol,
|
|
1358
|
+
probe_stats=warm_probe_stats,
|
|
1359
|
+
logger=logger,
|
|
1360
|
+
)
|
|
1361
|
+
cmd += ["--warm-basis", str(warm_cached)]
|
|
1362
|
+
if logger is not None:
|
|
1363
|
+
logger.info(
|
|
1364
|
+
"save_memory: warm-basis cache hit %s "
|
|
1365
|
+
"(first transfer — A/B probing, "
|
|
1366
|
+
"cold_time=%s)", fp, warm_cold_time,
|
|
1367
|
+
)
|
|
1368
|
+
# Always refresh the cache this run: capture a fresh
|
|
1369
|
+
# basis to a per-process tmp (atomic publish on success),
|
|
1370
|
+
# plus a stats sidecar for later measurement.
|
|
1371
|
+
warm_tmp_bas = cache_dir / f"{fp}.bas.tmp.{os.getpid()}"
|
|
1372
|
+
cmd += [
|
|
1373
|
+
"--basis", str(warm_tmp_bas),
|
|
1374
|
+
"--stats", str(warm_stats_path),
|
|
1375
|
+
]
|
|
1376
|
+
except Exception as exc: # noqa: BLE001 - fail safe to cold
|
|
1377
|
+
if logger is not None:
|
|
1378
|
+
logger.warning(
|
|
1379
|
+
"save_memory: warm-start setup failed (%s) — "
|
|
1380
|
+
"solving cold", exc,
|
|
1381
|
+
)
|
|
1382
|
+
warm_cached = warm_tmp_bas = warm_stats_path = None
|
|
1383
|
+
warm_probe_sol = warm_probe_stats = None
|
|
1384
|
+
warm_first_transfer = False
|
|
1385
|
+
|
|
1386
|
+
if logger is not None:
|
|
1387
|
+
logger.info(
|
|
1388
|
+
"save_memory: spawning subprocess HiGHS for %r", solve_name,
|
|
1389
|
+
)
|
|
1390
|
+
cp = subprocess.run(cmd)
|
|
1391
|
+
optimal = cp.returncode == 0
|
|
1392
|
+
|
|
1393
|
+
# --- Warm-start publish + measurement ---------------------------
|
|
1394
|
+
if warm_tmp_bas is not None:
|
|
1395
|
+
try:
|
|
1396
|
+
if cp.returncode == 0 and warm_tmp_bas.exists():
|
|
1397
|
+
# Atomic publish (write-tmp-then-rename); tmp and cache
|
|
1398
|
+
# share a directory so this is a same-FS rename.
|
|
1399
|
+
os.replace(str(warm_tmp_bas), str(warm_cached))
|
|
1400
|
+
if logger is not None:
|
|
1401
|
+
logger.info(
|
|
1402
|
+
"save_memory: published warm-basis cache %s",
|
|
1403
|
+
warm_cached.name,
|
|
1404
|
+
)
|
|
1405
|
+
else:
|
|
1406
|
+
# Failed/partial solve — never publish; drop the tmp.
|
|
1407
|
+
if warm_tmp_bas.exists():
|
|
1408
|
+
warm_tmp_bas.unlink()
|
|
1409
|
+
except Exception as exc: # noqa: BLE001 - non-fatal
|
|
1410
|
+
if logger is not None:
|
|
1411
|
+
logger.warning(
|
|
1412
|
+
"save_memory: warm-basis publish failed (%s) — "
|
|
1413
|
+
"cache not updated", exc,
|
|
1414
|
+
)
|
|
1415
|
+
try:
|
|
1416
|
+
if warm_tmp_bas.exists():
|
|
1417
|
+
warm_tmp_bas.unlink()
|
|
1418
|
+
except OSError:
|
|
1419
|
+
pass
|
|
1420
|
+
if warm_stats_path is not None:
|
|
1421
|
+
try:
|
|
1422
|
+
if warm_stats_path.exists():
|
|
1423
|
+
stats = json.loads(warm_stats_path.read_text())
|
|
1424
|
+
rt = stats.get("run_time")
|
|
1425
|
+
warm_run_time = float(rt) if rt is not None else None
|
|
1426
|
+
if logger is not None:
|
|
1427
|
+
logger.info(
|
|
1428
|
+
"save_memory: warm-start stats for %r — "
|
|
1429
|
+
"simplex_iters=%s warm_basis_used=%s run_time=%s",
|
|
1430
|
+
solve_name,
|
|
1431
|
+
stats.get("simplex_iteration_count"),
|
|
1432
|
+
stats.get("warm_basis_used"),
|
|
1433
|
+
warm_run_time,
|
|
1434
|
+
)
|
|
1435
|
+
except Exception as exc: # noqa: BLE001 - measurement only
|
|
1436
|
+
if logger is not None:
|
|
1437
|
+
logger.warning(
|
|
1438
|
+
"save_memory: warm-start stats parse failed (%s)",
|
|
1439
|
+
exc,
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
# --- First-transfer A/B verdict ---------------------------------
|
|
1443
|
+
# Decide whether warm-start helped this family. A supplied basis
|
|
1444
|
+
# disables presolve, so compare wall times: keep injecting only
|
|
1445
|
+
# when warm is within (1 + margin) of cold, else mark ``.nowarm``.
|
|
1446
|
+
# No usable timing → trust warm and stop re-probing (``.abtested``).
|
|
1447
|
+
if warm_first_transfer:
|
|
1448
|
+
try:
|
|
1449
|
+
cold_t = warm_cold_time
|
|
1450
|
+
warm_t = warm_run_time
|
|
1451
|
+
if (
|
|
1452
|
+
cold_t is not None and cold_t >= 0.0
|
|
1453
|
+
and warm_t is not None and warm_t >= 0.0
|
|
1454
|
+
):
|
|
1455
|
+
if warm_t > cold_t * (1.0 + _warm_ab_margin()):
|
|
1456
|
+
_touch_marker(warm_nowarm_marker, logger)
|
|
1457
|
+
if logger is not None:
|
|
1458
|
+
logger.info(
|
|
1459
|
+
"warm-start regressed for %s "
|
|
1460
|
+
"(warm=%ss vs cold=%ss) — disabling future "
|
|
1461
|
+
"injection", fp, warm_t, cold_t,
|
|
1462
|
+
)
|
|
1463
|
+
else:
|
|
1464
|
+
_touch_marker(warm_abtested_marker, logger)
|
|
1465
|
+
if logger is not None:
|
|
1466
|
+
logger.info(
|
|
1467
|
+
"save_memory: warm-start confirmed "
|
|
1468
|
+
"beneficial for %s (warm=%ss vs cold=%ss)",
|
|
1469
|
+
fp, warm_t, cold_t,
|
|
1470
|
+
)
|
|
1471
|
+
else:
|
|
1472
|
+
# Probe gave no usable time — trust warm, don't re-probe.
|
|
1473
|
+
_touch_marker(warm_abtested_marker, logger)
|
|
1474
|
+
if logger is not None:
|
|
1475
|
+
logger.debug(
|
|
1476
|
+
"save_memory: A/B probe produced no usable time "
|
|
1477
|
+
"for %s — trusting warm (.abtested)", fp,
|
|
1478
|
+
)
|
|
1479
|
+
except Exception as exc: # noqa: BLE001 - gating is best-effort
|
|
1480
|
+
if logger is not None:
|
|
1481
|
+
logger.warning(
|
|
1482
|
+
"save_memory: warm-start A/B gating failed (%s) — "
|
|
1483
|
+
"no verdict recorded", exc,
|
|
1484
|
+
)
|
|
1485
|
+
if cp.returncode > 1:
|
|
1486
|
+
raise RuntimeError(
|
|
1487
|
+
f"subprocess HiGHS for solve {solve_name!r} failed with "
|
|
1488
|
+
f"exit code {cp.returncode}; MPS+options preserved at "
|
|
1489
|
+
f"{out_dir} for inspection"
|
|
1490
|
+
)
|
|
1491
|
+
if logger is not None:
|
|
1492
|
+
logger.info(
|
|
1493
|
+
"save_memory: subprocess complete (exit=%d, optimal=%s); "
|
|
1494
|
+
"reading solution from %s",
|
|
1495
|
+
cp.returncode, optimal, sol_path,
|
|
1496
|
+
)
|
|
1497
|
+
|
|
1498
|
+
# Parse the .sol directly — no parent-side ``Highs.readModel``.
|
|
1499
|
+
# On large LPs the old path's ``readModel(mps)`` spiked +33 GB
|
|
1500
|
+
# of RSS purely to satisfy ``allVariableNames()`` / ``getSolution()``
|
|
1501
|
+
# on downstream writers; the .sol file already carries names +
|
|
1502
|
+
# primal + duals, and the writers see the same shape via the
|
|
1503
|
+
# ``_SolHighsShim`` wrapper.
|
|
1504
|
+
col_names, row_names, col_value, col_dual, row_dual = (
|
|
1505
|
+
_parse_highs_sol(sol_path)
|
|
1506
|
+
)
|
|
1507
|
+
n_cols = len(col_value)
|
|
1508
|
+
if col_dual.size == 0:
|
|
1509
|
+
col_dual = np.zeros(n_cols, dtype=np.float64)
|
|
1510
|
+
|
|
1511
|
+
# HiGHS' ``writeSolution`` may emit synthesized row names
|
|
1512
|
+
# (``r0, r1, ...``) when the LP carried duplicate or otherwise
|
|
1513
|
+
# un-preservable names through ``readModel`` — the warning
|
|
1514
|
+
# "Row names are not present, or contain duplicates: using
|
|
1515
|
+
# names with prefix 'r'" flags this on the subprocess stderr.
|
|
1516
|
+
# The MPS file written by polar-high carries the canonical
|
|
1517
|
+
# constraint names (e.g. ``nodeBalance_eq[n, d, t]``); recover
|
|
1518
|
+
# them so the row-dual output writers' name-based lookups keep
|
|
1519
|
+
# working. Detect by checking whether every name matches the
|
|
1520
|
+
# ``r<int>`` pattern AND the count equals the MPS' constraint
|
|
1521
|
+
# row count.
|
|
1522
|
+
if row_names and all(
|
|
1523
|
+
n.startswith("r") and n[1:].isdigit() for n in row_names
|
|
1524
|
+
):
|
|
1525
|
+
mps_row_names = _parse_mps_row_names(mps_path)
|
|
1526
|
+
if len(mps_row_names) == len(row_names):
|
|
1527
|
+
row_names = mps_row_names
|
|
1528
|
+
if logger is not None:
|
|
1529
|
+
logger.debug(
|
|
1530
|
+
"save_memory: recovered %d row names from MPS "
|
|
1531
|
+
"(HiGHS writeSolution emitted synthesized 'r*' "
|
|
1532
|
+
"names for solve %r)",
|
|
1533
|
+
len(row_names), solve_name,
|
|
1534
|
+
)
|
|
1535
|
+
obj = _read_objective_from_sol(sol_path)
|
|
1536
|
+
h = _SolHighsShim(
|
|
1537
|
+
col_names=col_names,
|
|
1538
|
+
row_names=row_names,
|
|
1539
|
+
col_value=col_value,
|
|
1540
|
+
col_dual=col_dual,
|
|
1541
|
+
row_dual=row_dual,
|
|
1542
|
+
objective=obj,
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
return Solution(
|
|
1546
|
+
optimal=optimal,
|
|
1547
|
+
obj=obj,
|
|
1548
|
+
col_value=col_value,
|
|
1549
|
+
row_dual=row_dual,
|
|
1550
|
+
col_dual=col_dual,
|
|
1551
|
+
col_names=col_names,
|
|
1552
|
+
row_names=row_names,
|
|
1553
|
+
vars=dict(problem._vars),
|
|
1554
|
+
highs=h,
|
|
1555
|
+
)
|
|
1556
|
+
finally:
|
|
1557
|
+
if cleanup:
|
|
1558
|
+
paths = [mps_path, sol_path, opts_path]
|
|
1559
|
+
# The warm-start stats sidecar and A/B probe throwaways live
|
|
1560
|
+
# inside ``out_dir`` — sweep them too so the ``rmdir`` below
|
|
1561
|
+
# succeeds. The cache itself (``warm_cached`` / ``warm_tmp_bas``)
|
|
1562
|
+
# and the verdict markers live OUTSIDE ``out_dir`` and survive.
|
|
1563
|
+
if warm_stats_path is not None:
|
|
1564
|
+
paths.append(warm_stats_path)
|
|
1565
|
+
if warm_probe_sol is not None:
|
|
1566
|
+
paths.append(warm_probe_sol)
|
|
1567
|
+
if warm_probe_stats is not None:
|
|
1568
|
+
paths.append(warm_probe_stats)
|
|
1569
|
+
for p in paths:
|
|
1570
|
+
try:
|
|
1571
|
+
if p.exists():
|
|
1572
|
+
p.unlink()
|
|
1573
|
+
except OSError:
|
|
1574
|
+
pass
|
|
1575
|
+
try:
|
|
1576
|
+
out_dir.rmdir()
|
|
1577
|
+
except OSError:
|
|
1578
|
+
pass
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
# ---------------------------------------------------------------------------
|
|
1582
|
+
# Commercial-solver subprocess path (new)
|
|
1583
|
+
# ---------------------------------------------------------------------------
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def _solve_commercial_subprocess(
|
|
1587
|
+
problem: "Problem",
|
|
1588
|
+
solver_name: str,
|
|
1589
|
+
options: dict[str, Any] | None,
|
|
1590
|
+
*,
|
|
1591
|
+
solve_name: str,
|
|
1592
|
+
logger: logging.Logger | None,
|
|
1593
|
+
work_folder: Path | None,
|
|
1594
|
+
) -> "Solution":
|
|
1595
|
+
"""Solve via a commercial solver's CLI binary.
|
|
1596
|
+
|
|
1597
|
+
1. ``problem.write_mps(release=True, emit_names=False)`` produces the
|
|
1598
|
+
MPS via the cheap polars writer (peak ~2-3 GB on a 9.9 M-row LP),
|
|
1599
|
+
using GENERIC ``C0000001`` / ``R0000002`` names. Free-format MPS
|
|
1600
|
+
is whitespace-delimited with no portable quoting, so real
|
|
1601
|
+
FlexTool entity names — which routinely contain spaces (e.g.
|
|
1602
|
+
``Battery Farm``) — cannot go in the file without corrupting it.
|
|
1603
|
+
We keep the real names in memory instead and map back by index.
|
|
1604
|
+
2. Locate ``gurobi_cl`` / ``cplex`` / ``optimizer`` / ``copt_cmd``
|
|
1605
|
+
via :func:`_find_solver_binary`.
|
|
1606
|
+
3. Spawn the binary with the per-solver argv + stdin script
|
|
1607
|
+
(copied verbatim from polar-high's ``_mps_fallback``).
|
|
1608
|
+
4. Parse the .sol with the per-solver parser; map its generic-named
|
|
1609
|
+
primal (and duals, for :data:`_DUAL_CAPABLE_SOLVERS`) back by
|
|
1610
|
+
*index* onto the real ``col_id`` / ``row_id`` names rebuilt from
|
|
1611
|
+
``problem._vars`` (:func:`_col_names_from_vars`) and the pre-release
|
|
1612
|
+
``_cstrs`` snapshot (:func:`_row_names_from_cstrs`).
|
|
1613
|
+
5. Wrap as a :class:`polar_high.Solution` backed by a
|
|
1614
|
+
:class:`_SolHighsShim` so the existing output writer paths Just
|
|
1615
|
+
Work — no parent-side ``highspy.Highs.readModel`` (which used to
|
|
1616
|
+
OOM on large LPs and hard-fail on entity names containing spaces).
|
|
1617
|
+
|
|
1618
|
+
``options`` is fed to the solver via a per-solver native opt-file
|
|
1619
|
+
materialised next to the MPS in the per-solve temp dir. The file
|
|
1620
|
+
starts from the user-editable baseline at
|
|
1621
|
+
``solver_config/<solver>.opt`` (shipped in the FlexTool repo) and
|
|
1622
|
+
overlays the scenario's *options* dict line-by-line (raw entries
|
|
1623
|
+
win on key collision — same semantics as
|
|
1624
|
+
:func:`build_solver_options`). Each per-solver script builder then
|
|
1625
|
+
references the merged file in the cleanest native form
|
|
1626
|
+
(Gurobi: ``gurobi_cl ReadParams=<file> ...``; CPLEX/Xpress/COPT:
|
|
1627
|
+
inline ``set``/``setControl`` lines piped on stdin before the
|
|
1628
|
+
``read``/``optimize`` pair). ``time_limit`` is *also* honoured as
|
|
1629
|
+
the subprocess timeout when present in *options*.
|
|
1630
|
+
"""
|
|
1631
|
+
from polar_high import Solution
|
|
1632
|
+
|
|
1633
|
+
binary = _find_solver_binary(solver_name)
|
|
1634
|
+
if binary is None:
|
|
1635
|
+
raise RuntimeError(
|
|
1636
|
+
f"{solver_name!r} CLI binary "
|
|
1637
|
+
f"({_BINARY_NAMES.get(solver_name)!r}) was not found on $PATH "
|
|
1638
|
+
f"or in the conventional install directories. Install the "
|
|
1639
|
+
f"solver and ensure its 'bin' directory is on $PATH."
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
cleanup = work_folder is None
|
|
1643
|
+
if work_folder is not None:
|
|
1644
|
+
out_dir = Path(work_folder) / "solve_data" / "subprocess"
|
|
1645
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1646
|
+
else:
|
|
1647
|
+
out_dir = Path(tempfile.mkdtemp(prefix=f"flextool_{solver_name}_"))
|
|
1648
|
+
|
|
1649
|
+
safe_name = solve_name.replace("/", "_").replace(" ", "_") or "solve"
|
|
1650
|
+
mps_path = out_dir / f"{safe_name}.mps"
|
|
1651
|
+
sol_path = out_dir / f"{safe_name}.sol"
|
|
1652
|
+
opt_path = out_dir / f"{safe_name}.opt"
|
|
1653
|
+
|
|
1654
|
+
# Pull a time_limit out of options if the caller forwarded one.
|
|
1655
|
+
time_limit: float | None = None
|
|
1656
|
+
if options:
|
|
1657
|
+
for key in ("time_limit", "TimeLimit", "timelimit", "maxtime"):
|
|
1658
|
+
v = options.get(key)
|
|
1659
|
+
if v is not None:
|
|
1660
|
+
try:
|
|
1661
|
+
time_limit = float(v)
|
|
1662
|
+
break
|
|
1663
|
+
except (TypeError, ValueError):
|
|
1664
|
+
pass
|
|
1665
|
+
|
|
1666
|
+
try:
|
|
1667
|
+
if logger is not None:
|
|
1668
|
+
logger.info(
|
|
1669
|
+
"subprocess[%s]: building LP for %r, writing MPS to %s",
|
|
1670
|
+
solver_name, solve_name, mps_path,
|
|
1671
|
+
)
|
|
1672
|
+
# Capture the real constraint names BEFORE releasing. Only the
|
|
1673
|
+
# dual-returning parsers need them (Gurobi/COPT/Xpress ResultFiles
|
|
1674
|
+
# carry primal values only), and ``write_mps(release=True)`` drops
|
|
1675
|
+
# ``_cstrs``. Real *column* names survive release via ``_vars`` and
|
|
1676
|
+
# are rebuilt after the solve.
|
|
1677
|
+
real_row_names: list[str] = (
|
|
1678
|
+
_row_names_from_cstrs(problem)
|
|
1679
|
+
if solver_name in _DUAL_CAPABLE_SOLVERS else []
|
|
1680
|
+
)
|
|
1681
|
+
# Emit GENERIC, whitespace-safe names (``C0000001`` / ``R0000002`` …)
|
|
1682
|
+
# rather than the real entity-bearing names. Free-format MPS is
|
|
1683
|
+
# whitespace-delimited with no portable quoting, so a real name
|
|
1684
|
+
# containing a space — common in FlexTool entity names, e.g. a node
|
|
1685
|
+
# inside ``v_flow[Battery Farm,…]`` — would split mid-token and the
|
|
1686
|
+
# solver silently mis-parses the column, returning a WRONG answer
|
|
1687
|
+
# (verified: obj changes, primal unrecoverable). We don't need real
|
|
1688
|
+
# names in the file: the solution is mapped back by *index* below and
|
|
1689
|
+
# the real names come from the in-memory Problem. This makes the
|
|
1690
|
+
# commercial path whitespace-agnostic, exactly like the in-process
|
|
1691
|
+
# HiGHS path.
|
|
1692
|
+
problem.write_mps(str(mps_path), release=True, emit_names=False)
|
|
1693
|
+
|
|
1694
|
+
# Merge baseline ``solver_config/<solver>.opt`` with the scenario
|
|
1695
|
+
# options dict (raw entries win) and write the result to
|
|
1696
|
+
# ``<solve>.opt`` for the per-solver CLI to ingest. Returns
|
|
1697
|
+
# ``None`` when both sources are empty — the per-solver script
|
|
1698
|
+
# builders then skip the opt-file slot entirely.
|
|
1699
|
+
opt_arg = _build_commercial_opt_file(
|
|
1700
|
+
solver_name, options, opt_path,
|
|
1701
|
+
)
|
|
1702
|
+
|
|
1703
|
+
argv, stdin_text = _SCRIPTS[solver_name](
|
|
1704
|
+
binary, mps_path, sol_path, opt_arg,
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
if logger is not None:
|
|
1708
|
+
logger.info(
|
|
1709
|
+
"subprocess[%s]: spawning %s for %r",
|
|
1710
|
+
solver_name, binary, solve_name,
|
|
1711
|
+
)
|
|
1712
|
+
try:
|
|
1713
|
+
cp = subprocess.run(
|
|
1714
|
+
argv,
|
|
1715
|
+
input=stdin_text,
|
|
1716
|
+
capture_output=True,
|
|
1717
|
+
text=True,
|
|
1718
|
+
check=False,
|
|
1719
|
+
timeout=time_limit,
|
|
1720
|
+
)
|
|
1721
|
+
except subprocess.TimeoutExpired as exc:
|
|
1722
|
+
raise RuntimeError(
|
|
1723
|
+
f"{solver_name!r} CLI exceeded time_limit={time_limit!r}s "
|
|
1724
|
+
f"for solve {solve_name!r}; captured stderr: {exc.stderr!r}"
|
|
1725
|
+
) from exc
|
|
1726
|
+
|
|
1727
|
+
stdout = cp.stdout or ""
|
|
1728
|
+
stderr = cp.stderr or ""
|
|
1729
|
+
|
|
1730
|
+
if cp.returncode != 0:
|
|
1731
|
+
msg = (
|
|
1732
|
+
f"{solver_name!r} CLI for solve {solve_name!r} exited with "
|
|
1733
|
+
f"returncode={cp.returncode}.\n"
|
|
1734
|
+
f"--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
|
|
1735
|
+
)
|
|
1736
|
+
# MPS+sol preserved when work_folder was given; otherwise
|
|
1737
|
+
# the finally-block has already cleaned them up.
|
|
1738
|
+
if _looks_like_license_error(stdout, stderr):
|
|
1739
|
+
raise RuntimeError(f"LICENSE: {msg}")
|
|
1740
|
+
raise RuntimeError(msg)
|
|
1741
|
+
|
|
1742
|
+
status_str, objective, primal, dual = _PARSERS[solver_name](sol_path)
|
|
1743
|
+
if status_str == "OTHER" and not primal:
|
|
1744
|
+
if _looks_like_license_error(stdout, stderr):
|
|
1745
|
+
raise RuntimeError(
|
|
1746
|
+
f"{solver_name!r} CLI for solve {solve_name!r} returned "
|
|
1747
|
+
f"no usable solution and its output mentions licensing.\n"
|
|
1748
|
+
f"--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
|
|
1749
|
+
)
|
|
1750
|
+
raise RuntimeError(
|
|
1751
|
+
f"{solver_name!r} CLI produced no solution file we could "
|
|
1752
|
+
f"parse at {sol_path} for solve {solve_name!r}.\n"
|
|
1753
|
+
f"--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
|
|
1754
|
+
)
|
|
1755
|
+
|
|
1756
|
+
optimal = status_str == "OPTIMAL"
|
|
1757
|
+
|
|
1758
|
+
# Real, ``col_id``-indexed column names come from the surviving
|
|
1759
|
+
# ``_vars`` (never from the MPS — which now carries only generic
|
|
1760
|
+
# names). The ``.sol`` primal is keyed by those generic names, so
|
|
1761
|
+
# we map each value back by *index*: ``C0000001`` → ``col_id`` 0.
|
|
1762
|
+
# No real (possibly space-bearing) name is ever round-tripped
|
|
1763
|
+
# through the whitespace-delimited MPS/.sol.
|
|
1764
|
+
col_names = _col_names_from_vars(problem)
|
|
1765
|
+
n_cols = len(col_names)
|
|
1766
|
+
col_value = np.zeros(n_cols, dtype=np.float64)
|
|
1767
|
+
primal_dict = primal or {}
|
|
1768
|
+
mapped = 0
|
|
1769
|
+
unrecognised = 0
|
|
1770
|
+
for gname, val in primal_dict.items():
|
|
1771
|
+
cid = _generic_col_id(gname)
|
|
1772
|
+
if cid is None or not (0 <= cid < n_cols):
|
|
1773
|
+
unrecognised += 1
|
|
1774
|
+
continue
|
|
1775
|
+
col_value[cid] = float(val)
|
|
1776
|
+
mapped += 1
|
|
1777
|
+
if unrecognised and logger is not None:
|
|
1778
|
+
logger.warning(
|
|
1779
|
+
"subprocess[%s]: %d/%d .sol entries had unrecognised "
|
|
1780
|
+
"(non-generic) column names for solve %r — ignored",
|
|
1781
|
+
solver_name, unrecognised, len(primal_dict), solve_name,
|
|
1782
|
+
)
|
|
1783
|
+
if mapped < n_cols and logger is not None:
|
|
1784
|
+
logger.warning(
|
|
1785
|
+
"subprocess[%s]: %d/%d columns absent from .sol for solve "
|
|
1786
|
+
"%r (defaulted to 0.0)",
|
|
1787
|
+
solver_name, n_cols - mapped, n_cols, solve_name,
|
|
1788
|
+
)
|
|
1789
|
+
|
|
1790
|
+
# Duals: only the dual-capable parsers return a dict (Gurobi/COPT/
|
|
1791
|
+
# Xpress carry none). Their ``.sol`` keys duals by the generic row
|
|
1792
|
+
# name (``R0000002`` → ``row_id`` 0); map onto the real constraint
|
|
1793
|
+
# names captured before release so downstream name-based dual
|
|
1794
|
+
# lookups keep working.
|
|
1795
|
+
row_names = real_row_names
|
|
1796
|
+
row_dual = np.zeros(len(row_names), dtype=np.float64)
|
|
1797
|
+
if dual and row_names:
|
|
1798
|
+
for gname, dv in dual.items():
|
|
1799
|
+
rid = _generic_row_id(gname)
|
|
1800
|
+
if rid is not None and 0 <= rid < len(row_names):
|
|
1801
|
+
row_dual[rid] = float(dv)
|
|
1802
|
+
col_dual = np.zeros(n_cols, dtype=np.float64)
|
|
1803
|
+
|
|
1804
|
+
# Wrap the parsed arrays in the same duck-typed shim the HiGHS
|
|
1805
|
+
# save-memory path uses. Downstream writers consume
|
|
1806
|
+
# ``allVariableNames()`` / ``getSolution()`` / ``getLp().row_names_``
|
|
1807
|
+
# / ``passColName()`` identically to a live ``highspy.Highs`` — no
|
|
1808
|
+
# 33 GB sidecar ``readModel``, no MPS text round-trip.
|
|
1809
|
+
obj = objective if objective is not None else 0.0
|
|
1810
|
+
h = _SolHighsShim(
|
|
1811
|
+
col_names=col_names,
|
|
1812
|
+
row_names=row_names,
|
|
1813
|
+
col_value=col_value,
|
|
1814
|
+
col_dual=col_dual,
|
|
1815
|
+
row_dual=row_dual,
|
|
1816
|
+
objective=obj,
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1819
|
+
return Solution(
|
|
1820
|
+
optimal=optimal,
|
|
1821
|
+
obj=obj,
|
|
1822
|
+
col_value=col_value,
|
|
1823
|
+
row_dual=row_dual,
|
|
1824
|
+
col_dual=col_dual,
|
|
1825
|
+
col_names=col_names,
|
|
1826
|
+
row_names=row_names,
|
|
1827
|
+
vars=dict(problem._vars),
|
|
1828
|
+
highs=h,
|
|
1829
|
+
)
|
|
1830
|
+
finally:
|
|
1831
|
+
if cleanup:
|
|
1832
|
+
# out_dir is a dedicated per-solve tempdir; remove it wholesale
|
|
1833
|
+
# so solver-written sidecars (Xpress' .slx/.prt, .hdr/.asc,
|
|
1834
|
+
# per-solver logs) don't leak or block an rmdir.
|
|
1835
|
+
shutil.rmtree(out_dir, ignore_errors=True)
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
__all__ = ["solve_via_subprocess"]
|