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,1715 @@
1
+ """Thread-safe execution manager for running FlexTool scenarios as subprocesses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime
15
+ from enum import Enum, auto
16
+ from pathlib import Path
17
+ from typing import Callable
18
+
19
+ import psutil
20
+
21
+ from flextool.gui.cli_format import format_cmd_for_log
22
+ from flextool.gui.data_models import GlobalSettings, ProjectSettings, ScenarioInfo
23
+ from flextool.gui.scenario_key import choose_output_subdir_for_write
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ _slice_probe_cache: dict[str, bool] = {}
29
+
30
+
31
+ def _slice_exists(name: str) -> bool:
32
+ """Return True if a user-level systemd slice with this name is loaded.
33
+
34
+ Result is cached for the process lifetime so repeated job spawns don't
35
+ re-probe. Probe failures (missing systemctl, timeout) cache as False.
36
+ """
37
+ if name in _slice_probe_cache:
38
+ return _slice_probe_cache[name]
39
+ loaded = False
40
+ try:
41
+ result = subprocess.run(
42
+ ["systemctl", "--user", "show", name, "--property=LoadState"],
43
+ capture_output=True, text=True, timeout=2.0, check=False,
44
+ )
45
+ loaded = "LoadState=loaded" in result.stdout
46
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
47
+ loaded = False
48
+ _slice_probe_cache[name] = loaded
49
+ if not loaded:
50
+ logger.warning(
51
+ "FLEXTOOL_SLICE=%s requested but slice is not loaded; "
52
+ "running without slice isolation.", name,
53
+ )
54
+ return loaded
55
+
56
+
57
+ def _wrap_linux(
58
+ cmd: list[str],
59
+ estimate_gb: float,
60
+ ) -> tuple[list[str], dict, Callable[[subprocess.Popen], object | None] | None]:
61
+ """Wrap via systemd-run user scope for slice isolation; no memory cap.
62
+
63
+ MemoryHigh throttles allocations (stalls the process) and MemoryMax kills
64
+ it — both interfere with large polars collect_all calls. The watchdog is
65
+ the sole memory enforcer. systemd-run is still used so the process can be
66
+ placed in a cgroup slice (FLEXTOOL_SLICE) for scheduling isolation.
67
+ """
68
+ if not shutil.which("systemd-run"):
69
+ return cmd, {}, None
70
+ wrapped = ["systemd-run", "--user", "--scope", "--quiet"]
71
+ slice_name = os.environ.get("FLEXTOOL_SLICE")
72
+ if slice_name and _slice_exists(slice_name):
73
+ wrapped.append(f"--slice={slice_name}")
74
+ wrapped.append("--")
75
+ wrapped.extend(cmd)
76
+ return wrapped, {}, None
77
+
78
+
79
+ def _wrap_windows(
80
+ cmd: list[str],
81
+ estimate_gb: float,
82
+ ) -> tuple[list[str], dict, Callable[[subprocess.Popen], object | None] | None]:
83
+ """No OS-level memory cap on Windows — MemoryWatchdog is the sole enforcer."""
84
+ return cmd, {}, None
85
+
86
+
87
+ def _wrap_macos(
88
+ cmd: list[str],
89
+ estimate_gb: float,
90
+ ) -> tuple[list[str], dict, Callable[[subprocess.Popen], object | None] | None]:
91
+ """No OS-level memory cap on macOS — MemoryWatchdog is the sole enforcer."""
92
+ return cmd, {}, None
93
+
94
+
95
+ def _wrap_for_memory_cap(
96
+ cmd: list[str],
97
+ estimate_gb: float,
98
+ ) -> tuple[list[str], dict, Callable[[subprocess.Popen], object | None] | None]:
99
+ """Optionally wrap a command with a soft memory hint and return Popen extras.
100
+
101
+ Returns a triple ``(wrapped_cmd, popen_extras, post_spawn)``:
102
+
103
+ * ``wrapped_cmd`` — argv to hand to ``subprocess.Popen``.
104
+ * ``popen_extras`` — kwargs to merge into the ``Popen`` call.
105
+ * ``post_spawn`` — optional callable invoked with the spawned ``Popen``;
106
+ its return value MUST be retained by the caller until the child exits.
107
+
108
+ Per-platform mechanism:
109
+
110
+ * Linux: transient ``systemd-run --user --scope`` for cgroup slice
111
+ isolation only (``FLEXTOOL_SLICE``). No memory property is set —
112
+ ``MemoryHigh`` throttles allocations and ``MemoryMax`` kills the
113
+ process, both of which stall or abort large solver runs.
114
+ * Windows / macOS: no OS-level wrapping.
115
+
116
+ ``MemoryWatchdog`` is the sole memory enforcer on all platforms.
117
+ """
118
+ if estimate_gb < 0:
119
+ estimate_gb = 0
120
+ if sys.platform.startswith("linux"):
121
+ return _wrap_linux(cmd, estimate_gb)
122
+ if sys.platform == "win32":
123
+ return _wrap_windows(cmd, estimate_gb)
124
+ if sys.platform == "darwin":
125
+ return _wrap_macos(cmd, estimate_gb)
126
+ return cmd, {}, None
127
+
128
+
129
+ # --- Solver "warmed" detection ---------------------------------------------
130
+ # A scenario job is "warming" from dispatch until its solver has loaded the
131
+ # matrix and begun reserving its working memory — the high-water point at
132
+ # which its measured RSS is a reliable proxy for its real footprint. Until
133
+ # then admission only has an *estimate* to go on (see ExecutionJob.footprint_
134
+ # solid and ExecutionManager admission).
135
+ #
136
+ # These strings are matched case-insensitively as substrings of the job's
137
+ # stdout. NONE is load-bearing on its own: the watchdog's RSS-plateau detector
138
+ # (see MemoryWatchdog._loop) is the wording-proof, log-level-proof backstop, so
139
+ # a HiGHS release that renames any of them — or a quiet ``--solver-log-level``
140
+ # that suppresses the block entirely — degrades to a ~10s-later flip rather
141
+ # than breaking. ``tests/gui/test_warm_markers.py`` pins them so silent drift
142
+ # turns a test red.
143
+ _SOLVER_WARM_MARKERS: tuple[str, ...] = (
144
+ "coefficient ranges", # HiGHS load-time statistics block
145
+ "running highs", # HiGHS startup banner
146
+ "presolving model", # HiGHS presolve phase
147
+ "solving lp", # HiGHS LP solve phase
148
+ "solving mip", # HiGHS MIP solve phase
149
+ "matrix built by polar-high", # our own engine phase checkpoint
150
+ )
151
+
152
+
153
+ def _is_solver_warm_marker(line: str) -> bool:
154
+ """True if *line* shows the solver has begun optimising (memory reserved)."""
155
+ low = line.lower()
156
+ return any(m in low for m in _SOLVER_WARM_MARKERS)
157
+
158
+
159
+ # RSS-plateau backstop (used when no marker is seen — e.g. quiet solver log or
160
+ # suppressed phase checkpoints on within-group rolling iters):
161
+ # * consider a job warmed once its RSS grows < WARM_PLATEAU_RATIO between two
162
+ # consecutive polls, for WARM_PLATEAU_POLLS polls in a row, and it has
163
+ # allocated at least WARM_MIN_RSS_MB (HiGHS has actually started);
164
+ # * WARM_TIMEOUT_S is the last-resort anti-deadlock cap.
165
+ WARM_PLATEAU_RATIO = 1.03
166
+ WARM_PLATEAU_POLLS = 2
167
+ WARM_MIN_RSS_MB = 200.0
168
+ WARM_TIMEOUT_S = 180.0
169
+
170
+
171
+ class MemoryWatchdog:
172
+ """Polls running scenario jobs every 5s; tracks peak RSS and enforces
173
+ global memory/swap thresholds by killing the job most over its
174
+ budget."""
175
+
176
+ POLL_INTERVAL_S = 5.0
177
+
178
+ def __init__(self, manager: "ExecutionManager") -> None:
179
+ self._manager = manager
180
+ self._stop = threading.Event()
181
+ self._thread: threading.Thread | None = None
182
+ # Captured at start(): swap usage attributable to other processes
183
+ # (kswapd cache of inactive pages, other apps, etc.). Only swap
184
+ # GROWTH past this baseline counts toward swap_allowance_gb.
185
+ self._baseline_swap_used: int = 0
186
+
187
+ def start(self) -> None:
188
+ if self._thread is not None and self._thread.is_alive():
189
+ return
190
+ try:
191
+ self._baseline_swap_used = psutil.swap_memory().used
192
+ except Exception:
193
+ self._baseline_swap_used = 0
194
+ self._stop.clear()
195
+ self._thread = threading.Thread(target=self._loop, daemon=True)
196
+ self._thread.start()
197
+
198
+ def stop(self) -> None:
199
+ self._stop.set()
200
+
201
+ def _maybe_mark_warmed(self, job: "ExecutionJob", rss_mb: float) -> None:
202
+ """Backstop warmed-detection from measured RSS. Holds ``_manager._lock``.
203
+
204
+ Flips ``footprint_solid`` once the job's RSS has plateaued (grew less
205
+ than ``WARM_PLATEAU_RATIO`` for ``WARM_PLATEAU_POLLS`` consecutive
206
+ polls, having allocated at least ``WARM_MIN_RSS_MB``) or a hard
207
+ ``WARM_TIMEOUT_S`` has elapsed. The stdout warm-marker (see
208
+ ``_run_job``) usually flips it sooner; this covers quiet solver logs
209
+ and suppressed phase checkpoints so the auto-stagger never deadlocks.
210
+ """
211
+ if job.footprint_solid:
212
+ return
213
+ if job.start_time is not None:
214
+ elapsed = (datetime.now() - job.start_time).total_seconds()
215
+ if elapsed > WARM_TIMEOUT_S:
216
+ job.footprint_solid = True
217
+ return
218
+ prev = job._warm_last_rss_mb
219
+ if rss_mb >= WARM_MIN_RSS_MB and prev > 0 and rss_mb <= prev * WARM_PLATEAU_RATIO:
220
+ job._warm_stable_polls += 1
221
+ else:
222
+ job._warm_stable_polls = 0
223
+ job._warm_last_rss_mb = rss_mb
224
+ if job._warm_stable_polls >= WARM_PLATEAU_POLLS:
225
+ job.footprint_solid = True
226
+
227
+ def _loop(self) -> None:
228
+ while not self._stop.wait(self.POLL_INTERVAL_S):
229
+ with self._manager._lock:
230
+ snapshot = [
231
+ (j, j.process, j.memory_cap_gb, j.watchdog_exempt)
232
+ for j in self._manager._jobs
233
+ if j.status == JobStatus.RUNNING and j.process is not None
234
+ ]
235
+
236
+ measured: list[tuple[ExecutionJob, int, int]] = []
237
+ for job, proc, est_gb, exempt in snapshot:
238
+ if proc.poll() is not None:
239
+ continue
240
+ try:
241
+ parent = psutil.Process(proc.pid)
242
+ rss = parent.memory_info().rss
243
+ for child in parent.children(recursive=True):
244
+ try:
245
+ rss += child.memory_info().rss
246
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
247
+ pass
248
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
249
+ continue
250
+ rss_mb = rss / (1024 ** 2)
251
+ with self._manager._lock:
252
+ if rss_mb > job.peak_rss_mb:
253
+ job.peak_rss_mb = rss_mb
254
+ self._maybe_mark_warmed(job, rss_mb)
255
+ # Exempt jobs (calibrator / RP aux jobs) still have their peak
256
+ # RSS tracked above, but are never eligible to be killed: keep
257
+ # them out of the victim-selection candidate list.
258
+ if exempt:
259
+ continue
260
+ est_bytes = int(est_gb * (1024 ** 3))
261
+ measured.append((job, rss, est_bytes))
262
+
263
+ if not measured:
264
+ continue
265
+
266
+ # Peaks above are always tracked (they feed the learned budget),
267
+ # but with the memory guard switched off we never kill: the user
268
+ # has accepted the OOM risk for this session. Admission still
269
+ # honours the reserve, so this only disables killing.
270
+ if not self._manager._memory_guard_enabled:
271
+ continue
272
+
273
+ limits = self._manager.execution_limits
274
+ reserve_bytes = int(limits.system_reserve_gb * (1024 ** 3))
275
+ swap_allow_bytes = int(limits.swap_allowance_gb * (1024 ** 3))
276
+
277
+ try:
278
+ vm = psutil.virtual_memory()
279
+ sm = psutil.swap_memory()
280
+ except Exception:
281
+ continue
282
+
283
+ # Both thresholds are safety nets, not independent triggers.
284
+ # We only kill when BOTH are exhausted: the system is short
285
+ # on free RAM AND swap growth has reached the allowance.
286
+ # Plenty of free RAM ⇒ never kill, regardless of swap growth
287
+ # (Linux's kswapd may proactively swap inactive pages even
288
+ # when RAM is plentiful, which is harmless).
289
+ reason = ""
290
+ if vm.available < reserve_bytes:
291
+ swap_growth = max(0, sm.used - self._baseline_swap_used)
292
+ if swap_growth > swap_allow_bytes:
293
+ reason = (
294
+ f"system out of memory: {vm.available / 1024**3:.1f} GB free "
295
+ f"(reserve {limits.system_reserve_gb:.1f} GB) and swap grew "
296
+ f"{swap_growth / 1024**3:.1f} GB since FlexTool started "
297
+ f"> allowance {limits.swap_allowance_gb:.1f} GB"
298
+ )
299
+
300
+ if not reason:
301
+ continue
302
+
303
+ victim, victim_rss, victim_est = max(
304
+ measured, key=lambda t: t[1] - t[2]
305
+ )
306
+ overage_gb = (victim_rss - victim_est) / 1024**3
307
+ logger.warning(
308
+ "Killing job %d (%s): %s. RSS %.1f GB, estimate %.1f GB (overage %+.1f GB).",
309
+ victim.job_id,
310
+ victim.scenario_name or victim.display_name,
311
+ reason,
312
+ victim_rss / 1024**3,
313
+ victim_est / 1024**3,
314
+ overage_gb,
315
+ )
316
+ with self._manager._lock:
317
+ victim.killed_for_memory = True
318
+ victim.kill_reason = reason
319
+ try:
320
+ vproc = victim.process
321
+ if vproc is not None:
322
+ vproc.kill()
323
+ except OSError:
324
+ pass
325
+
326
+
327
+ class JobStatus(Enum):
328
+ PENDING = auto()
329
+ RUNNING = auto()
330
+ SUCCESS = auto()
331
+ FAILED = auto()
332
+ KILLED = auto()
333
+
334
+
335
+ class JobType(Enum):
336
+ """Distinguishes different kinds of jobs in the execution list."""
337
+
338
+ SCENARIO = auto() # FlexTool scenario run
339
+ CONVERSION = auto() # xlsx → sqlite pre-conversion
340
+ OUTPUT_ACTION = auto() # output generation (plots, Excel, CSV, comparison)
341
+ OLD_CONVERT = auto() # old FlexTool 2.0 import
342
+ MIGRATION = auto() # database schema / data version migration
343
+ UPDATE = auto() # FlexTool self-update (git pull / pip upgrade)
344
+ DB_EDITOR = auto() # Spine DB Editor launch (recorded only on failure)
345
+ ENV_REPAIR = auto() # native-library compatibility fix (polars-lts-cpu swap)
346
+
347
+
348
+ @dataclass
349
+ class ExecutionJob:
350
+ """Represents a single job in the execution list.
351
+
352
+ For scenario runs, the scenario-specific fields (``scenario_name``,
353
+ ``source_name``, etc.) are populated. For auxiliary jobs (conversions,
354
+ output actions) only ``display_name`` and ``action_key`` are used.
355
+ """
356
+
357
+ job_id: int
358
+ job_type: JobType = JobType.SCENARIO
359
+ display_name: str = "" # Shown in the tree for non-scenario jobs
360
+ action_key: str = "" # For replacement: same key → old entry removed
361
+ # --- Scenario-specific fields ---
362
+ scenario_name: str = ""
363
+ source_name: str = ""
364
+ source_number: int = 0
365
+ output_subdir: str = "" # on-disk folder under output_parquet/
366
+ input_db_url: str = "" # sqlite:///path/to/input.sqlite
367
+ is_xlsx_source: bool = False
368
+ xlsx_path: Path | None = None
369
+ # --- Common fields ---
370
+ status: JobStatus = JobStatus.PENDING
371
+ stdout_lines: list[str] = field(default_factory=list)
372
+ start_time: datetime | None = None
373
+ end_time: datetime | None = None
374
+ process: subprocess.Popen | None = None
375
+ finish_timestamp: str = "" # DD.MM.YY hh:mm format
376
+ memory_cap_gb: float = 0.0 # snapshot of estimate at dispatch (0 = none)
377
+ peak_rss_mb: float = 0.0 # high-water mark, fed by MemoryWatchdog
378
+ # ``footprint_solid`` flips True once the solver has begun optimising — by a
379
+ # stdout warm-marker (fast path) or the watchdog's RSS-plateau/timeout
380
+ # backstop. Until then the job is "warming": admission only has its
381
+ # estimate (``memory_cap_gb``) to reserve against; afterwards its measured
382
+ # RSS (already reflected in ``vm.available``) is the truth. See admission.
383
+ footprint_solid: bool = False
384
+ _warm_stable_polls: int = 0 # consecutive flat-RSS watchdog polls
385
+ _warm_last_rss_mb: float = 0.0 # RSS at the previous watchdog poll
386
+ killed_for_memory: bool = False # set by watchdog just before SIGTERM
387
+ kill_reason: str = "" # populated by watchdog: "exceeded estimate" / "global memory pressure" / "global swap pressure"
388
+ force_start: bool = False # user override: admit despite the memory-reserve check (watchdog still applies)
389
+ # ``watchdog_exempt`` removes a RUNNING job from the watchdog's kill-victim
390
+ # selection. Long calibrator / RP auxiliary jobs run with no reliable
391
+ # ``memory_cap_gb`` estimate (0), so their ``rss - estimate`` overage would
392
+ # equal their whole RSS and make them the automatic victim under global
393
+ # memory pressure — killed mid-run through no fault of their own. Their peak
394
+ # RSS is still tracked; they are only excluded from being chosen to kill.
395
+ # Set by ``flextool/gui/calibrate_jobs.py``. See MemoryWatchdog._loop.
396
+ watchdog_exempt: bool = False
397
+ native_fault: bool = False # set on finalize when the process died from a native crash (segfault / illegal instruction)
398
+ # Opaque per-platform handle returned by `_wrap_for_memory_cap` post_spawn
399
+ # (e.g. Windows Job Object). Must outlive the Popen, else the cap is
400
+ # released when the last handle closes. None on platforms that don't need it.
401
+ memory_cap_handle: object | None = None
402
+
403
+
404
+ class ExecutionManager:
405
+ """Thread-safe manager for parallel FlexTool scenario execution."""
406
+
407
+ def __init__(
408
+ self,
409
+ project_path: Path,
410
+ settings: ProjectSettings,
411
+ on_status_change: Callable[[ExecutionJob], None] | None = None,
412
+ on_all_finished: Callable[[], None] | None = None,
413
+ global_settings: GlobalSettings | None = None,
414
+ ):
415
+ """
416
+ Args:
417
+ project_path: Path to the project directory.
418
+ settings: Project settings (auto-generate flags, plot settings).
419
+ on_status_change: Callback when a job's status changes. Called from
420
+ a worker thread -- the caller should use ``root.after()`` to
421
+ safely update tkinter.
422
+ on_all_finished: Callback when all jobs are done (for post-execution
423
+ hooks like comparison).
424
+ global_settings: Optional global settings (for execution limits).
425
+ """
426
+ self._lock = threading.Lock()
427
+ # Separate lock for settings.yaml saves so worker threads don't
428
+ # serialize on the manager lock during file I/O.
429
+ self._settings_save_lock = threading.Lock()
430
+ self._jobs: list[ExecutionJob] = []
431
+ self._next_id = 0
432
+ # Seed max_workers: prefer the per-project value, fall back to the
433
+ # legacy global preference, finally to the dataclass default of 1.
434
+ if settings.max_workers > 0:
435
+ self._max_workers = max(1, settings.max_workers)
436
+ elif global_settings is not None and global_settings.max_workers > 0:
437
+ self._max_workers = max(1, global_settings.max_workers)
438
+ else:
439
+ self._max_workers = 1
440
+ self._running_count = 0
441
+ self._wind_down = False
442
+ self._stopped = False
443
+ self._memory_limited: bool = False # set by scheduler when admission blocked by RAM
444
+ self._thread_limited: bool = False # set by scheduler when running == max_workers and pending exist
445
+ # Memory-guard switch (session-only, never persisted). When False the
446
+ # watchdog stops KILLING jobs; it does not change how many jobs start
447
+ # (the reserve still limits admission — ``force_start`` is the
448
+ # deliberate per-job override). So only Allow swap (a watchdog-only
449
+ # threshold) becomes non-binding. Defaults on; re-disable each session.
450
+ self._memory_guard_enabled: bool = True
451
+ self.project_path = project_path
452
+ self.settings = settings
453
+ self._global_settings = global_settings
454
+ self._on_status_change = on_status_change
455
+ self._on_all_finished = on_all_finished
456
+ self._scheduler_thread: threading.Thread | None = None
457
+ self._comparison_scenarios: list[str] = []
458
+ self._pending_select_job_id: int | None = None # for auto-selecting new jobs in UI
459
+
460
+ self._converted_xlsx: set[str] = set() # source_names already converted
461
+ self._watchdog: MemoryWatchdog | None = None
462
+
463
+ # Register cleanup for when the Python process exits
464
+ atexit.register(self.cleanup)
465
+
466
+ @property
467
+ def execution_limits(self):
468
+ """Return the active ExecutionLimits from ProjectSettings."""
469
+ from flextool.gui.data_models import ExecutionLimits
470
+ proj = getattr(self.settings, "execution_limits", None)
471
+ return proj if proj is not None else ExecutionLimits()
472
+
473
+ def set_global_settings(self, gs: GlobalSettings) -> None:
474
+ self._global_settings = gs
475
+
476
+ @property
477
+ def memory_guard_enabled(self) -> bool:
478
+ """Whether the watchdog may kill jobs under memory pressure.
479
+
480
+ Session-only; not persisted to settings.yaml. When False the
481
+ watchdog still tracks peak RSS but never kills — the OS is the only
482
+ backstop. Admission (how many jobs start) is unaffected: the reserve
483
+ still limits it, and ``force_start`` is the per-job override.
484
+ """
485
+ return self._memory_guard_enabled
486
+
487
+ @memory_guard_enabled.setter
488
+ def memory_guard_enabled(self, value: bool) -> None:
489
+ with self._lock:
490
+ self._memory_guard_enabled = bool(value)
491
+
492
+ def _compute_memory_budget_for_job(
493
+ self, job: ExecutionJob | None = None
494
+ ) -> tuple[float, str]:
495
+ """Return ``(per_job_budget_gb, source)``. Budget 0 means none set.
496
+
497
+ Resolution order (``source`` in parentheses):
498
+ 1. ``"explicit"`` — user-set ``ExecutionLimits.memory_cap_per_job_gb``
499
+ when > 0. A *soft* target, not a guarantee.
500
+ 2. ``"history"`` — learned peak from a prior successful run in
501
+ ``ProjectSettings.scenario_resource_history``, ×1.05 for safety.
502
+ 3. ``"auto"`` — fair-share fallback ``(system_total - reserve) /
503
+ max_workers``.
504
+
505
+ The ``source`` drives admission (see ``_memory_admits``): ``explicit``
506
+ and ``history`` are solid enough to admit a batch up front, whereas
507
+ ``auto`` is an unreliable guess that must be staggered one job at a
508
+ time until its real footprint is observed.
509
+ """
510
+ limits = self.execution_limits
511
+ if limits.memory_cap_per_job_gb > 0:
512
+ return limits.memory_cap_per_job_gb, "explicit"
513
+ if job is not None and job.output_subdir:
514
+ history = self.settings.scenario_resource_history.get(job.output_subdir)
515
+ if history is not None and history.peak_rss_mb > 0:
516
+ return (history.peak_rss_mb / 1024.0) * 1.05, "history"
517
+ try:
518
+ total_gb = psutil.virtual_memory().total / (1024 ** 3)
519
+ except Exception:
520
+ return 0.0, "auto"
521
+ available = max(0.0, total_gb - limits.system_reserve_gb)
522
+ workers = max(1, self._max_workers)
523
+ return available / workers, "auto"
524
+
525
+ def _job_warming(self, job: ExecutionJob) -> bool:
526
+ """A RUNNING scenario job whose solver hasn't begun optimising yet —
527
+ its real memory footprint is still unknown. Holds ``self._lock``."""
528
+ return (
529
+ job.job_type == JobType.SCENARIO
530
+ and job.status == JobStatus.RUNNING
531
+ and not job.footprint_solid
532
+ )
533
+
534
+ def _predicted_available_gb(self, vm_available_gb: float) -> float:
535
+ """Free RAM debited by the *unrealised* growth of still-warming jobs.
536
+
537
+ ``vm.available`` already reflects every running job's RSS so far. A
538
+ warming job will keep growing toward its estimate, so we reserve the
539
+ shortfall ``max(0, estimate - measured_rss)``. Warmed jobs are fully
540
+ reflected in ``vm.available`` and reserve nothing — their measured RSS
541
+ is the truth, not the estimate. Holds ``self._lock``.
542
+ """
543
+ reserved = 0.0
544
+ for j in self._jobs:
545
+ if not self._job_warming(j) or j.memory_cap_gb <= 0:
546
+ continue
547
+ measured_gb = j.peak_rss_mb / 1024.0
548
+ reserved += max(0.0, j.memory_cap_gb - measured_gb)
549
+ return vm_available_gb - reserved
550
+
551
+ def _memory_admits(
552
+ self, candidate: ExecutionJob, source: str, estimate_gb: float
553
+ ) -> bool:
554
+ """Decide whether live free RAM allows admitting *candidate*.
555
+
556
+ Must hold ``self._lock``; ``_running_count`` is > 0 here (the first job
557
+ is admitted unconditionally upstream). Admission keys off actual
558
+ ``vm.available``, re-measured every tick, not a static reservation
559
+ against total RAM — so freed memory lets more jobs start.
560
+
561
+ * ``auto`` source (no cap, no history): the estimate is an unreliable
562
+ fair-share guess, so *stagger* — refuse while any job is still
563
+ warming, to observe one job's real footprint before starting the
564
+ next unknown.
565
+ * ``explicit`` / ``history`` source: the estimate is solid, so budget
566
+ directly against predicted-available RAM with no staggering; a burst
567
+ of pending jobs is admitted up to the point one no longer fits.
568
+ """
569
+ if estimate_gb <= 0:
570
+ return True # no estimate; let the watchdog handle it
571
+ if source == "auto" and any(self._job_warming(j) for j in self._jobs):
572
+ return False
573
+ try:
574
+ vm_available_gb = psutil.virtual_memory().available / (1024 ** 3)
575
+ except Exception:
576
+ return True
577
+ limits = self.execution_limits
578
+ predicted = self._predicted_available_gb(vm_available_gb)
579
+ return predicted - estimate_gb >= limits.system_reserve_gb
580
+
581
+ def _record_scenario_peak(self, job: ExecutionJob, runtime_s: float) -> None:
582
+ """Persist this job's peak RSS into the project settings history.
583
+
584
+ Called once per successful scenario run. Auxiliary jobs are skipped.
585
+ """
586
+ if job.job_type != JobType.SCENARIO:
587
+ return
588
+ if not job.output_subdir or job.peak_rss_mb <= 0:
589
+ return
590
+ from flextool.gui.data_models import ScenarioRun
591
+ record = ScenarioRun(
592
+ peak_rss_mb=float(job.peak_rss_mb),
593
+ runtime_s=float(runtime_s),
594
+ last_run=datetime.now().isoformat(timespec="seconds"),
595
+ )
596
+ with self._lock:
597
+ self.settings.scenario_resource_history[job.output_subdir] = record
598
+ with self._settings_save_lock:
599
+ try:
600
+ from flextool.gui.settings_io import save_project_settings
601
+ save_project_settings(self.project_path, self.settings)
602
+ except Exception:
603
+ logger.warning(
604
+ "Could not persist scenario_resource_history for '%s'",
605
+ job.output_subdir, exc_info=True,
606
+ )
607
+
608
+ # ------------------------------------------------------------------
609
+ # max_workers property
610
+ # ------------------------------------------------------------------
611
+
612
+ @property
613
+ def max_workers(self) -> int:
614
+ return self._max_workers
615
+
616
+ @max_workers.setter
617
+ def max_workers(self, value: int) -> None:
618
+ self._max_workers = max(1, value)
619
+
620
+ def _resolve_source_path(self, source_name: str) -> Path:
621
+ """Return the absolute path to a source file, honouring external refs."""
622
+ rel = self.settings.external_refs.get(source_name)
623
+ if rel is not None:
624
+ return (self.project_path / rel).resolve()
625
+ return self.project_path / "input_sources" / source_name
626
+
627
+ # ------------------------------------------------------------------
628
+ # Job management
629
+ # ------------------------------------------------------------------
630
+
631
+ def add_jobs(self, scenarios: list[ScenarioInfo]) -> list[ExecutionJob]:
632
+ """Add scenarios to the execution queue. Returns the created jobs."""
633
+ new_jobs: list[ExecutionJob] = []
634
+ ownership_changed = False
635
+ with self._lock:
636
+ for scenario in scenarios:
637
+ is_xlsx = scenario.source_name.lower().endswith(
638
+ (".xlsx", ".xls", ".ods")
639
+ )
640
+ source_path = self._resolve_source_path(scenario.source_name)
641
+ if is_xlsx:
642
+ xlsx_path = source_path
643
+ stem = Path(scenario.source_name).stem
644
+ input_db_url = (
645
+ "sqlite:///"
646
+ + str(self.project_path / "intermediate" / f"{stem}.sqlite")
647
+ )
648
+ else:
649
+ xlsx_path = None
650
+ input_db_url = "sqlite:///" + str(source_path)
651
+
652
+ prev_owners = dict(self.settings.bare_output_owners)
653
+ output_subdir = choose_output_subdir_for_write(
654
+ self.project_path,
655
+ self.settings.bare_output_owners,
656
+ scenario.source_number,
657
+ scenario.name,
658
+ )
659
+ if self.settings.bare_output_owners != prev_owners:
660
+ ownership_changed = True
661
+
662
+ job = ExecutionJob(
663
+ job_id=self._next_id,
664
+ scenario_name=scenario.name,
665
+ source_name=scenario.source_name,
666
+ source_number=scenario.source_number,
667
+ output_subdir=output_subdir,
668
+ input_db_url=input_db_url,
669
+ is_xlsx_source=is_xlsx,
670
+ xlsx_path=xlsx_path,
671
+ )
672
+ self._next_id += 1
673
+ self._jobs.append(job)
674
+ new_jobs.append(job)
675
+
676
+ if ownership_changed:
677
+ try:
678
+ from flextool.gui.settings_io import save_project_settings
679
+ save_project_settings(self.project_path, self.settings)
680
+ except Exception:
681
+ logger.warning(
682
+ "Could not persist bare_output_owners to settings.yaml",
683
+ exc_info=True,
684
+ )
685
+
686
+ # Upfront, additive seeding of input-DB entity colors into the
687
+ # project plot_settings.yaml (Stage 5). This runs ONCE here in the
688
+ # GUI thread (the single writer) — scenarios themselves run as
689
+ # parallel subprocesses, so per-scenario writes would race. We seed
690
+ # OUTSIDE self._lock: the DB fetch + file splice is slow I/O that must
691
+ # not hold the manager lock and block other job operations. The jobs
692
+ # created above are all SCENARIO jobs (add_jobs only enqueues
693
+ # scenarios; auxiliary jobs go through add_auxiliary_job).
694
+ #
695
+ # Collect the DISTINCT input DB URLs of the newly-added jobs whose
696
+ # backing sqlite file exists on disk. xlsx sources point at a
697
+ # not-yet-created intermediate/<stem>.sqlite; those are skipped here
698
+ # and picked up on a later batch once the intermediate DB exists
699
+ # (known limitation). Best-effort / non-fatal: any failure is logged
700
+ # and never breaks job queuing. GUI-execution path only; the headless
701
+ # CLI path is out of scope.
702
+ input_db_urls = list(
703
+ dict.fromkeys(j.input_db_url for j in new_jobs if j.input_db_url)
704
+ )
705
+ if input_db_urls:
706
+ try:
707
+ from flextool.scenario_comparison.input_entity_colors import (
708
+ seed_input_entity_colors,
709
+ )
710
+ seed_input_entity_colors(self.project_path, input_db_urls)
711
+ except Exception:
712
+ logger.warning(
713
+ "Upfront input-DB color seeding failed (non-fatal)",
714
+ exc_info=True,
715
+ )
716
+
717
+ return new_jobs
718
+
719
+ # ------------------------------------------------------------------
720
+ # Auxiliary (non-scenario) jobs
721
+ # ------------------------------------------------------------------
722
+
723
+ def add_auxiliary_job(
724
+ self,
725
+ job_type: JobType,
726
+ display_name: str,
727
+ action_key: str,
728
+ *,
729
+ insert_before_source: str | None = None,
730
+ ) -> ExecutionJob:
731
+ """Create a non-scenario job (conversion, output action, etc.).
732
+
733
+ Auxiliary jobs are **not** managed by the scheduler — the caller is
734
+ responsible for running them in a thread and calling
735
+ :meth:`append_stdout` / :meth:`finish_job`.
736
+
737
+ If a finished or running job with the same *action_key* already
738
+ exists, it is killed (if running) and removed first.
739
+
740
+ Args:
741
+ job_type: The kind of auxiliary job.
742
+ display_name: What to show in the job tree.
743
+ action_key: Unique key for replacement logic.
744
+ insert_before_source: If given, insert the job right before the
745
+ first SCENARIO job with this ``source_name`` (used for
746
+ conversion jobs). Otherwise appended at end.
747
+ """
748
+ with self._lock:
749
+ # Kill and remove old job with the same action_key
750
+ for old in list(self._jobs):
751
+ if old.action_key and old.action_key == action_key:
752
+ if old.status == JobStatus.RUNNING and old.process:
753
+ try:
754
+ old.process.kill()
755
+ except OSError:
756
+ pass
757
+ self._jobs.remove(old)
758
+
759
+ job = ExecutionJob(
760
+ job_id=self._next_id,
761
+ job_type=job_type,
762
+ display_name=display_name,
763
+ action_key=action_key,
764
+ status=JobStatus.RUNNING,
765
+ start_time=datetime.now(),
766
+ )
767
+ self._next_id += 1
768
+
769
+ # Determine insertion position
770
+ if insert_before_source is not None:
771
+ idx = len(self._jobs)
772
+ for i, j in enumerate(self._jobs):
773
+ if (
774
+ j.job_type == JobType.SCENARIO
775
+ and j.source_name == insert_before_source
776
+ ):
777
+ idx = i
778
+ break
779
+ self._jobs.insert(idx, job)
780
+ else:
781
+ self._jobs.append(job)
782
+
783
+ self._pending_select_job_id = job.job_id
784
+
785
+ self._notify_status_change(job)
786
+ return job
787
+
788
+ def append_stdout(self, job_id: int, line: str) -> None:
789
+ """Thread-safe append of a stdout line to a job."""
790
+ with self._lock:
791
+ job = self._get_job(job_id)
792
+ if job is not None:
793
+ job.stdout_lines.append(line)
794
+
795
+ def finish_job(self, job_id: int, success: bool) -> None:
796
+ """Mark an auxiliary job as finished (SUCCESS or FAILED)."""
797
+ with self._lock:
798
+ job = self._get_job(job_id)
799
+ if job is None:
800
+ return
801
+ now = datetime.now()
802
+ job.end_time = now
803
+ job.finish_timestamp = now.strftime("%d.%m.%y %H:%M")
804
+ job.process = None
805
+ job.status = JobStatus.SUCCESS if success else JobStatus.FAILED
806
+ self._notify_status_change(job)
807
+
808
+ def remove_jobs_for_source(self, source_name: str) -> None:
809
+ """Remove all jobs related to *source_name*.
810
+
811
+ This removes:
812
+ - Scenario jobs whose ``source_name`` matches
813
+ - Auxiliary jobs whose ``action_key`` or ``display_name`` contains
814
+ the source name or its stem (e.g. deleting ``model.sqlite`` also
815
+ matches ``old_convert:model.xlsm`` because the stem ``model``
816
+ is the same).
817
+
818
+ Running or pending jobs are killed first.
819
+ """
820
+ stem = Path(source_name).stem
821
+ with self._lock:
822
+ to_remove: list[ExecutionJob] = []
823
+ for job in self._jobs:
824
+ match = False
825
+ if job.job_type == JobType.SCENARIO and job.source_name == source_name:
826
+ match = True
827
+ elif job.action_key and (
828
+ source_name in job.action_key or stem in job.action_key
829
+ ):
830
+ match = True
831
+ elif job.display_name and (
832
+ source_name in job.display_name or stem in job.display_name
833
+ ):
834
+ match = True
835
+ if match:
836
+ if job.status == JobStatus.RUNNING and job.process:
837
+ try:
838
+ job.process.kill()
839
+ except OSError:
840
+ pass
841
+ to_remove.append(job)
842
+ for job in to_remove:
843
+ self._jobs.remove(job)
844
+
845
+ def start(self) -> None:
846
+ """Start executing pending jobs up to *max_workers* concurrently.
847
+
848
+ Launches a scheduler thread that monitors and dispatches jobs.
849
+ """
850
+ with self._lock:
851
+ if self._scheduler_thread is not None and self._scheduler_thread.is_alive():
852
+ return
853
+ self._wind_down = False
854
+ self._stopped = False
855
+
856
+ # Pre-create the shared results.sqlite once, in-process, before any
857
+ # scenario subprocess runs. Each run writes its alternative at the
858
+ # end via the spinedb write-method; spinedb_api gives writers a
859
+ # 1800s busy_timeout so concurrent *appends* to an existing file
860
+ # serialize safely. The unsafe part is the create-from-JSON path,
861
+ # whose guard is a non-atomic os.path.exists() — on a project's
862
+ # first batch every parallel subprocess would otherwise find the
863
+ # file missing and race to build the schema. Doing it here once
864
+ # closes that race without touching the engine writer.
865
+ self._precreate_results_db_if_needed()
866
+
867
+ self._scheduler_thread = threading.Thread(
868
+ target=self._scheduler_loop, daemon=True
869
+ )
870
+ self._scheduler_thread.start()
871
+
872
+ if self._watchdog is None:
873
+ self._watchdog = MemoryWatchdog(self)
874
+ self._watchdog.start()
875
+
876
+ def _precreate_results_db_if_needed(self) -> None:
877
+ """Initialize the shared results.sqlite schema once before the batch.
878
+
879
+ No-op unless the SpineDB output is enabled and at least one scenario
880
+ job is pending. Idempotent (``ensure_results_db`` short-circuits on
881
+ an existing file). Non-fatal: on failure the scenario subprocesses
882
+ fall back to creating it themselves (the pre-existing behaviour).
883
+ """
884
+ if not getattr(self.settings, "auto_generate_comp_spinedb", False):
885
+ return
886
+ with self._lock:
887
+ has_pending = any(
888
+ j.job_type == JobType.SCENARIO and j.status == JobStatus.PENDING
889
+ for j in self._jobs
890
+ )
891
+ if not has_pending:
892
+ return
893
+ db_url = "sqlite:///" + str(self.project_path / "results.sqlite")
894
+ try:
895
+ from flextool.process_outputs.write_spinedb import ensure_results_db
896
+ ensure_results_db(db_url)
897
+ except Exception:
898
+ logger.warning(
899
+ "Could not pre-create results SpineDB at %s; scenario runs "
900
+ "will attempt to create it (possible cold-start race).",
901
+ db_url, exc_info=True,
902
+ )
903
+
904
+ # ------------------------------------------------------------------
905
+ # Scheduler
906
+ # ------------------------------------------------------------------
907
+
908
+ def _pick_next_pending(self) -> "ExecutionJob | None":
909
+ """Choose the next pending scenario job to dispatch this tick.
910
+
911
+ Must be called while holding ``self._lock``. Resets and sets the
912
+ ``_thread_limited`` / ``_memory_limited`` status flags, and on a
913
+ successful pick increments ``_running_count``. Returns the chosen
914
+ job, or ``None`` when nothing can start.
915
+
916
+ Admission rules, in order:
917
+ 1. Thread slots (``max_workers``) always bind — a full pool sets
918
+ ``_thread_limited`` and nothing starts, even forced jobs. This is
919
+ the upper limit on concurrency; live RAM decides how much of it is
920
+ actually used.
921
+ 2. A job flagged ``force_start`` bypasses the memory check and is
922
+ admitted ahead of the queue.
923
+ 3. With nothing running, the first scenario is admitted
924
+ unconditionally — even if its estimate says it might not fit — so
925
+ a single oversized scenario always gets its shot and a tight
926
+ machine never deadlocks.
927
+ 4. Otherwise the live free-RAM check (``_memory_admits``) binds;
928
+ failing it sets ``_memory_limited``.
929
+
930
+ On a successful pick the job is marked RUNNING here, under the lock, so
931
+ the scheduler's immediate re-pick can't double-dispatch the same
932
+ still-PENDING job before its worker thread starts.
933
+
934
+ The memory-guard switch does NOT enter here: it only governs the
935
+ watchdog's killing. How many runs start is always limited by RAM, and
936
+ ``force_start`` is the deliberate per-job override.
937
+ """
938
+ self._thread_limited = False
939
+ self._memory_limited = False
940
+ if self._wind_down:
941
+ return None
942
+
943
+ candidate: ExecutionJob | None = None
944
+ forced: ExecutionJob | None = None
945
+ for job in self._jobs:
946
+ if job.job_type == JobType.SCENARIO and job.status == JobStatus.PENDING:
947
+ if candidate is None:
948
+ candidate = job
949
+ if forced is None and job.force_start:
950
+ forced = job
951
+ if forced is not None:
952
+ break
953
+ if candidate is None:
954
+ return None
955
+
956
+ if self._running_count >= self._max_workers:
957
+ self._thread_limited = True
958
+ return None
959
+
960
+ if forced is not None:
961
+ chosen = forced
962
+ elif self._running_count == 0:
963
+ # Always give at least one scenario its shot, fit or not.
964
+ chosen = candidate
965
+ else:
966
+ estimate_gb, source = self._compute_memory_budget_for_job(candidate)
967
+ if self._memory_admits(candidate, source, estimate_gb):
968
+ chosen = candidate
969
+ else:
970
+ self._memory_limited = True
971
+ return None
972
+
973
+ # Mark RUNNING under the lock so the scheduler's immediate re-pick
974
+ # doesn't see this job as still-PENDING and dispatch it twice. The
975
+ # worker thread re-asserts this (idempotent) and sets start_time.
976
+ chosen.status = JobStatus.RUNNING
977
+ self._running_count += 1
978
+ return chosen
979
+
980
+ def _scheduler_loop(self) -> None:
981
+ """Main loop of the scheduler thread.
982
+
983
+ The lock is only held for brief critical sections (checking state,
984
+ updating counters). It is never held while spawning threads,
985
+ running subprocesses, sleeping, or calling callbacks.
986
+ """
987
+ while True:
988
+ # --- brief lock: read state ---
989
+ with self._lock:
990
+ if self._stopped:
991
+ break
992
+
993
+ next_job = self._pick_next_pending()
994
+
995
+ # Check if all SCENARIO jobs are done (auxiliary jobs are
996
+ # managed externally and don't block the scheduler)
997
+ any_active = any(
998
+ j.status in (JobStatus.PENDING, JobStatus.RUNNING)
999
+ for j in self._jobs
1000
+ if j.job_type == JobType.SCENARIO
1001
+ )
1002
+ # --- lock released ---
1003
+
1004
+ # Spawn the worker thread WITHOUT holding the lock
1005
+ if next_job is not None:
1006
+ worker = threading.Thread(
1007
+ target=self._run_job, args=(next_job,), daemon=True
1008
+ )
1009
+ worker.start()
1010
+ continue # Re-check immediately for more slots
1011
+
1012
+ if not any_active:
1013
+ # All jobs finished — run post-execution in a separate thread
1014
+ # so the scheduler exits promptly and start() can launch a new
1015
+ # scheduler if the user submits more jobs.
1016
+ def _post():
1017
+ self._run_post_execution()
1018
+ if self._on_all_finished is not None:
1019
+ try:
1020
+ self._on_all_finished()
1021
+ except Exception:
1022
+ logger.exception("on_all_finished callback failed")
1023
+ threading.Thread(target=_post, daemon=True).start()
1024
+ break
1025
+
1026
+ time.sleep(0.1)
1027
+
1028
+ # ------------------------------------------------------------------
1029
+ # Job execution
1030
+ # ------------------------------------------------------------------
1031
+
1032
+ def _run_job(self, job: ExecutionJob) -> None:
1033
+ """Run a single job in a worker thread."""
1034
+ try:
1035
+ # Mark as RUNNING
1036
+ with self._lock:
1037
+ job.status = JobStatus.RUNNING
1038
+ job.start_time = datetime.now()
1039
+ self._notify_status_change(job)
1040
+
1041
+ # xlsx conversion must be done before jobs are dispatched
1042
+ if job.is_xlsx_source and job.source_name not in self._converted_xlsx:
1043
+ raise RuntimeError(
1044
+ f"Bug: xlsx source '{job.source_name}' was not pre-converted"
1045
+ )
1046
+
1047
+ # Step 2: Build and run the main FlexTool command
1048
+ work_folder = self.project_path / "work" / job.output_subdir
1049
+
1050
+ # Clean work folder so re-executions start fresh
1051
+ if work_folder.exists():
1052
+ shutil.rmtree(work_folder, ignore_errors=True)
1053
+ work_folder.mkdir(parents=True, exist_ok=True)
1054
+
1055
+ cmd = self._build_run_command(job, work_folder)
1056
+
1057
+ estimate_gb, _src = self._compute_memory_budget_for_job(job)
1058
+ with self._lock:
1059
+ job.memory_cap_gb = estimate_gb
1060
+
1061
+ wrapped_cmd, popen_extras, post_spawn = _wrap_for_memory_cap(
1062
+ cmd, estimate_gb
1063
+ )
1064
+ cmd_str = format_cmd_for_log(wrapped_cmd)
1065
+ logger.info("Running job %d (%s):\n%s", job.job_id, job.scenario_name, cmd_str)
1066
+
1067
+ # Log the CLI command as the first line in the progress output
1068
+ with self._lock:
1069
+ job.stdout_lines.append(cmd_str)
1070
+ job.stdout_lines.append("")
1071
+ self._notify_status_change(job)
1072
+
1073
+ # Run from flextool root so relative paths (templates/, etc.) work
1074
+ flextool_root = Path.cwd() # subprocess cwd — user workspace, formerly the repo root
1075
+
1076
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
1077
+ popen_kwargs = dict(
1078
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
1079
+ text=True, bufsize=1, cwd=str(flextool_root), env=env,
1080
+ )
1081
+ popen_kwargs.update(popen_extras)
1082
+ proc = subprocess.Popen(wrapped_cmd, **popen_kwargs)
1083
+ cap_handle = post_spawn(proc) if post_spawn is not None else None
1084
+ killed = False
1085
+ with self._lock:
1086
+ # Check if killed while we were setting up
1087
+ if job.status == JobStatus.KILLED:
1088
+ killed = True
1089
+ else:
1090
+ job.process = proc
1091
+ job.memory_cap_handle = cap_handle
1092
+
1093
+ if killed:
1094
+ proc.kill()
1095
+ proc.wait()
1096
+ return
1097
+
1098
+ # Read stdout line by line
1099
+ assert proc.stdout is not None
1100
+ for line in proc.stdout:
1101
+ stripped = line.rstrip("\n")
1102
+ with self._lock:
1103
+ job.stdout_lines.append(stripped)
1104
+ # Fast-path warmed flip: the solver has loaded the matrix
1105
+ # and begun reserving memory, so this job's RSS is now a
1106
+ # reliable footprint. The watchdog's RSS-plateau backstop
1107
+ # covers quiet/suppressed logs where no marker appears.
1108
+ if not job.footprint_solid and _is_solver_warm_marker(stripped):
1109
+ job.footprint_solid = True
1110
+ # Notify so the GUI can update the log view
1111
+ self._notify_status_change(job)
1112
+
1113
+ return_code = proc.wait()
1114
+
1115
+ # Finalise
1116
+ now = datetime.now()
1117
+ peak_to_record: tuple[ExecutionJob, float] | None = None
1118
+ with self._lock:
1119
+ if job.status == JobStatus.KILLED:
1120
+ return
1121
+ job.end_time = now
1122
+ job.finish_timestamp = now.strftime("%d.%m.%y %H:%M")
1123
+ job.process = None
1124
+ if return_code == 0:
1125
+ job.status = JobStatus.SUCCESS
1126
+ self.settings.scenarios_changed = True
1127
+ runtime_s = (
1128
+ (now - job.start_time).total_seconds()
1129
+ if job.start_time else 0.0
1130
+ )
1131
+ # Capture under lock; persist below (outside lock) to
1132
+ # avoid blocking other workers on YAML I/O.
1133
+ peak_to_record = (job, runtime_s)
1134
+ else:
1135
+ job.status = JobStatus.FAILED
1136
+ if job.killed_for_memory:
1137
+ job.stdout_lines.append(
1138
+ f"[execution_manager] Killed: {job.kill_reason or 'memory pressure'}"
1139
+ )
1140
+ from flextool import env_check
1141
+ if env_check.is_native_fault(return_code):
1142
+ job.native_fault = True
1143
+ job.stdout_lines.append(
1144
+ f"[execution_manager] Process crashed: "
1145
+ f"{env_check.describe_fault(return_code)} "
1146
+ f"(exit code {return_code})"
1147
+ )
1148
+ job.stdout_lines.append(
1149
+ "[execution_manager] This is a native library crash, "
1150
+ "not a model error. It may be an incompatible solver "
1151
+ "wheel (polars or HiGHS) that FlexTool can re-install — "
1152
+ "see the prompt at the next start, or run 'Update "
1153
+ "FlexTool'. If the solver check passes, the cause is "
1154
+ "usually the Python environment itself: a venv built on "
1155
+ "Anaconda/conda, or running from a network/mapped drive. "
1156
+ "Rebuild from a clean python.org Python on a local disk."
1157
+ )
1158
+ else:
1159
+ job.stdout_lines.append(
1160
+ f"[execution_manager] Process exited with code {return_code}"
1161
+ )
1162
+ self._prune_old_jobs(job)
1163
+ if peak_to_record is not None:
1164
+ self._record_scenario_peak(*peak_to_record)
1165
+ self._notify_status_change(job)
1166
+
1167
+ except Exception:
1168
+ logger.exception("Unexpected error running job %d (%s)", job.job_id, job.scenario_name)
1169
+ with self._lock:
1170
+ job.status = JobStatus.FAILED
1171
+ job.end_time = datetime.now()
1172
+ job.finish_timestamp = job.end_time.strftime("%d.%m.%y %H:%M")
1173
+ self._notify_status_change(job)
1174
+ finally:
1175
+ with self._lock:
1176
+ self._running_count -= 1
1177
+
1178
+ def _build_run_command(self, job: ExecutionJob, work_folder: Path) -> list[str]:
1179
+ """Build the ``cmd_run_flextool`` command line."""
1180
+ settings = self.settings
1181
+
1182
+ # Write methods: always include parquet
1183
+ write_methods: list[str] = ["parquet"]
1184
+ if settings.auto_generate_scen_plots:
1185
+ write_methods.append("plot")
1186
+ if settings.auto_generate_scen_excels:
1187
+ write_methods.append("excel")
1188
+ if settings.auto_generate_scen_csvs:
1189
+ write_methods.append("csv")
1190
+ # SpineDB results database (project-wide results.sqlite). Must run
1191
+ # on the native solve path — the writer needs the live s/par
1192
+ # namespaces — so it is requested here at solve time rather than
1193
+ # via the parquet-replay regen actions. The default target is
1194
+ # <output-location>/results.sqlite, i.e. the project root, shared
1195
+ # across scenarios (each appended as its own alternative).
1196
+ if settings.auto_generate_comp_spinedb:
1197
+ write_methods.append("spinedb")
1198
+
1199
+ cmd = [
1200
+ sys.executable,
1201
+ "-m",
1202
+ "flextool.cli.cmd_run_flextool",
1203
+ job.input_db_url,
1204
+ "--scenario-name",
1205
+ job.scenario_name,
1206
+ "--work-folder",
1207
+ str(work_folder),
1208
+ "--output-location",
1209
+ str(self.project_path),
1210
+ "--output-subdir",
1211
+ job.output_subdir,
1212
+ "--write-methods",
1213
+ *write_methods,
1214
+ ]
1215
+
1216
+ # Output config file
1217
+ single = settings.single_plot_settings
1218
+ if single.config_file:
1219
+ cmd.extend(["--output-config", single.config_file])
1220
+
1221
+ # Active configs — pass all available if none explicitly set
1222
+ active = single.active_configs
1223
+ if not active:
1224
+ from flextool._resources import package_data_path
1225
+ from flextool.gui.config_parser import parse_plot_configs
1226
+ if single.config_file:
1227
+ config_path = Path(single.config_file)
1228
+ if not config_path.is_absolute():
1229
+ config_path = Path.cwd() / config_path
1230
+ else:
1231
+ config_path = package_data_path("schemas/default_plots.yaml")
1232
+ active = parse_plot_configs(config_path) or ["default"]
1233
+ cmd.extend(["--active-configs", *active])
1234
+
1235
+ # Plot rows (start_time .. start_time + duration - 1)
1236
+ if single.duration > 0:
1237
+ first_row = single.start_time
1238
+ last_row = single.start_time + single.duration - 1
1239
+ cmd.extend(["--plot-rows", str(first_row), str(last_row)])
1240
+
1241
+ if single.only_first_file:
1242
+ cmd.append("--only-first-file-per-plot")
1243
+
1244
+ cmd.extend(["--highs-threads", str(self.execution_limits.max_cores_per_job)])
1245
+
1246
+ # "Debug" radio group in the main window: Off / Basic / Full.
1247
+ # Basic → --debug=basic only (verbose checkpoints, no tracemalloc).
1248
+ # Full → --debug=full + --csv-dump (tracemalloc + retained
1249
+ # intermediate CSVs; the heavy I/O path).
1250
+ debug_level = getattr(settings, "debug_level", "off")
1251
+ if debug_level == "basic":
1252
+ cmd.append("--debug=basic")
1253
+ elif debug_level == "full":
1254
+ cmd.append("--debug=full")
1255
+ cmd.append("--csv-dump")
1256
+
1257
+ # "Save memory" checkbox enables polar-high's save_memory path
1258
+ # (drop polar-side LP source + MPS round-trip pre-solve).
1259
+ if settings.save_memory:
1260
+ cmd.append("--save-memory")
1261
+
1262
+ # ── Solver options (Solver options dialog in the side menu) ──
1263
+ # Append each flag only when the user has changed it from the
1264
+ # GUI-side default, keeping the engine command line clean on
1265
+ # the common path. HiGHS thread count is sourced solely from
1266
+ # the execution_limits.max_cores_per_job append above (the
1267
+ # Execution jobs window owns that knob); ``user_bound_scale``
1268
+ # is advanced-user territory routed through ``solver_arguments``.
1269
+ _sll = getattr(settings, "solver_log_level", "normal")
1270
+ if _sll != "normal":
1271
+ cmd.append(f"--solver-log-level={_sll}")
1272
+ _stl = getattr(settings, "solver_time_limit", 0)
1273
+ if isinstance(_stl, int) and _stl > 0:
1274
+ cmd.extend(["--solver-time-limit", str(_stl)])
1275
+ # MIP gap is forwarded only when explicitly enabled (the dialog's
1276
+ # "Set relative MIP gap here" checkbox). When enabled, 0 is a
1277
+ # valid value and IS sent; when disabled, no flag is emitted and
1278
+ # the solver_config/<solver>.opt baseline governs.
1279
+ if getattr(settings, "solver_mip_gap_set", False):
1280
+ _smg = getattr(settings, "solver_mip_gap", 0.0)
1281
+ if isinstance(_smg, (int, float)) and not isinstance(_smg, bool) and _smg >= 0:
1282
+ cmd.extend(["--solver-mip-gap", repr(float(_smg))])
1283
+ _mff = getattr(settings, "matrix_file_format", "mps")
1284
+ if _mff != "mps":
1285
+ cmd.append(f"--matrix-file-format={_mff}")
1286
+ _scl = getattr(settings, "scaling", "full")
1287
+ if _scl != "full":
1288
+ cmd.append(f"--scaling={_scl}")
1289
+ # Presolve is always forwarded (incl. the "choose" default): the
1290
+ # engine's determinism pin lives on the in-process baseline used
1291
+ # by the test gate, not this CLI path, so passing "choose" here
1292
+ # genuinely lets HiGHS decide per-problem.
1293
+ _ps = getattr(settings, "presolve", "choose")
1294
+ if _ps in ("on", "off", "choose"):
1295
+ cmd.append(f"--presolve={_ps}")
1296
+
1297
+ return cmd
1298
+
1299
+ # ------------------------------------------------------------------
1300
+ # Post-execution (comparison)
1301
+ # ------------------------------------------------------------------
1302
+
1303
+ def _run_post_execution(self) -> None:
1304
+ """Run comparison plots/Excel after all scenarios finish.
1305
+
1306
+ Accumulates successful scenario names across batches so that when
1307
+ the user adds batch 2 after batch 1 has already finished, the
1308
+ comparison includes all scenarios from both batches.
1309
+ """
1310
+ settings = self.settings
1311
+ do_comp_plots = settings.auto_generate_comp_plots
1312
+ do_comp_excel = settings.auto_generate_comp_excel
1313
+
1314
+ # Gather on-disk subdirs from successful SCENARIO jobs only. Each
1315
+ # job records its chosen subdir (bare when uncontested, suffixed
1316
+ # on collision), so same-named scenarios from different sources
1317
+ # stay distinct.
1318
+ with self._lock:
1319
+ successful = [
1320
+ j.output_subdir
1321
+ for j in self._jobs
1322
+ if j.job_type == JobType.SCENARIO and j.status == JobStatus.SUCCESS
1323
+ ]
1324
+ # Merge in any externally-specified comparison subdirs
1325
+ extra = [s for s in self._comparison_scenarios if s not in successful]
1326
+ all_scenarios = successful + extra
1327
+ # Accumulate across batches
1328
+ for subdir in successful:
1329
+ if subdir not in self._comparison_scenarios:
1330
+ self._comparison_scenarios.append(subdir)
1331
+
1332
+ if not do_comp_plots and not do_comp_excel:
1333
+ return
1334
+
1335
+ if len(all_scenarios) < 1:
1336
+ logger.info("No successful scenarios; skipping comparison")
1337
+ return
1338
+
1339
+ cmd = [
1340
+ sys.executable,
1341
+ "-m",
1342
+ "flextool.cli.cmd_scenario_results",
1343
+ "--parquet-base-dir",
1344
+ str(self.project_path / "output_parquet"),
1345
+ "--alternatives",
1346
+ *all_scenarios,
1347
+ "--plot-dir",
1348
+ str(self.project_path / "output_plot_comparisons"),
1349
+ ]
1350
+
1351
+ comp = settings.comparison_plot_settings
1352
+
1353
+ # Self-heal stale settings still pointing at the removed
1354
+ # default_comparison_plots.yaml — comparison rendering now lives
1355
+ # in default_plots.yaml.
1356
+ cfg_file = comp.config_file
1357
+ if cfg_file and cfg_file.endswith("default_comparison_plots.yaml"):
1358
+ cfg_file = cfg_file.replace(
1359
+ "default_comparison_plots.yaml", "default_plots.yaml",
1360
+ )
1361
+
1362
+ if cfg_file:
1363
+ cmd.extend(["--output-config-path", cfg_file])
1364
+
1365
+ active = comp.active_configs
1366
+ if not active:
1367
+ from flextool._resources import package_data_path
1368
+ from flextool.gui.config_parser import parse_plot_configs
1369
+ if cfg_file:
1370
+ config_path = Path(cfg_file)
1371
+ if not config_path.is_absolute():
1372
+ config_path = Path.cwd() / config_path
1373
+ else:
1374
+ config_path = package_data_path("schemas/default_plots.yaml")
1375
+ active = parse_plot_configs(config_path) or ["default"]
1376
+ cmd.extend(["--active-configs", *active])
1377
+
1378
+ if comp.duration > 0:
1379
+ first_row = comp.start_time
1380
+ last_row = comp.start_time + comp.duration - 1
1381
+ cmd.extend(["--plot-rows", str(first_row), str(last_row)])
1382
+
1383
+ if do_comp_plots:
1384
+ cmd.append("--dispatch-plots")
1385
+
1386
+ if do_comp_excel:
1387
+ cmd.extend(["--write-to-xlsx", "--write-dispatch-xlsx"])
1388
+
1389
+ if comp.only_first_file:
1390
+ cmd.append("--only-first-file-per-plot")
1391
+
1392
+ logger.info("Running post-execution comparison:\n%s", format_cmd_for_log(cmd))
1393
+
1394
+ # Create an auxiliary job so comparison output is visible in the
1395
+ # execution window.
1396
+ job = self.add_auxiliary_job(
1397
+ JobType.OUTPUT_ACTION,
1398
+ "Auto-comparison",
1399
+ "output:auto_comparison",
1400
+ )
1401
+ cmd_str = format_cmd_for_log(cmd)
1402
+ self.append_stdout(job.job_id, cmd_str)
1403
+ self.append_stdout(job.job_id, "")
1404
+
1405
+ flextool_root = Path.cwd() # subprocess cwd — user workspace, formerly the repo root
1406
+ success = False
1407
+
1408
+ try:
1409
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
1410
+ estimate_gb, _src = self._compute_memory_budget_for_job(None)
1411
+ with self._lock:
1412
+ job.memory_cap_gb = estimate_gb
1413
+ wrapped_cmd, popen_extras, post_spawn = _wrap_for_memory_cap(
1414
+ cmd, estimate_gb
1415
+ )
1416
+ popen_kwargs = dict(
1417
+ stdout=subprocess.PIPE,
1418
+ stderr=subprocess.STDOUT,
1419
+ text=True,
1420
+ bufsize=1,
1421
+ cwd=str(flextool_root),
1422
+ env=env,
1423
+ )
1424
+ popen_kwargs.update(popen_extras)
1425
+ proc = subprocess.Popen(wrapped_cmd, **popen_kwargs)
1426
+ cap_handle = post_spawn(proc) if post_spawn is not None else None
1427
+ with self._lock:
1428
+ job.process = proc
1429
+ job.memory_cap_handle = cap_handle
1430
+ assert proc.stdout is not None
1431
+ for line in proc.stdout:
1432
+ stripped = line.rstrip("\n")
1433
+ self.append_stdout(job.job_id, stripped)
1434
+ return_code = proc.wait()
1435
+ success = return_code == 0
1436
+ if not success:
1437
+ self.append_stdout(
1438
+ job.job_id, f"Process exited with code {return_code}"
1439
+ )
1440
+ except Exception:
1441
+ logger.exception("Post-execution comparison failed")
1442
+ self.append_stdout(job.job_id, "Comparison failed with exception")
1443
+ finally:
1444
+ self.finish_job(job.job_id, success)
1445
+ # Record which scenarios were compared so the Show button works
1446
+ if do_comp_plots:
1447
+ settings.comp_plots_scenarios = list(all_scenarios)
1448
+ if do_comp_excel:
1449
+ settings.comp_excel_scenarios = list(all_scenarios)
1450
+
1451
+ # ------------------------------------------------------------------
1452
+ # Control methods
1453
+ # ------------------------------------------------------------------
1454
+
1455
+ def pause(self) -> None:
1456
+ """Let currently running jobs finish but don't start new ones."""
1457
+ with self._lock:
1458
+ self._wind_down = True
1459
+
1460
+ def resume(self) -> None:
1461
+ """Resume starting new jobs after a pause.
1462
+
1463
+ If the scheduler thread has exited (all jobs were done when paused),
1464
+ a new scheduler is started.
1465
+ """
1466
+ with self._lock:
1467
+ self._wind_down = False
1468
+ # Re-launch the scheduler if it has exited
1469
+ self.start()
1470
+
1471
+ @property
1472
+ def is_paused(self) -> bool:
1473
+ """Return True if the manager is in wind-down / paused state."""
1474
+ with self._lock:
1475
+ return self._wind_down
1476
+
1477
+ def kill_all(self) -> None:
1478
+ """Kill all running jobs and cancel pending ones."""
1479
+ with self._lock:
1480
+ self._stopped = True
1481
+ for job in self._jobs:
1482
+ if job.status == JobStatus.RUNNING and job.process:
1483
+ try:
1484
+ job.process.kill()
1485
+ except OSError:
1486
+ pass
1487
+ job.status = JobStatus.KILLED
1488
+ elif job.status == JobStatus.PENDING:
1489
+ job.status = JobStatus.KILLED
1490
+
1491
+ def cleanup(self) -> None:
1492
+ """Kill all running subprocesses. Called on exit.
1493
+
1494
+ This method is registered with ``atexit`` so it runs when Python
1495
+ exits normally (``sys.exit``, end of script). It is also safe to
1496
+ call explicitly from signal handlers or window-close callbacks --
1497
+ the method is idempotent.
1498
+ """
1499
+ if self._watchdog is not None:
1500
+ self._watchdog.stop()
1501
+ with self._lock:
1502
+ self._stopped = True
1503
+ for job in self._jobs:
1504
+ if job.status == JobStatus.RUNNING and job.process:
1505
+ try:
1506
+ job.process.kill()
1507
+ job.process.wait(timeout=5)
1508
+ except Exception:
1509
+ pass
1510
+
1511
+ def kill_job(self, job_id: int) -> None:
1512
+ """Kill a specific running job."""
1513
+ with self._lock:
1514
+ job = self._get_job(job_id)
1515
+ if job and job.status == JobStatus.RUNNING and job.process:
1516
+ try:
1517
+ job.process.kill()
1518
+ except OSError:
1519
+ pass
1520
+ job.status = JobStatus.KILLED
1521
+ job.end_time = datetime.now()
1522
+ job.finish_timestamp = job.end_time.strftime("%d.%m.%y %H:%M")
1523
+ if job:
1524
+ self._notify_status_change(job)
1525
+
1526
+ def _prune_old_jobs(self, finished_job: ExecutionJob) -> None:
1527
+ """Remove previous jobs for the same scenario after a job finishes.
1528
+
1529
+ Must be called while holding ``self._lock``.
1530
+
1531
+ Only applies to SCENARIO jobs. Auxiliary jobs are pruned via
1532
+ ``action_key`` in :meth:`add_auxiliary_job`.
1533
+
1534
+ Rules:
1535
+ - New job succeeded → remove all previous jobs for this scenario.
1536
+ - New job failed → remove previous failed/killed jobs, keep successful ones.
1537
+ """
1538
+ if finished_job.job_type != JobType.SCENARIO:
1539
+ return
1540
+ to_remove = []
1541
+ for old in self._jobs:
1542
+ if old is finished_job:
1543
+ continue
1544
+ if old.job_type != JobType.SCENARIO:
1545
+ continue
1546
+ if (old.source_number, old.scenario_name) != (
1547
+ finished_job.source_number, finished_job.scenario_name
1548
+ ):
1549
+ continue
1550
+ if old.status in (JobStatus.PENDING, JobStatus.RUNNING):
1551
+ continue
1552
+ if finished_job.status == JobStatus.SUCCESS:
1553
+ to_remove.append(old)
1554
+ elif old.status in (JobStatus.FAILED, JobStatus.KILLED):
1555
+ to_remove.append(old)
1556
+ for old in to_remove:
1557
+ self._jobs.remove(old)
1558
+
1559
+ def remove_job(self, job_id: int) -> None:
1560
+ """Remove a finished or pending job from the list."""
1561
+ with self._lock:
1562
+ job = self._get_job(job_id)
1563
+ if job and job.status in (
1564
+ JobStatus.PENDING,
1565
+ JobStatus.SUCCESS,
1566
+ JobStatus.FAILED,
1567
+ JobStatus.KILLED,
1568
+ ):
1569
+ self._jobs.remove(job)
1570
+
1571
+ def move_pending_up(self, job_id: int) -> None:
1572
+ """Move a pending job one position earlier in the queue."""
1573
+ with self._lock:
1574
+ job = self._get_job(job_id)
1575
+ if job is None or job.status != JobStatus.PENDING:
1576
+ return
1577
+ idx = self._jobs.index(job)
1578
+ if idx <= 0:
1579
+ return
1580
+ # Swap with the previous item (only if it is also pending)
1581
+ prev = self._jobs[idx - 1]
1582
+ if prev.status == JobStatus.PENDING:
1583
+ self._jobs[idx - 1], self._jobs[idx] = self._jobs[idx], self._jobs[idx - 1]
1584
+
1585
+ def move_pending_down(self, job_id: int) -> None:
1586
+ """Move a pending job one position later in the queue."""
1587
+ with self._lock:
1588
+ job = self._get_job(job_id)
1589
+ if job is None or job.status != JobStatus.PENDING:
1590
+ return
1591
+ idx = self._jobs.index(job)
1592
+ if idx >= len(self._jobs) - 1:
1593
+ return
1594
+ # Swap with the next item (only if it is also pending)
1595
+ nxt = self._jobs[idx + 1]
1596
+ if nxt.status == JobStatus.PENDING:
1597
+ self._jobs[idx], self._jobs[idx + 1] = self._jobs[idx + 1], self._jobs[idx]
1598
+
1599
+ def set_force_start(self, job_id: int, force: bool = True) -> None:
1600
+ """Flag (or unflag) a pending scenario job to bypass the memory check.
1601
+
1602
+ Only affects ``PENDING`` ``SCENARIO`` jobs; finished or running jobs
1603
+ are ignored. A forced job is admitted by the scheduler ahead of the
1604
+ memory-reserve check, but still respects the thread-slot limit and
1605
+ the watchdog.
1606
+ """
1607
+ with self._lock:
1608
+ job = self._get_job(job_id)
1609
+ if (
1610
+ job is not None
1611
+ and job.job_type == JobType.SCENARIO
1612
+ and job.status == JobStatus.PENDING
1613
+ ):
1614
+ job.force_start = bool(force)
1615
+
1616
+ # ------------------------------------------------------------------
1617
+ # Query methods
1618
+ # ------------------------------------------------------------------
1619
+
1620
+ def get_jobs(self) -> list[ExecutionJob]:
1621
+ """Return a copy of the job list."""
1622
+ with self._lock:
1623
+ return list(self._jobs)
1624
+
1625
+ def get_stdout(self, job_id: int) -> list[str]:
1626
+ """Get stdout lines for a specific job."""
1627
+ with self._lock:
1628
+ job = self._get_job(job_id)
1629
+ if job is None:
1630
+ return []
1631
+ return list(job.stdout_lines)
1632
+
1633
+ def has_pending_or_running(self) -> bool:
1634
+ """Check if there are any pending or running jobs (any type)."""
1635
+ with self._lock:
1636
+ return any(
1637
+ j.status in (JobStatus.PENDING, JobStatus.RUNNING) for j in self._jobs
1638
+ )
1639
+
1640
+ def get_execution_status(self) -> dict:
1641
+ """Return a snapshot of the dispatcher's state for the GUI status bar.
1642
+
1643
+ Keys:
1644
+ running int — number of currently running scenario jobs
1645
+ max_threads int — current ``max_workers`` setting
1646
+ pending int — number of queued scenario jobs
1647
+ used_gb float — system RAM in use (total - available)
1648
+ total_gb float — system RAM total
1649
+ thread_limited bool — pending jobs are being held by max_workers
1650
+ memory_limited bool — pending jobs are being held by free RAM
1651
+ (live ``vm.available`` admission check)
1652
+ memory_guard bool — whether the memory guard is active this session
1653
+ """
1654
+ with self._lock:
1655
+ running = self._running_count
1656
+ max_threads = self._max_workers
1657
+ pending = sum(
1658
+ 1 for j in self._jobs
1659
+ if j.job_type == JobType.SCENARIO and j.status == JobStatus.PENDING
1660
+ )
1661
+ thread_limited = self._thread_limited
1662
+ memory_limited = self._memory_limited
1663
+ memory_guard = self._memory_guard_enabled
1664
+ try:
1665
+ vm = psutil.virtual_memory()
1666
+ total_gb = vm.total / (1024 ** 3)
1667
+ used_gb = (vm.total - vm.available) / (1024 ** 3)
1668
+ except Exception:
1669
+ total_gb = 0.0
1670
+ used_gb = 0.0
1671
+ return {
1672
+ "running": running,
1673
+ "max_threads": max_threads,
1674
+ "pending": pending,
1675
+ "used_gb": used_gb,
1676
+ "total_gb": total_gb,
1677
+ "thread_limited": thread_limited,
1678
+ "memory_limited": memory_limited,
1679
+ "memory_guard": memory_guard,
1680
+ }
1681
+
1682
+ def has_pending_or_running_scenarios(self) -> bool:
1683
+ """Check if there are pending or running scenario jobs specifically."""
1684
+ with self._lock:
1685
+ return any(
1686
+ j.status in (JobStatus.PENDING, JobStatus.RUNNING)
1687
+ for j in self._jobs
1688
+ if j.job_type == JobType.SCENARIO
1689
+ )
1690
+
1691
+ def set_comparison_scenarios(self, scenario_names: list[str]) -> None:
1692
+ """Set the list of executed scenarios to include in comparison."""
1693
+ with self._lock:
1694
+ self._comparison_scenarios = list(scenario_names)
1695
+
1696
+ # ------------------------------------------------------------------
1697
+ # Internal helpers
1698
+ # ------------------------------------------------------------------
1699
+
1700
+ def _get_job(self, job_id: int) -> ExecutionJob | None:
1701
+ """Find job by ID. Must be called with lock held."""
1702
+ for job in self._jobs:
1703
+ if job.job_id == job_id:
1704
+ return job
1705
+ return None
1706
+
1707
+ def _notify_status_change(self, job: ExecutionJob) -> None:
1708
+ """Invoke the on_status_change callback (if set)."""
1709
+ if self._on_status_change is not None:
1710
+ try:
1711
+ self._on_status_change(job)
1712
+ except Exception:
1713
+ logger.exception(
1714
+ "on_status_change callback failed for job %d", job.job_id
1715
+ )