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.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. 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"]