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.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- 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
|
+
)
|