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,511 @@
1
+ """Multi-solver dispatch helpers (Phases 2 + 3 of the FlexTool multi-solver port).
2
+
3
+ This module owns:
4
+
5
+ * :func:`build_solver_options` — translate :class:`SolverConfig` into
6
+ the option dict polar-high's ``solve()`` consumes (Phase 2).
7
+ * :func:`run_one_solve` — dispatch a single :class:`polar_high.Problem`
8
+ to either ``Problem.solve()`` (in-process HiGHS, default — preserves
9
+ streaming + ``Solution.highs``) or
10
+ :func:`flextool.engine_polars._subprocess_solve.solve_via_subprocess`
11
+ (every cold path — HiGHS via ``cmd_solve_mps`` and commercial solvers
12
+ via their CLI binaries; both return real ``polar_high.Solution``
13
+ objects with the HiGHS instance read back from the MPS).
14
+ * :class:`FlexToolUserError` — surface user-facing errors from the
15
+ commercial path with actionable hints.
16
+
17
+ See ``specs/flextool-multi-solver-handoff.md`` Step 3 for the design
18
+ rationale and the canonical _PARAM_MAP table.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any, Mapping
25
+
26
+ from flextool.engine_polars._solve_config import SolverConfig
27
+
28
+ if TYPE_CHECKING:
29
+ from pathlib import Path
30
+
31
+ from polar_high import Problem
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Effective HiGHS-options resolver (Batch C.1)
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def _parse_highs_opt_file(path: Path | None) -> dict[str, str]:
40
+ """Parse a HiGHS-style ``key=value`` options file into a flat dict.
41
+
42
+ HiGHS' ``highs.opt`` syntax is one ``key = value`` line per option
43
+ with optional surrounding whitespace; lines starting with ``#`` and
44
+ blank lines are comments and skipped. Unknown / malformed lines
45
+ are also skipped (HiGHS itself tolerates them) so the floor never
46
+ fails the engine; the user sees the misparse in HiGHS' own warning
47
+ output when it loads the file.
48
+
49
+ Returns an empty dict when *path* is None or does not exist — that
50
+ is the steady state for in-process engine runs (the file is read
51
+ by HiGHS on the CLI path only). The resolver still calls this
52
+ helper so a future change to wire the file through
53
+ ``set_solver_options`` is a one-call edit.
54
+ """
55
+ if path is None or not path.is_file():
56
+ return {}
57
+ options: dict[str, str] = {}
58
+ for line in path.read_text(encoding="utf-8").splitlines():
59
+ stripped = line.strip()
60
+ if not stripped or stripped.startswith("#"):
61
+ continue
62
+ if "=" not in stripped:
63
+ continue
64
+ key, _, val = stripped.partition("=")
65
+ options[key.strip()] = val.strip()
66
+ return options
67
+
68
+
69
+ def _resolve_effective_highs_options(
70
+ *,
71
+ solver_arguments_map: Mapping[str, Any] | None,
72
+ highs_opt_path: Path | None,
73
+ cli_overrides: Mapping[str, Any] | None,
74
+ baseline: Mapping[str, Any] | None = None,
75
+ ) -> dict[str, Any]:
76
+ """Resolve the effective HiGHS solver options for one solve.
77
+
78
+ Precedence (lowest → highest):
79
+
80
+ 1. ``baseline`` — engine-pinned defaults from
81
+ :func:`flextool.engine_polars._orchestration._baseline_highs_options`
82
+ (Curtis-Reid simplex scale + the four determinism keys from
83
+ :data:`flextool.engine_polars._determinism.DETERMINISM_OPTIONS`).
84
+ Each higher layer may overwrite these — operator intent wins.
85
+ 2. ``highs.opt`` — floor parsed from ``solver_config/highs.opt``
86
+ via :func:`_parse_highs_opt_file`. Project-level defaults the
87
+ user has committed to disk.
88
+ 3. ``solver_arguments`` — the 1d-map authored on the active
89
+ solve's ``solver_arguments`` parameter (Batch C.1+).
90
+ 4. ``cli_overrides`` — keys injected by CLI flags
91
+ (``--highs-threads``, ``--solver-time-limit``, …) via
92
+ :func:`flextool.engine_polars._orchestration._finalise_highs_options`.
93
+ Highest precedence; the operator's command-line intent is
94
+ authoritative.
95
+
96
+ Empty / ``None`` layers are skipped cleanly.
97
+
98
+ Parameters
99
+ ----------
100
+ solver_arguments_map
101
+ The 1d-map dict authored on the solve's ``solver_arguments``
102
+ parameter. ``None`` and ``{}`` are equivalent.
103
+ highs_opt_path
104
+ Path to ``solver_config/highs.opt`` (or any equivalent).
105
+ ``None`` skips this layer.
106
+ cli_overrides
107
+ Dict of HiGHS option-keys → values to apply at the top of the
108
+ precedence chain. ``None`` and ``{}`` are equivalent.
109
+ baseline
110
+ Optional engine-pinned floor below all other layers. When
111
+ ``None`` an empty dict is used (callers that want the
112
+ determinism + scale floor pass it explicitly).
113
+
114
+ Returns
115
+ -------
116
+ dict[str, Any]
117
+ The final option dict ready to forward to
118
+ ``polar_high.Problem.set_solver_options``.
119
+ """
120
+ options: dict[str, Any] = dict(baseline) if baseline else {}
121
+ options.update(_parse_highs_opt_file(highs_opt_path))
122
+ if solver_arguments_map:
123
+ for key, value in solver_arguments_map.items():
124
+ options[str(key)] = value
125
+ if cli_overrides:
126
+ for key, value in cli_overrides.items():
127
+ options[str(key)] = value
128
+ return options
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # User-facing error type
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ class FlexToolUserError(Exception):
137
+ """Raised for user-actionable misconfiguration of the multi-solver
138
+ dispatch.
139
+
140
+ Carries a message intended for direct surfacing to the user — installer
141
+ hint, license hint, or "switch the solver" hint. ``__cause__`` carries
142
+ the underlying polar-high exception for debugging.
143
+ """
144
+
145
+
146
+ # Per-solver native parameter names for the three "convenience" knobs
147
+ # normalised by FlexTool. Anything outside these three goes through
148
+ # untranslated via ``SolverConfig.options``.
149
+ #
150
+ # Source: ``specs/flextool-multi-solver-handoff.md`` lines 109-136.
151
+ _PARAM_MAP: dict[str, dict[str, str]] = {
152
+ "highs": {"time_limit": "time_limit", "mip_gap": "mip_rel_gap", "threads": "threads"},
153
+ "gurobi": {"time_limit": "TimeLimit", "mip_gap": "MIPGap", "threads": "Threads"},
154
+ "cplex": {"time_limit": "timelimit", "mip_gap": "mip.tolerances.mipgap", "threads": "threads"},
155
+ "xpress": {"time_limit": "maxtime", "mip_gap": "miprelstop", "threads": "threads"},
156
+ "copt": {"time_limit": "TimeLimit", "mip_gap": "RelGap", "threads": "Threads"},
157
+ }
158
+
159
+
160
+ def build_solver_options(solver_config: SolverConfig) -> dict[str, Any]:
161
+ """Translate a :class:`SolverConfig` into the raw options dict that
162
+ polar-high's ``solve()`` consumes.
163
+
164
+ 1. The three convenience knobs (``time_limit`` / ``mip_gap`` /
165
+ ``threads``), when set on *solver_config*, are translated to the
166
+ chosen solver's native parameter name via :data:`_PARAM_MAP`.
167
+ ``None`` values are skipped (no override).
168
+ 2. The raw ``solver_config.options`` dict is merged on top of the
169
+ translated knobs — **raw options win** on key collisions. The
170
+ user knows what they're doing; if they hand-write
171
+ ``solver_arguments = {"TimeLimit": 30}`` and also set
172
+ ``solver_time_limit = 60``, the raw value (30) wins.
173
+
174
+ Parameters
175
+ ----------
176
+ solver_config
177
+ Solve-level config built by
178
+ :meth:`flextool.engine_polars._solve_config.SolveConfig.load_from_db`.
179
+
180
+ Returns
181
+ -------
182
+ dict[str, Any]
183
+ Option dict ready to ``**unpack`` into
184
+ :func:`polar_high.solvers.solve`. Empty dict when no
185
+ convenience knobs are set and ``options`` is empty.
186
+
187
+ Raises
188
+ ------
189
+ ValueError
190
+ If ``solver_config.name`` is not in :data:`_PARAM_MAP` AND at
191
+ least one convenience knob is set. Raw-options-only with an
192
+ unknown solver passes through silently so users can plug a
193
+ future solver via ``solver_arguments`` before
194
+ :data:`_PARAM_MAP` is updated.
195
+ """
196
+ has_convenience_knob = (
197
+ solver_config.time_limit is not None
198
+ or solver_config.mip_gap is not None
199
+ or solver_config.threads is not None
200
+ )
201
+ mapping = _PARAM_MAP.get(solver_config.name)
202
+ if mapping is None and has_convenience_knob:
203
+ available = ", ".join(sorted(_PARAM_MAP.keys()))
204
+ raise ValueError(
205
+ f"unknown solver {solver_config.name!r}, expected one of: "
206
+ f"{available}"
207
+ )
208
+
209
+ opts: dict[str, Any] = {}
210
+ if mapping is not None:
211
+ if solver_config.time_limit is not None:
212
+ opts[mapping["time_limit"]] = solver_config.time_limit
213
+ if solver_config.mip_gap is not None:
214
+ opts[mapping["mip_gap"]] = solver_config.mip_gap
215
+ if solver_config.threads is not None:
216
+ opts[mapping["threads"]] = solver_config.threads
217
+
218
+ # Raw options take precedence — see docstring.
219
+ opts.update(solver_config.options)
220
+ return opts
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Single-solve dispatch (Phase 3)
225
+ # ---------------------------------------------------------------------------
226
+
227
+
228
+ def run_one_solve(
229
+ problem: "Problem",
230
+ solver_config: SolverConfig,
231
+ logger: logging.Logger | None = None,
232
+ *,
233
+ save_memory: bool = False,
234
+ solve_name: str | None = None,
235
+ work_folder: "Path | None" = None,
236
+ ):
237
+ """Dispatch *problem* to the chosen solver.
238
+
239
+ The default HiGHS path is byte-identical to the pre-Phase-3 code: a
240
+ direct call to ``problem.solve(keep_solver=True)`` preserves
241
+ streaming, the live ``Solution.highs`` (consumed by the output
242
+ writer adapter), and the established option-resolution chain.
243
+
244
+ The commercial path (gurobi / cplex / xpress / copt) routes through
245
+ :func:`flextool.engine_polars._subprocess_solve.solve_via_subprocess`,
246
+ which spawns the solver's CLI binary against an MPS written by
247
+ ``Problem.write_mps`` and reads the .sol back through a read-only
248
+ ``highspy.Highs`` populated via ``setSolution``. Downstream consumers
249
+ (``input.py``, ``_emit_co2_accumulators.py``,
250
+ ``process_outputs/read_parameters.py``) see a uniform
251
+ :class:`polar_high.Solution` shape regardless of which solver ran.
252
+
253
+ Parameters
254
+ ----------
255
+ problem
256
+ The :class:`polar_high.Problem` to solve.
257
+ solver_config
258
+ Resolved per-solve configuration (defaults to HiGHS/direct when
259
+ no ``solver_*`` parameter is authored on the solve).
260
+ logger
261
+ Optional logger. When provided, the commercial-path error
262
+ messages are also logged at ERROR level before being raised.
263
+ save_memory
264
+ When True on the HiGHS path, divert to
265
+ :func:`flextool.engine_polars._subprocess_solve.solve_via_subprocess`:
266
+ polar-high writes the LP to a temp MPS file via
267
+ ``Problem.write_mps(release=True)`` (a direct polars→MPS writer
268
+ that never builds a ``highspy.Highs`` instance, peaking at
269
+ ~2-3 GB on the largest LPs vs ~45 GB for HiGHS' own
270
+ ``writeModel``), then a ``flextool.cli.cmd_solve_mps``
271
+ subprocess solves the MPS in a clean address space. The
272
+ parent reads the solution back via a read-only ``highspy.Highs``
273
+ and wraps it as a ``polar_high.Solution`` identical in shape
274
+ to the in-process return. Trades file I/O for HiGHS' active-
275
+ solve memory living outside the parent process. Also disables
276
+ warm-LP reuse for the cascade — the Problem is in ``_released``
277
+ state after the write. Silently ignored on the commercial path.
278
+ solve_name
279
+ Used by the subprocess path to name MPS/options/solution files.
280
+ Defaults to ``"solve"`` when omitted.
281
+ work_folder
282
+ When provided, the subprocess path keeps its MPS/options/sol
283
+ files under ``<work_folder>/solve_data/subprocess/`` for post-
284
+ mortem inspection. ``None`` uses a self-cleaning tempdir.
285
+ Ignored on the in-process path.
286
+
287
+ Returns
288
+ -------
289
+ polar_high.Solution
290
+ The native polar-high Solution. On the cold/subprocess paths
291
+ the contained ``highs`` instance is a read-only
292
+ ``highspy.Highs`` reconstructed from the MPS with the primal /
293
+ dual injected via ``setSolution``.
294
+
295
+ Raises
296
+ ------
297
+ FlexToolUserError
298
+ If the requested solver's Python wrapper is not installed, the
299
+ license check fails, or the solver returns a model-level error.
300
+ """
301
+ if solver_config.name == "highs":
302
+ # Default path: keep ``Problem.solve()`` (preserves streaming +
303
+ # ``Solution.highs`` for the output writer adapter). Forward
304
+ # ``solver_arguments`` and the convenience-knob translations so
305
+ # HiGHS-side users get the same surface as commercial users —
306
+ # ``problem.solve(options=...)`` accepts a dict and routes each
307
+ # key to HiGHS via ``setOptionValue`` (polar-high engine.py).
308
+ highs_options = build_solver_options(solver_config) or None
309
+ if save_memory:
310
+ # Subprocess path: write MPS via Problem.write_mps directly
311
+ # from polars frames, spawn flextool.cli.cmd_solve_mps, read
312
+ # solution back. The effective options are forwarded to the
313
+ # subprocess through the .opt file written by solve_via_
314
+ # subprocess (write_mps itself runs no solver and takes no
315
+ # options). Source: convenience-knob-translated options
316
+ # when present, else whatever was already stored on the
317
+ # Problem via ``set_solver_options`` upstream (autoscale
318
+ # Layer 3 may have mutated those).
319
+ from flextool.engine_polars._subprocess_solve import (
320
+ solve_via_subprocess,
321
+ )
322
+ effective_opts = (
323
+ highs_options if highs_options is not None
324
+ else dict(getattr(problem, "_solver_options", {}) or {})
325
+ )
326
+ return solve_via_subprocess(
327
+ problem,
328
+ "highs",
329
+ effective_opts,
330
+ solve_name=solve_name or "solve",
331
+ logger=logger,
332
+ work_folder=work_folder,
333
+ )
334
+ return problem.solve(
335
+ options=highs_options,
336
+ keep_solver=True,
337
+ )
338
+
339
+ # Commercial path. Always subprocess: write MPS via the cheap
340
+ # ``Problem.write_mps`` (polars→MPS, no LpView), spawn the solver's
341
+ # CLI binary, parse the .sol. The in-process commercial-solver
342
+ # dispatch was retired alongside the in-process cold-HiGHS path —
343
+ # the goal is a single hard memory bound (~2-3 GB for write_mps on
344
+ # the largest LPs) for every cold solve regardless of solver.
345
+ #
346
+ # Convenience-knob translations are honoured but most commercial
347
+ # knobs (and the raw ``solver_options`` dict) are not yet plumbed
348
+ # into the CLI scripts — see :mod:`_subprocess_solve` for the
349
+ # current contract. ``time_limit`` flows through as the subprocess
350
+ # timeout.
351
+ from flextool.engine_polars._subprocess_solve import (
352
+ _BINARY_NAMES,
353
+ solve_via_subprocess,
354
+ )
355
+ if solver_config.name not in _BINARY_NAMES and solver_config.name != "highs":
356
+ # Unknown solver name — fail clean before we try anything else.
357
+ from polar_high.solvers import available_solvers
358
+ msg = (
359
+ f"Unknown solver {solver_config.name!r}. Supported solvers: "
360
+ f"highs, gurobi, cplex, xpress, copt. Installed wrappers: "
361
+ f"{available_solvers}."
362
+ )
363
+ if logger is not None:
364
+ logger.error(msg)
365
+ raise FlexToolUserError(msg)
366
+ options = build_solver_options(solver_config)
367
+ try:
368
+ return solve_via_subprocess(
369
+ problem,
370
+ solver_config.name,
371
+ options,
372
+ solve_name=solve_name or "solve",
373
+ logger=logger,
374
+ work_folder=work_folder,
375
+ )
376
+ except RuntimeError as e:
377
+ msg = str(e)
378
+ if msg.startswith("LICENSE: "):
379
+ user_msg = (
380
+ f"Solver {solver_config.name!r} subprocess failed a "
381
+ f"license check. Details: {msg[len('LICENSE: '):]}. "
382
+ f"See docs/solvers/{solver_config.name}.md#licensing "
383
+ f"for help."
384
+ )
385
+ if logger is not None:
386
+ logger.error(user_msg)
387
+ raise FlexToolUserError(user_msg) from e
388
+ if "was not found on $PATH" in msg:
389
+ user_msg = (
390
+ f"Solver {solver_config.name!r} CLI binary is not "
391
+ f"installed on this system. {msg} See "
392
+ f"docs/solvers/{solver_config.name}.md for installation "
393
+ f"instructions."
394
+ )
395
+ if logger is not None:
396
+ logger.error(user_msg)
397
+ raise FlexToolUserError(user_msg) from e
398
+ if logger is not None:
399
+ logger.error(msg)
400
+ raise FlexToolUserError(
401
+ f"Solver {solver_config.name!r} subprocess returned an "
402
+ f"error: {msg}. This is usually a model issue (numerics, "
403
+ f"scaling, infeasibility) or a CLI invocation problem."
404
+ ) from e
405
+
406
+
407
+ _LICENSE_PROBE_CACHE: dict[str, str] | None = None
408
+
409
+
410
+ def probe_solver_licenses() -> dict[str, str]:
411
+ """Return ``{solver_name: status}`` for every solver in
412
+ ``polar_high.solvers.available_solvers``.
413
+
414
+ Status values:
415
+
416
+ - ``"installed"`` — wrapper installed, license check passed (with
417
+ either a free test license or a commercial one), trivial solve
418
+ completed. Neutral wording — does not imply the user holds a
419
+ full commercial entitlement; a free trial license counts too.
420
+ - ``"no-license"`` — wrapper installed but the solver refused on
421
+ license grounds (commercial trial expired, no licence file, etc.).
422
+ - ``"not-installed"`` — Python wrapper isn't on this system.
423
+ - ``"probe-failed"`` — any other exception during the probe; the
424
+ solver may or may not be functional on a real problem.
425
+
426
+ The probe runs a 1-variable, 0-constraint LP through
427
+ ``polar_high.solvers.solve(...)`` per solver. Solver chatter on
428
+ stdout is suppressed. Result cached at module level so repeat
429
+ cascade runs in the same Python process don't re-probe.
430
+
431
+ Used by ``_orchestration.run_chain_from_db`` to print one INFO line
432
+ per cascade, giving users a quick "is gurobi actually working on
433
+ this machine" hint.
434
+ """
435
+ global _LICENSE_PROBE_CACHE
436
+ if _LICENSE_PROBE_CACHE is not None:
437
+ return _LICENSE_PROBE_CACHE
438
+ import io
439
+ import os
440
+ from contextlib import redirect_stdout, redirect_stderr
441
+ # Tests set FLEXTOOL_SKIP_SOLVER_PROBE=1 in conftest to avoid the
442
+ # FICO Xpress Community LicenseWarning (and other startup chatter)
443
+ # that the probe triggers via xpress.problem(). Skip silently and
444
+ # cache an empty dict so callers' .items() iteration is a no-op.
445
+ if os.environ.get("FLEXTOOL_SKIP_SOLVER_PROBE"):
446
+ _LICENSE_PROBE_CACHE = {}
447
+ return _LICENSE_PROBE_CACHE
448
+ import polars as pl
449
+ from polar_high import Problem
450
+ from polar_high.solvers import (
451
+ LicenseError,
452
+ SolverError,
453
+ SolverNotAvailableError,
454
+ available_solvers,
455
+ )
456
+ from polar_high.solvers import solve as polar_solve
457
+
458
+ statuses: dict[str, str] = {}
459
+ # HiGHS / Xpress write probe chatter via C-level handles that
460
+ # ``redirect_stdout`` alone can't catch. Redirect the underlying
461
+ # file descriptors for the duration of each probe so the startup
462
+ # log stays clean.
463
+ devnull_fd = os.open(os.devnull, os.O_WRONLY)
464
+ saved_stdout_fd = os.dup(1)
465
+ saved_stderr_fd = os.dup(2)
466
+ try:
467
+ for solver_name in available_solvers:
468
+ try:
469
+ p = Problem()
470
+ df = pl.DataFrame({"i": [0]})
471
+ v = p.add_var(
472
+ "x", dims=("i",), index=df, lower=0.0, upper=10.0,
473
+ )
474
+ p.set_objective(v.to_expr())
475
+ os.dup2(devnull_fd, 1)
476
+ os.dup2(devnull_fd, 2)
477
+ with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
478
+ polar_solve(p, solver_name=solver_name)
479
+ # The probe only confirms that a license file (free
480
+ # test license or commercial) is *installed* — not that
481
+ # the user holds a full commercial entitlement. Use
482
+ # the neutral "installed" wording to avoid overstating.
483
+ statuses[solver_name] = "installed"
484
+ except LicenseError:
485
+ statuses[solver_name] = "no-license"
486
+ except SolverNotAvailableError:
487
+ statuses[solver_name] = "not-installed"
488
+ except SolverError:
489
+ statuses[solver_name] = "solver-error"
490
+ except Exception: # noqa: BLE001 — probe should never crash startup
491
+ statuses[solver_name] = "probe-failed"
492
+ finally:
493
+ os.dup2(saved_stdout_fd, 1)
494
+ os.dup2(saved_stderr_fd, 2)
495
+ finally:
496
+ os.close(devnull_fd)
497
+ os.close(saved_stdout_fd)
498
+ os.close(saved_stderr_fd)
499
+ _LICENSE_PROBE_CACHE = statuses
500
+ return statuses
501
+
502
+
503
+ __all__ = [
504
+ "_PARAM_MAP",
505
+ "FlexToolUserError",
506
+ "_parse_highs_opt_file",
507
+ "_resolve_effective_highs_options",
508
+ "build_solver_options",
509
+ "probe_solver_licenses",
510
+ "run_one_solve",
511
+ ]