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,134 @@
1
+ """Shell out to ``cmd_run_flextool`` for one calibrator iteration.
2
+
3
+ The calibrator does **not** solve in-process: it launches a fresh
4
+ :mod:`flextool.cli.cmd_run_flextool` subprocess per iteration (a clean
5
+ address space per solve, matching how the model is run in production) and
6
+ then reads the produced parquet outputs. This module owns that launch —
7
+ building the argv, wiring the warm-start environment, capturing the launch
8
+ time (needed by the solve-success detector's freshness check), and running
9
+ the subprocess with its stdout+stderr merged into one captured stream.
10
+
11
+ Warm start
12
+ ----------
13
+ Warm start is enabled via the environment, not the ``--warm-start`` CLI
14
+ flag, because it is the env vars the engine actually reads
15
+ (``FLEXTOOL_WARM_START`` / ``FLEXTOOL_BASIS_CACHE_DIR`` in
16
+ ``flextool.engine_polars._orchestration``). A *stable* basis-cache
17
+ directory shared across iterations lets HiGHS reuse the previous
18
+ iteration's basis when the structural model is unchanged (the adder is
19
+ RHS-only, so the warm-start fingerprint is stable across iterations).
20
+
21
+ ``FLEXTOOL_SAVE_MEMORY`` is deliberately **not** set here: it releases the
22
+ live HiGHS instance after each sub-solve and so DISABLES warm-LP reuse. It
23
+ must also stay constant (unset) across every iteration — flipping it
24
+ mid-run would invalidate the shared basis cache.
25
+
26
+ This module does not judge success: it returns the raw
27
+ :class:`SolveRun` and lets the loop call
28
+ :func:`flextool.calibrate.assess_solve` so the loop owns the
29
+ ``required_outputs`` choice.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ import subprocess
36
+ import sys
37
+ import time
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+
41
+ # Repo root = <root>/flextool/calibrate/_solve.py → parents[2].
42
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
43
+
44
+
45
+ @dataclass
46
+ class SolveRun:
47
+ """Raw record of one ``cmd_run_flextool`` subprocess.
48
+
49
+ ``returncode`` — the subprocess exit code (a weak success signal; see
50
+ :mod:`flextool.calibrate._solve_status`).
51
+ ``started_at`` — POSIX wall-clock time captured immediately before the
52
+ subprocess launched, for the detector's freshness check.
53
+ ``assess_dir`` — the directory that directly holds this run's result
54
+ parquets (``<out_root>/output_parquet/<scenario>``).
55
+ ``stdout`` — the merged stdout+stderr text of the subprocess.
56
+ """
57
+
58
+ returncode: int
59
+ started_at: float
60
+ assess_dir: Path
61
+ stdout: str
62
+
63
+
64
+ def run_solve(
65
+ url: str,
66
+ scenario: str,
67
+ *,
68
+ work_dir: Path,
69
+ out_root: Path,
70
+ cache_dir: Path,
71
+ ) -> SolveRun:
72
+ """Run one calibrator solve and return its raw :class:`SolveRun`.
73
+
74
+ Parameters
75
+ ----------
76
+ url:
77
+ Input SpineDB — a bare path (promoted to ``sqlite:///``) or a full
78
+ SQLAlchemy URL.
79
+ scenario:
80
+ The model scenario to solve.
81
+ work_dir:
82
+ Working directory for the subprocess's intermediate files
83
+ (``--work-folder``).
84
+ out_root:
85
+ Output-location root (``--output-location``); results land under
86
+ ``out_root/output_parquet/<scenario>/``.
87
+ cache_dir:
88
+ Warm-start basis-cache directory, shared across iterations
89
+ (``FLEXTOOL_BASIS_CACHE_DIR``). Keep it stable across the whole
90
+ calibration run so HiGHS can reuse the prior iteration's basis.
91
+ """
92
+ url_norm = url if "://" in url else f"sqlite:///{url}"
93
+ argv = [
94
+ sys.executable,
95
+ "-m",
96
+ "flextool.cli.cmd_run_flextool",
97
+ url_norm,
98
+ "--scenario-name",
99
+ scenario,
100
+ "--work-folder",
101
+ str(work_dir),
102
+ "--output-location",
103
+ str(out_root),
104
+ "--write-methods",
105
+ "parquet",
106
+ ]
107
+
108
+ env = os.environ.copy()
109
+ env["FLEXTOOL_WARM_START"] = "1"
110
+ env["FLEXTOOL_BASIS_CACHE_DIR"] = str(cache_dir)
111
+ # FLEXTOOL_SAVE_MEMORY is intentionally left untouched: setting it would
112
+ # disable warm-LP reuse, and it must stay constant across iterations.
113
+
114
+ assess_dir = Path(out_root) / "output_parquet" / scenario
115
+
116
+ started_at = time.time()
117
+ proc = subprocess.run(
118
+ argv,
119
+ cwd=str(_REPO_ROOT),
120
+ env=env,
121
+ stdout=subprocess.PIPE,
122
+ stderr=subprocess.STDOUT,
123
+ text=True,
124
+ )
125
+
126
+ return SolveRun(
127
+ returncode=proc.returncode,
128
+ started_at=started_at,
129
+ assess_dir=assess_dir,
130
+ stdout=proc.stdout or "",
131
+ )
132
+
133
+
134
+ __all__ = ["SolveRun", "run_solve"]
@@ -0,0 +1,495 @@
1
+ """Resilient solve-success detection for the energy-margin calibrator.
2
+
3
+ The calibrator runs an investment+dispatch solve each iteration by
4
+ *shelling out* to :mod:`flextool.cli.cmd_run_flextool` and then reads the
5
+ per-node unserved-energy slack from the produced outputs. Before it can
6
+ trust those numbers it must answer one question: **did this solve actually
7
+ succeed?** This module is that answer.
8
+
9
+ Why the subprocess exit code is not enough
10
+ ------------------------------------------
11
+ Success is ``f(solve-status signals, output completeness)`` with the exit
12
+ code as only *one weak input*, because the exit code lies in both
13
+ directions:
14
+
15
+ * **False failure (nonzero exit, good solve).** A known *model-specific*
16
+ post-solve writer bug — ``Shared-alternative write failed: '<REG>'``
17
+ ``KeyError`` in the separate PLEXOS→FlexTool writer, **not** in this
18
+ engine — can raise *after* the cascade has solved and written every
19
+ output, bubbling to a nonzero exit. The results on disk are complete
20
+ and usable; the run must be treated as a success.
21
+
22
+ * **False success (zero exit, missing results).** A run that never
23
+ reached, or aborted inside, output writing can still exit cleanly in
24
+ some paths; if the calibrator's required result files are absent it must
25
+ be treated as a failure regardless of the exit code.
26
+
27
+ What FlexTool actually leaves on disk
28
+ -------------------------------------
29
+ FlexTool does **not** persist a per-sub-solve optimality/acceptance status
30
+ file. The authoritative "was this solve acceptable" decision
31
+ (:func:`flextool.engine_polars._solve_acceptance.classify_acceptance`, run
32
+ at the solve site, and the cascade exit-scan
33
+ ``flextool.cli.cmd_run_flextool._scan_cascade_optimality`` that consumes
34
+ it) lives *in memory* and is surfaced only via:
35
+
36
+ * the process **exit code** (0 iff every sub-solve was ``kOptimal``,
37
+ accepted near-optimal, or a Benders solve with a feasible incumbent;
38
+ 1 on a genuine failure), and
39
+ * **log lines** on stdout/stderr.
40
+
41
+ Crucially, ``cmd_run_flextool`` calls ``write_outputs`` **only when the
42
+ cascade returned success** — a genuinely failed / infeasible / unaccepted
43
+ solve short-circuits with ``return_code == 1`` and writes *no* output
44
+ files at all. Therefore, on a fresh output directory, the **presence and
45
+ non-emptiness of the required result parquets is itself the on-disk
46
+ signal that every sub-solve was accepted**: a failed sub-solve manifests
47
+ as *missing outputs*, not as a status flag.
48
+
49
+ The detector's rule
50
+ -------------------
51
+ ``outputs_complete`` = every required output parquet is present, is a
52
+ readable parquet with at least one row, and (when ``started_at`` is given)
53
+ was written by *this* run rather than left over from a previous one:
54
+
55
+ * not complete → **failed** (name the offending files);
56
+ * complete + exit 0/``None`` → **succeeded** (``started_at`` optional);
57
+ * complete + nonzero exit + fresh → **succeeded**, the nonzero exit is
58
+ recorded as *overridden* (the post-solve-writer-crash case);
59
+ * complete + nonzero exit + freshness UNVERIFIABLE (no ``started_at``)
60
+ → **failed** — the override is refused because it cannot be made safely.
61
+
62
+ Stale-output caveat (why ``started_at`` matters)
63
+ ------------------------------------------------
64
+ When a solve genuinely fails, ``write_outputs`` is skipped and the parquet
65
+ directory is **not** emptied, so a *previous* successful run's files can
66
+ linger. "outputs complete + nonzero exit" would then wrongly look like
67
+ the writer-crash case. So the nonzero-exit → success override is allowed
68
+ ONLY when ``started_at`` (the wall-clock time the calibrator launched the
69
+ subprocess) was supplied AND every required output is at least that new; a
70
+ stale file fails the completeness check, and a nonzero exit with no
71
+ ``started_at`` at all is treated as a **failure** (freshness unverifiable —
72
+ the override cannot be made safely). **C1 must always pass ``started_at``.**
73
+ The exit-0 / no-exit path does not need it: a successful ``write_outputs``
74
+ empties then rewrites the parquet dir, so its files are this run's product
75
+ by construction.
76
+
77
+ This module never solves and never touches the network: it is a pure
78
+ post-hoc reader of a solve's output directory.
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import logging
84
+ from dataclasses import dataclass, field
85
+ from datetime import datetime
86
+ from pathlib import Path
87
+ from typing import Sequence
88
+
89
+ logger = logging.getLogger(__name__)
90
+
91
+ # The calibrator's load-bearing signals: per-period node up-slack (unserved
92
+ # energy) and the discounted per-entity node cost table (its 'upward slack
93
+ # penalty' category is the monetised slack). Held as REGISTRY keys — the
94
+ # on-disk filenames are resolved through the parquet-bundle registry so a
95
+ # schema/rename breaks loudly *here* rather than silently missing a file.
96
+ # Only ``node_slack_up_d_e`` is a robust success gate: it is *dense* — one
97
+ # row per period for every balance node, emitted unconditionally
98
+ # (out_node.py, ``v.q_state_up`` clipped ≥0) — so a valid solve NEVER omits
99
+ # it, even at zero slack. ``cost_node_discounted_d_ec`` is deliberately NOT
100
+ # a default requirement: out_costs.py skips a node cost category with an
101
+ # ``if not pieces: continue`` guard, so a legitimate solve can omit that
102
+ # table → it would cause a false FAIL. The calibrator READS it for the
103
+ # penalty M€, but presence of the slack table is the success signal.
104
+ _DEFAULT_REQUIRED_KEYS: tuple[str, ...] = (
105
+ "node_slack_up_d_e",
106
+ )
107
+
108
+
109
+ def _registry_filename(key: str) -> str:
110
+ """Resolve a processed-output *key* to its on-disk parquet basename.
111
+
112
+ Validated against
113
+ :data:`flextool.process_outputs._output_meta.OUTPUT_TRANSFORM` — the
114
+ single-source registry of every processed output table name (the same
115
+ keys ``write_outputs`` uses when it writes ``<key>.parquet``). We use
116
+ this rather than
117
+ :data:`flextool.engine_polars._parquet_bundle.REGISTRY`, whose
118
+ processed-output coverage is documented as REPRESENTATIVE / incomplete
119
+ by design (``cost_node_discounted_d_ec`` is absent there). A key not in
120
+ the registry raises loudly here instead of silently looking for a file
121
+ that can never exist — so a schema rename that updates ``OUTPUT_TRANSFORM``
122
+ surfaces as a clear error at the calibrator boundary.
123
+ """
124
+ from flextool.process_outputs._output_meta import OUTPUT_TRANSFORM
125
+
126
+ if key not in OUTPUT_TRANSFORM:
127
+ raise KeyError(
128
+ f"{key!r} is not a registered FlexTool output "
129
+ "(flextool.process_outputs._output_meta.OUTPUT_TRANSFORM). "
130
+ "The calibrator's required-output default is stale; update "
131
+ "_DEFAULT_REQUIRED_KEYS or pass required_outputs explicitly."
132
+ )
133
+ return f"{key}.parquet"
134
+
135
+
136
+ def default_required_outputs() -> tuple[str, ...]:
137
+ """The default required-output filenames, resolved via the registry.
138
+
139
+ Returns the ``*.parquet`` basenames the calibrator minimally needs to
140
+ trust a solve: the node up-slack and the discounted node-cost table.
141
+ """
142
+ return tuple(_registry_filename(k) for k in _DEFAULT_REQUIRED_KEYS)
143
+
144
+
145
+ def _normalise_required(
146
+ required_outputs: Sequence[str] | None,
147
+ ) -> list[str]:
148
+ """Normalise the caller's required-output list to ``*.parquet`` basenames.
149
+
150
+ Accepts either REGISTRY keys (e.g. ``"node_slack_up_d_e"``) or explicit
151
+ filenames (e.g. ``"node_slack_up_d_e.parquet"``); a bare key is resolved
152
+ through the registry, a ``*.parquet`` name is taken verbatim. ``None``
153
+ yields :func:`default_required_outputs`.
154
+ """
155
+ if required_outputs is None:
156
+ return list(default_required_outputs())
157
+ resolved: list[str] = []
158
+ for item in required_outputs:
159
+ name = str(item)
160
+ if name.endswith(".parquet"):
161
+ resolved.append(name)
162
+ else:
163
+ resolved.append(_registry_filename(name))
164
+ return resolved
165
+
166
+
167
+ def _as_epoch(started_at: float | int | datetime | None) -> float | None:
168
+ """Coerce a ``started_at`` marker to a POSIX timestamp, or ``None``.
169
+
170
+ The recommended input (what C1 passes) is a plain epoch ``float`` from
171
+ :func:`time.time`, which compares directly against ``st_mtime``. A
172
+ tz-aware :class:`datetime` also compares correctly. A *naive* datetime
173
+ is interpreted as LOCAL time via :meth:`datetime.timestamp` — the same
174
+ convention ``st_mtime`` follows on POSIX — so it is safe (not silently
175
+ skewed); prefer the epoch float to avoid any ambiguity.
176
+ """
177
+ if started_at is None:
178
+ return None
179
+ if isinstance(started_at, datetime):
180
+ # ``.timestamp()`` assumes LOCAL time for a naive datetime, matching
181
+ # how ``st_mtime`` (also epoch) relates to local wall-clock; a
182
+ # tz-aware datetime converts exactly. No skew either way.
183
+ return started_at.timestamp()
184
+ return float(started_at)
185
+
186
+
187
+ @dataclass
188
+ class OutputCheck:
189
+ """Per-required-output evidence gathered from the output directory.
190
+
191
+ FlexTool persists no per-*sub-solve* status to disk (see the module
192
+ docstring), so this per-*output* record is the finest-grained on-disk
193
+ success evidence available. It is what :attr:`SolveOutcome.per_solve`
194
+ carries.
195
+ """
196
+
197
+ filename: str
198
+ present: bool
199
+ num_rows: int | None
200
+ fresh: bool
201
+ detail: str
202
+
203
+ @property
204
+ def ok(self) -> bool:
205
+ """Whether this output counts toward completeness.
206
+
207
+ Requires the file to be present, a readable parquet with at least
208
+ one row, and (subject to ``started_at``) fresh.
209
+ """
210
+ return self.present and (self.num_rows or 0) > 0 and self.fresh
211
+
212
+
213
+ @dataclass
214
+ class SolveOutcome:
215
+ """Verdict on a completed (or crashed) FlexTool solve run.
216
+
217
+ ``succeeded`` — whether the calibrator may consume this run's
218
+ results.
219
+ ``reason`` — human-readable justification for the verdict.
220
+ ``exit_code`` — the subprocess exit code, if the caller supplied
221
+ it (a weak input only).
222
+ ``per_solve`` — per-required-output evidence (:class:`OutputCheck`
223
+ list). FlexTool exposes no on-disk per-sub-solve
224
+ optimality status, so this is per-output, not
225
+ per-LP-subsolve.
226
+ ``outputs_complete`` — whether every required output was present,
227
+ non-empty and (if checked) fresh.
228
+ """
229
+
230
+ succeeded: bool
231
+ reason: str
232
+ exit_code: int | None
233
+ per_solve: list[OutputCheck] = field(default_factory=list)
234
+ outputs_complete: bool = False
235
+
236
+
237
+ def _parquet_num_rows(path: Path) -> int | None:
238
+ """Row count of a parquet file via footer metadata, or ``None``.
239
+
240
+ Reads only the parquet footer (no column data), so it is cheap even for
241
+ large tables. A missing/truncated/corrupt file (e.g. a partial write
242
+ from an interrupted run) returns ``None`` — the caller treats that as an
243
+ incomplete output, i.e. a failure signal, not a crash.
244
+ """
245
+ try:
246
+ import pyarrow.parquet as pq
247
+
248
+ return int(pq.ParquetFile(str(path)).metadata.num_rows)
249
+ except Exception as exc: # noqa: BLE001 - any read error ⇒ "not usable"
250
+ logger.debug("Could not read parquet row count for %s: %s", path, exc)
251
+ return None
252
+
253
+
254
+ def _check_output(
255
+ output_dir: Path, filename: str, started_epoch: float | None,
256
+ ) -> OutputCheck:
257
+ """Assess a single required output file inside *output_dir*."""
258
+ path = output_dir / filename
259
+ if not path.is_file():
260
+ return OutputCheck(
261
+ filename=filename,
262
+ present=False,
263
+ num_rows=None,
264
+ fresh=False,
265
+ detail="missing",
266
+ )
267
+
268
+ num_rows = _parquet_num_rows(path)
269
+ if num_rows is None:
270
+ return OutputCheck(
271
+ filename=filename,
272
+ present=True,
273
+ num_rows=None,
274
+ fresh=False,
275
+ detail="present but unreadable/corrupt as parquet",
276
+ )
277
+ if num_rows == 0:
278
+ # An empty table fails on the row count regardless of freshness;
279
+ # report ``fresh`` honestly (mtime vs started_at) rather than
280
+ # conflating "empty" with "stale".
281
+ empty_fresh = True
282
+ if started_epoch is not None:
283
+ try:
284
+ empty_fresh = path.stat().st_mtime + 1.0 >= started_epoch
285
+ except OSError:
286
+ empty_fresh = False
287
+ return OutputCheck(
288
+ filename=filename,
289
+ present=True,
290
+ num_rows=0,
291
+ fresh=empty_fresh,
292
+ detail="present but empty (0 rows)",
293
+ )
294
+
295
+ fresh = True
296
+ detail = "present, non-empty"
297
+ if started_epoch is not None:
298
+ try:
299
+ mtime = path.stat().st_mtime
300
+ except OSError as exc:
301
+ fresh = False
302
+ detail = f"present, non-empty, but mtime unavailable ({exc})"
303
+ else:
304
+ # 1s slack absorbs coarse filesystem mtime granularity so a file
305
+ # written in the same second the subprocess launched is not
306
+ # wrongly judged stale.
307
+ if mtime + 1.0 < started_epoch:
308
+ fresh = False
309
+ detail = (
310
+ "present, non-empty, but STALE "
311
+ "(older than this run's start — left over from a "
312
+ "previous run)"
313
+ )
314
+ else:
315
+ detail = "present, non-empty, fresh"
316
+
317
+ return OutputCheck(
318
+ filename=filename,
319
+ present=True,
320
+ num_rows=num_rows,
321
+ fresh=fresh,
322
+ detail=detail,
323
+ )
324
+
325
+
326
+ def assess_solve(
327
+ output_dir: Path | str,
328
+ *,
329
+ exit_code: int | None = None,
330
+ required_outputs: Sequence[str] | None = None,
331
+ started_at: float | int | datetime | None = None,
332
+ ) -> SolveOutcome:
333
+ """Decide whether a FlexTool solve run succeeded, from its outputs.
334
+
335
+ Parameters
336
+ ----------
337
+ output_dir:
338
+ The directory that directly holds the run's ``*.parquet`` result
339
+ files — i.e. ``<output_location>/output_parquet/<subdir>/``.
340
+ exit_code:
341
+ The subprocess exit code, if known. A *weak* input: it is a
342
+ warning that can be overridden (see the success rule below), never
343
+ the sole determinant. ``None`` means "not supplied".
344
+ required_outputs:
345
+ Output keys or ``*.parquet`` filenames that must be present and
346
+ non-empty for the run to count as successful. Defaults to the
347
+ calibrator's robust success gate, ``node_slack_up_d_e``
348
+ (:func:`default_required_outputs`); pass more if a caller wants
349
+ stricter completeness.
350
+ started_at:
351
+ POSIX timestamp (recommended: ``time.time()``) / :class:`datetime`
352
+ of when the subprocess was launched. A required output older than
353
+ this is treated as a stale leftover (not this run's product) and
354
+ fails completeness. **Required to make the nonzero-exit override
355
+ safe** — see the success rule. C1 must always pass it.
356
+
357
+ Returns
358
+ -------
359
+ SolveOutcome
360
+ The verdict, its reason, the exit code echoed back, the per-output
361
+ evidence, and the ``outputs_complete`` flag.
362
+
363
+ Success rule
364
+ ------------
365
+ Let ``complete`` = every required output present, a readable parquet
366
+ with ≥1 row, and (if ``started_at`` given) fresh.
367
+
368
+ * ``not complete`` → **failed**.
369
+ * ``complete`` and exit 0 / ``None`` → **succeeded** (``started_at``
370
+ optional: a successful run empties + rewrites the dir, so files are
371
+ fresh by construction).
372
+ * ``complete`` and nonzero exit and ``started_at`` given and fresh
373
+ → **succeeded**; the nonzero exit is *overridden* (post-solve writer
374
+ crash with complete, fresh results).
375
+ * ``complete`` and nonzero exit and NO ``started_at``
376
+ → **failed**; freshness is unverifiable, so a genuine failure that
377
+ left a prior run's outputs in place cannot be ruled out — the
378
+ override is refused.
379
+ """
380
+ out_dir = Path(output_dir)
381
+ started_epoch = _as_epoch(started_at)
382
+ required = _normalise_required(required_outputs)
383
+
384
+ checks = [_check_output(out_dir, fn, started_epoch) for fn in required]
385
+ outputs_complete = bool(checks) and all(c.ok for c in checks)
386
+
387
+ if not out_dir.is_dir():
388
+ return SolveOutcome(
389
+ succeeded=False,
390
+ reason=(
391
+ f"output directory {out_dir} does not exist; the solve wrote "
392
+ "no results (a genuinely failed / infeasible / unaccepted "
393
+ "solve skips output writing entirely)."
394
+ ),
395
+ exit_code=exit_code,
396
+ per_solve=checks,
397
+ outputs_complete=False,
398
+ )
399
+
400
+ if not outputs_complete:
401
+ bad = [c for c in checks if not c.ok]
402
+ detail = "; ".join(f"{c.filename}: {c.detail}" for c in bad)
403
+ return SolveOutcome(
404
+ succeeded=False,
405
+ reason=(
406
+ "required output(s) missing, empty or stale — the solve did "
407
+ f"not produce usable results [{detail}]. A genuinely failed "
408
+ "sub-solve is surfaced this way: FlexTool skips output "
409
+ "writing on a non-accepted cascade, so absent outputs ARE "
410
+ "the failure signal."
411
+ ),
412
+ exit_code=exit_code,
413
+ per_solve=checks,
414
+ outputs_complete=False,
415
+ )
416
+
417
+ # Every required output is present, non-empty and (subject to
418
+ # started_at) fresh. Because FlexTool writes outputs only for an
419
+ # accepted cascade, this state means no failed/unaccepted sub-solve is
420
+ # detectable.
421
+ #
422
+ # Exit 0 / None: succeed. On success write_outputs empties then
423
+ # rewrites the parquet dir, so the files are this run's product by
424
+ # construction — no stale-masking hole, and started_at is optional here.
425
+ if exit_code in (None, 0):
426
+ fresh_note = (
427
+ " (verified fresh against this run's start time)"
428
+ if started_epoch is not None
429
+ else ""
430
+ )
431
+ return SolveOutcome(
432
+ succeeded=True,
433
+ reason=(
434
+ "all required outputs present and non-empty"
435
+ f"{fresh_note}; "
436
+ + ("exit code 0." if exit_code == 0 else "no exit code "
437
+ "supplied.")
438
+ ),
439
+ exit_code=exit_code,
440
+ per_solve=checks,
441
+ outputs_complete=True,
442
+ )
443
+
444
+ # Nonzero exit but complete outputs. This is EITHER the known
445
+ # post-solve writer-crash case (the cascade solved and wrote complete
446
+ # results; the nonzero exit is a writer failure) OR a genuinely failed
447
+ # solve that skipped write_outputs — which does NOT empty the parquet
448
+ # dir — leaving a PRIOR iteration's complete outputs lingering. The
449
+ # only thing that tells these apart is freshness. So the override to
450
+ # success is allowed ONLY when started_at was supplied AND every output
451
+ # verified fresh; otherwise we must NOT override.
452
+ if started_epoch is None:
453
+ return SolveOutcome(
454
+ succeeded=False,
455
+ reason=(
456
+ f"nonzero exit code {exit_code} and freshness is unverifiable "
457
+ "(no started_at supplied) — the required outputs are complete "
458
+ "but we CANNOT distinguish a post-solve writer crash (which "
459
+ "leaves this run's fresh results) from a genuinely failed "
460
+ "solve that skipped output writing and left a PRIOR run's "
461
+ "outputs in place. Pass started_at (the subprocess launch "
462
+ "time) so the override can be made safely."
463
+ ),
464
+ exit_code=exit_code,
465
+ per_solve=checks,
466
+ outputs_complete=True,
467
+ )
468
+
469
+ # started_at supplied and all outputs are fresh → safe to override the
470
+ # nonzero exit to success (the post-solve writer-crash case, e.g. the
471
+ # PLEXOS→FlexTool writer's "Shared-alternative write failed" KeyError,
472
+ # which lives outside this engine).
473
+ return SolveOutcome(
474
+ succeeded=True,
475
+ reason=(
476
+ "all required outputs present, non-empty and verified fresh "
477
+ f"against this run's start time; the nonzero exit code {exit_code} "
478
+ "is OVERRIDDEN to success — the cascade solved and wrote complete, "
479
+ "fresh results, and the nonzero exit reflects a post-solve writer "
480
+ "failure (e.g. the model-specific 'Shared-alternative write "
481
+ "failed' KeyError in the PLEXOS→FlexTool writer, which is outside "
482
+ "the solve engine), not a solve failure."
483
+ ),
484
+ exit_code=exit_code,
485
+ per_solve=checks,
486
+ outputs_complete=True,
487
+ )
488
+
489
+
490
+ __all__ = [
491
+ "OutputCheck",
492
+ "SolveOutcome",
493
+ "assess_solve",
494
+ "default_required_outputs",
495
+ ]
@@ -0,0 +1,9 @@
1
+ """CLI entry points for FlexTool. Available commands:
2
+ - flextool / run_flextool: Run model optimization for a scenario
3
+ - write_outputs: Process and write solver outputs
4
+ - scenario_results: Cross-scenario comparison analysis
5
+ - read_tabular_input: Import CSV/Excel data to Spine database
6
+ - execute_flextool_workflow: Full workflow orchestration
7
+ - update_flextool: Update FlexTool from GitHub
8
+ - migrate_database: Migrate database to latest schema
9
+ """
@@ -0,0 +1,51 @@
1
+ """Shared ``__main__`` shim for FlexTool CLI Tools.
2
+
3
+ Spine Toolbox's *Basic Console* does not run a Python Tool as a separate
4
+ ``python script.py`` process. It exec's the Tool's file inside a persistent
5
+ ``python -i`` REPL (``sys.flags.interactive`` is set). A ``sys.exit()``
6
+ there — whether at the end of the program or raised deep inside ``main()`` —
7
+ raises ``SystemExit``, which TERMINATES that REPL; Toolbox then pings the dead
8
+ process and reports a spurious ``Kernel died (×_×)``. The Basic Console
9
+ decides success/failure from an *uncaught exception*, not the process exit
10
+ code.
11
+
12
+ ``run_tool`` reconciles both runtimes. It invokes the Tool's entry point and
13
+ swallows the resulting ``SystemExit``:
14
+
15
+ * Under ``-i`` (Basic Console) it returns quietly on a zero/``None`` code and
16
+ re-raises a ``RuntimeError`` on a non-zero one, so Toolbox marks the Tool
17
+ failed while the REPL stays alive — no "Kernel died".
18
+ * As a standalone CLI (``sys.flags.interactive == 0``) it preserves normal
19
+ shell exit-code semantics (re-raises the original ``SystemExit`` / exits with
20
+ the entry point's return value).
21
+
22
+ Use it at a Tool's ``__main__`` boundary::
23
+
24
+ if __name__ == "__main__":
25
+ run_tool(main)
26
+ """
27
+ import sys
28
+
29
+
30
+ def run_tool(entry):
31
+ """Run ``entry`` (a zero-arg callable) reconciling CLI vs Basic Console.
32
+
33
+ See the module docstring for the rationale.
34
+ """
35
+ try:
36
+ rc = entry()
37
+ except SystemExit as exc:
38
+ if not sys.flags.interactive:
39
+ raise # standalone CLI: let the original exit code propagate
40
+ code = exc.code
41
+ rc = 0 if code is None else code
42
+ if sys.flags.interactive:
43
+ # Basic Console: NEVER sys.exit() — it would kill the persistent REPL.
44
+ # Signal failure via an exception (Toolbox catches it); succeed quietly.
45
+ if isinstance(rc, int):
46
+ if rc != 0:
47
+ raise RuntimeError(f"FlexTool Tool failed (exit code {rc}).")
48
+ elif rc is not None: # e.g. sys.exit("some message")
49
+ raise RuntimeError(f"FlexTool Tool failed: {rc}")
50
+ return rc
51
+ sys.exit(rc)