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