flextool 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. flextool-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from flextool.plot_outputs.config import flatten_new_format
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def parse_plot_configs(yaml_path: Path) -> list[str]:
14
+ """Read a plot config YAML file and return available config names.
15
+
16
+ Supports both old format (result_key at top level) and new format
17
+ (entry-name grouping with ``group``/``order`` keys). New-format
18
+ entries are flattened to result_key level before scanning.
19
+
20
+ Returns a sorted list of config names
21
+ (e.g. ``['chunks', 'default', 'reserve']``).
22
+ """
23
+ if not yaml_path.is_file():
24
+ logger.warning("Plot config file not found: %s", yaml_path)
25
+ return []
26
+
27
+ try:
28
+ with open(yaml_path, "r", encoding="utf-8") as f:
29
+ data = yaml.safe_load(f)
30
+ except (yaml.YAMLError, OSError) as exc:
31
+ logger.error("Failed to read plot config %s: %s", yaml_path, exc)
32
+ return []
33
+
34
+ if not isinstance(data, dict):
35
+ return []
36
+
37
+ plots = data.get("plots")
38
+ if not isinstance(plots, dict):
39
+ return []
40
+
41
+ # Flatten new-format entries so we always iterate flat result_keys
42
+ plots = flatten_new_format(plots)
43
+
44
+ config_names: set[str] = set()
45
+ has_default = False
46
+
47
+ for _result_key, result_value in plots.items():
48
+ if not isinstance(result_value, dict):
49
+ continue
50
+
51
+ # Look at the first sub-key to decide whether this entry is a
52
+ # bare plot definition (belongs to ``default``) or a mapping of
53
+ # config-name -> plot definition.
54
+ first_subkey = next(iter(result_value), None)
55
+ if first_subkey == "plot_name":
56
+ # Direct plot definition -> part of the implicit "default" config
57
+ has_default = True
58
+ else:
59
+ # Each sub-key is a config name (e.g. "default", "chunks",
60
+ # "debug", "reserve", ...).
61
+ for config_name in result_value:
62
+ config_names.add(config_name)
63
+
64
+ # If any entries had a bare plot_name, they belong to "default".
65
+ if has_default:
66
+ config_names.add("default")
67
+
68
+ return sorted(config_names)
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class PlotSettings:
8
+ """Settings for a set of plots (single scenario or comparison)."""
9
+ start_time: int = 0
10
+ duration: int = 0
11
+ config_file: str = ""
12
+ active_configs: list[str] = field(default_factory=list)
13
+ dispatch_plots: bool = True # comparison only: include --dispatch-plots
14
+ only_first_file: bool = False # limit to one file per plot (quick overview)
15
+ # Per-variant duration, keyed by variant letter (e.g. {"h": 168, "w": 8760}).
16
+ # Only ints — "all" sentinel from the template is resolved to a concrete
17
+ # integer the first time the variant is seen and stored here.
18
+ variant_durations: dict[str, int] = field(default_factory=dict)
19
+
20
+
21
+ @dataclass
22
+ class ViewerSettings:
23
+ """Settings for the result viewer window."""
24
+ last_scenario: str = ""
25
+ last_entry: str = "" # e.g., "0.0"
26
+ last_variant: str = "" # e.g., "t"
27
+ last_mode: str = "single" # "single", "comparison", "network"
28
+ # Saved viewer-window placement, keyed by monitor configuration
29
+ # (ui_metrics.monitor_signature): {signature -> "WxH+X+Y"}.
30
+ window_geometry: dict[str, str] = field(default_factory=dict)
31
+ left_pane_width: int = 0 # saved horizontal sash position (0 = use default)
32
+ scenario_pane_height: int = 0 # saved vertical sash in left column (0 = default)
33
+ # cw (TkDefaultFont.measure("0")) at the time window_geometry / sash
34
+ # positions were saved. 0 means unknown — use raw saved values.
35
+ layout_cw: int = 0
36
+ cache_gb: float = 0.5 # plot cache memory limit in GB
37
+
38
+
39
+ @dataclass
40
+ class ScenarioRun:
41
+ """Recorded resource usage from a previous successful scenario run.
42
+
43
+ Persisted in settings.yaml under ``scenario_resource_history``, keyed
44
+ by output subdir. Used by the execution manager to set a learned
45
+ memory budget for the next run of the same scenario.
46
+ """
47
+ peak_rss_mb: float = 0.0 # high-water mark observed by MemoryWatchdog
48
+ runtime_s: float = 0.0 # wall-clock seconds from start_time to end_time
49
+ last_run: str = "" # ISO-8601 local timestamp, e.g. "2026-04-25T14:30:15"
50
+
51
+
52
+ @dataclass
53
+ class ProjectSettings:
54
+ """Per-project settings stored in settings.yaml."""
55
+ # Auto-generate flags
56
+ auto_generate_scen_plots: bool = True
57
+ auto_generate_scen_excels: bool = False
58
+ auto_generate_scen_csvs: bool = True
59
+ auto_generate_comp_plots: bool = True
60
+ auto_generate_comp_excel: bool = False
61
+ # SpineDB results database. Produced only during the solve (the writer
62
+ # needs the live s/par namespaces), so it has no parquet-based regen
63
+ # path — unlike the other outputs above. One results.sqlite per project,
64
+ # each scenario appended as its own alternative.
65
+ auto_generate_comp_spinedb: bool = False
66
+
67
+ # Diagnostic verbosity for scenario execution. Controlled by the
68
+ # "Debug" radio group in the main window. Values mirror the CLI
69
+ # ``--debug`` flag in ``flextool/cli/cmd_run_flextool.py``:
70
+ #
71
+ # "off" — no extra flags appended.
72
+ # "basic" — appends ``--debug=basic`` (verbose memory checkpoints
73
+ # + DEBUG log level; no tracemalloc overhead).
74
+ # "full" — appends ``--debug=full --csv-dump`` (basic + tracemalloc
75
+ # diagnostics CSV + retained intermediate CSVs). Slow;
76
+ # reserved for allocation-regression investigations.
77
+ debug_level: str = "off"
78
+
79
+ # When True, scenario execution runs with --save-memory: builds the
80
+ # LP, writes MPS, drops everything Python-side, then spawns a HiGHS
81
+ # subprocess to solve. Parent and solver memory no longer compound
82
+ # in the same process address space. Trades ~+30-60 s I/O per
83
+ # sub-solve and warm-LP reuse for substantial peak RSS relief.
84
+ # Controlled by the "Save memory" checkbox in the main window,
85
+ # above "Debug".
86
+ save_memory: bool = False
87
+
88
+ # ── Solver options (CLI knobs surfaced via the "Solver options…"
89
+ # dialog launched from the main window side menu). Values mirror
90
+ # the matching flags in ``flextool/cli/cmd_run_flextool.py`` and
91
+ # are appended by ExecutionManager only when they differ from the
92
+ # defaults below, so the engine command line stays clean on the
93
+ # common path.
94
+ #
95
+ # The previous ``highs_threads`` and ``user_bound_scale`` fields
96
+ # were dropped: HiGHS thread count is the canonical responsibility
97
+ # of ``execution_limits.max_cores_per_job`` (Execution jobs
98
+ # window), and ``user_bound_scale`` overlaps with ``scaling`` for
99
+ # the rare advanced-user case where the autoscaler is bypassed in
100
+ # favour of a hand-set bound scale via ``solver_arguments``.
101
+
102
+ # HiGHS log verbosity (``--solver-log-level``). One of
103
+ # ``"silent"`` | ``"normal"`` | ``"verbose"``. Default "normal";
104
+ # only appended when non-default.
105
+ solver_log_level: str = "normal"
106
+
107
+ # HiGHS wall-clock time limit in whole seconds
108
+ # (``--solver-time-limit``). 0 means "no limit" (the CLI's unset
109
+ # default). Only appended when > 0.
110
+ solver_time_limit: int = 0
111
+
112
+ # HiGHS MIP relative optimality gap (``--solver-mip-gap``), routed to
113
+ # HiGHS' ``mip_rel_gap`` option. ``solver_mip_gap_set`` gates whether
114
+ # the value is sent at all: when True the gap is appended (and 0 is a
115
+ # valid value — solve to a proven exact optimum); when False no
116
+ # override is emitted and the solver_config/<solver>.opt baseline (or
117
+ # the solver's built-in default) governs. Only affects MIP solves
118
+ # (integer investments, unit-commitment / online variables); pure-LP
119
+ # solves ignore it.
120
+ solver_mip_gap_set: bool = True
121
+ solver_mip_gap: float = 0.001
122
+
123
+ # On-disk format used when the solver is dispatched via a matrix
124
+ # file (``--matrix-file-format``). One of ``"mps"`` | ``"lp"``.
125
+ # Default "mps"; only appended when non-default. The in-process
126
+ # vs. file decision itself is implicit (HiGHS + no --save-memory
127
+ # = direct binding; commercial solvers + HiGHS --save-memory =
128
+ # file write using the chosen format).
129
+ matrix_file_format: str = "mps"
130
+
131
+ # FlexTool autoscaler strategy (``--scaling``). One of
132
+ # ``"off"`` | ``"solver_only"`` | ``"basic"`` | ``"full"``.
133
+ # Default "full"; only appended when non-default.
134
+ scaling: str = "full"
135
+
136
+ # HiGHS ``presolve`` override (``--presolve``). One of
137
+ # ``"on"`` | ``"off"`` | ``"choose"``. All three are appended to
138
+ # the engine command line: "choose" is HiGHS' native default and
139
+ # lets the solver decide per-problem, overriding the engine's
140
+ # determinism-pinned "on" baseline (that pin only governs the test
141
+ # gate, which does not go through this CLI path). Default "choose".
142
+ presolve: str = "choose"
143
+
144
+ # ── Calibrate-investments dialog settings (surfaced via the
145
+ # "Calibrate investments…" dialog). Representative-period build knobs
146
+ # (calib_rp_*), the set of solves the calibrator iterates over
147
+ # (calib_selected_solves), and the iteration / sizing / damping /
148
+ # stall-guard controls. Persisted so the dialog reopens with the
149
+ # user's last choices.
150
+ calib_rp_n_rp: int = 20
151
+ calib_rp_period_length: int = 108
152
+ calib_rp_force_sustained: bool = True
153
+ calib_rp_force_peak: bool = False
154
+ calib_rp_force_window: int = 24
155
+ # Disposition of the freshly built RP alternative:
156
+ # "detached" — create the alternative but attach it to nothing;
157
+ # "add" — append it to each selected scenario's stack (default,
158
+ # so its investment solve immediately uses the periods);
159
+ # "new_scenario" — leave the selected scenarios untouched and clone each
160
+ # into a new "<scenario>_<alternative>" scenario that
161
+ # carries the same stack plus the new alternative on top.
162
+ calib_rp_scenario_mode: str = "add" # "detached" | "add" | "new_scenario"
163
+ calib_selected_solves: list[str] = field(default_factory=list)
164
+ calib_max_iterations: int = 8
165
+ calib_sizing: str = "timed" # "timed" | "uniform"
166
+ calib_overshoot_pct: float = 0.0
167
+ calib_damping_first: float = 1.0
168
+ calib_damping_remaining: float = 0.5
169
+ calib_stall_fraction: float = 0.05
170
+ calib_keep_artifacts: bool = False
171
+
172
+ # Plot settings
173
+ single_plot_settings: PlotSettings = field(default_factory=PlotSettings)
174
+ comparison_plot_settings: PlotSettings = field(default_factory=PlotSettings)
175
+
176
+ # Input source numbers: source name -> number (live sources only).
177
+ input_source_numbers: dict[str, int] = field(default_factory=dict)
178
+
179
+ # Persistent per-number source identity: str(number) -> SourceRecord.
180
+ # Survives file deletion so orphaned results stay attributable and a
181
+ # re-added file reclaims its old number. Garbage-collected once a
182
+ # number has neither a live file nor any results. See SourceRecord.
183
+ source_registry: dict[str, SourceRecord] = field(default_factory=dict)
184
+
185
+ # External input references: source name -> POSIX path relative to project root.
186
+ # Files are read in place (not copied into input_sources/).
187
+ external_refs: dict[str, str] = field(default_factory=dict)
188
+
189
+ # Bare-name ownership for executed scenario folders.
190
+ # Maps scenario_name -> source_number. Scenarios in this map write to
191
+ # ``output_parquet/<name>/`` (no suffix); other sources with the same
192
+ # scenario name write to ``output_parquet/<name>_<src#>/``.
193
+ bare_output_owners: dict[str, int] = field(default_factory=dict)
194
+
195
+ # Ordered list of scenario names for execution
196
+ scenario_order: list[str] = field(default_factory=list)
197
+
198
+ # User-ordered list of executed-scenario names for the result viewer
199
+ # tree (drag/Alt+Up/Down reordering). New scenarios are appended at the
200
+ # end of this list when first seen. Names that no longer exist on disk
201
+ # are pruned at next scan.
202
+ executed_scenario_order: list[str] = field(default_factory=list)
203
+
204
+ # Scenarios used for the last comparison outputs
205
+ comp_plots_scenarios: list[str] = field(default_factory=list)
206
+ comp_excel_scenarios: list[str] = field(default_factory=list)
207
+
208
+ # Scenarios ticked in the result-viewer comparison mode. Distinct from
209
+ # comp_plots_scenarios (which tracks the last run) because the viewer's
210
+ # ticks may evolve independently until a regen/plot is triggered.
211
+ comp_viewer_scenarios: list[str] = field(default_factory=list)
212
+
213
+ # Persisted checkbox states
214
+ checked_input_sources: list[str] = field(default_factory=list) # source names
215
+ checked_available_scenarios: list[str] = field(default_factory=list) # "source_number|name" keys
216
+ checked_executed_scenarios: list[str] = field(default_factory=list) # scenario names
217
+
218
+ # Result viewer settings
219
+ viewer_settings: ViewerSettings = field(default_factory=ViewerSettings)
220
+
221
+ # Per-scenario resource history: output_subdir -> ScenarioRun.
222
+ # Populated after a successful run; consulted at dispatch time so the
223
+ # next run gets a learned memory budget instead of the static auto fallback.
224
+ scenario_resource_history: dict[str, ScenarioRun] = field(default_factory=dict)
225
+
226
+ # Per-project execution limits — primary source of truth for the
227
+ # execution manager. Defaults match the conservative single-job
228
+ # profile the user prefers as a baseline (1 worker, 1 core, auto
229
+ # budget, 0.5 GB reserve, no swap). GlobalSettings still carries
230
+ # the same fields as a legacy fallback; see ExecutionManager.
231
+ execution_limits: "ExecutionLimits" = field(
232
+ default_factory=lambda: ExecutionLimits()
233
+ )
234
+ # Last chosen "Max. parallel executions" for this project. 0 means
235
+ # "use the GlobalSettings fallback"; otherwise an explicit value
236
+ # honoured by ExecutionManager.
237
+ max_workers: int = 1
238
+
239
+ # Transient flag (not persisted): set by execution manager when scenarios
240
+ # finish, cleared by the result viewer when it picks up the changes.
241
+ scenarios_changed: bool = field(default=False, repr=False)
242
+
243
+
244
+ @dataclass
245
+ class ExecutionLimits:
246
+ """Per-machine resource limits for FlexTool subprocess execution.
247
+
248
+ Primary storage is ``ProjectSettings.execution_limits`` (per-project
249
+ settings.yaml). ``GlobalSettings.execution_limits`` is retained as a
250
+ legacy fallback for projects whose settings.yaml predates the
251
+ per-project field. A value of 0 / 0.0 means "auto" (compute at
252
+ dispatch time from system info).
253
+ """
254
+ max_cores_per_job: int = 1 # passed as --highs-threads to each subprocess
255
+ memory_cap_per_job_gb: float = 0.0 # 0 = auto: (system_total - system_reserve_gb) / max_workers
256
+ system_reserve_gb: float = 0.5 # tier 4: leave at least this much free system RAM
257
+ swap_allowance_gb: float = 0.0 # tier 4: 0 = no swap; >0 allowed but warns user
258
+
259
+
260
+ @dataclass
261
+ class GlobalSettings:
262
+ """Global settings stored in projects/projects.yaml."""
263
+ recent_project: str | None = None
264
+ theme: str = "dark" # Valid values: "dark", "light", "os"
265
+ exec_jobs_sash: int = 0 # saved Jobs/Progress sash position (0 = default)
266
+ # cw at the time the exec-jobs geometry/sash was saved. 0 = unknown.
267
+ exec_jobs_layout_cw: int = 0
268
+ # Saved exec-jobs window placement, keyed by monitor configuration
269
+ # (ui_metrics.monitor_signature): {signature -> "WxH+X+Y"}. Each layout
270
+ # remembers its own placement; an unseen layout falls back to the
271
+ # monitor-aware default. Restored clamped/rescaled on open.
272
+ exec_jobs_geometry: dict[str, str] = field(default_factory=dict)
273
+ # Saved main-window placement, same per-configuration scheme as above.
274
+ main_window_geometry: dict[str, str] = field(default_factory=dict)
275
+ # cw at the time main_window_geometry was saved. 0 = unknown.
276
+ main_window_layout_cw: int = 0
277
+ # Legacy fallback for projects whose settings.yaml predates the
278
+ # per-project ``max_workers`` field. 0 means "not set yet".
279
+ max_workers: int = 0
280
+ # Legacy fallback for projects whose settings.yaml predates the
281
+ # per-project ``execution_limits`` field. ExecutionManager reads
282
+ # ProjectSettings first and only consults this when the project
283
+ # field is missing / empty.
284
+ execution_limits: ExecutionLimits = field(default_factory=ExecutionLimits)
285
+ font_size_pt: int = 10 # body / menu / heading base size
286
+ code_font_size_pt: int = 0 # TkFixedFont (logs, code views); 0 = auto = body
287
+ # Check PyPI / the git remote for a newer version once at startup.
288
+ check_updates_on_startup: bool = True
289
+ # env_fingerprint() of the environment for which the polars native
290
+ # self-check last passed. Empty / mismatched => the check re-runs once
291
+ # at startup (so a fresh install or a swapped polars build is verified
292
+ # exactly once). See flextool.env_check.
293
+ polars_check_fingerprint: str = ""
294
+
295
+
296
+ @dataclass
297
+ class SourceRecord:
298
+ """Persistent identity of an input source, keyed by its source number.
299
+
300
+ Stored in ``ProjectSettings.source_registry`` as ``str(number) ->
301
+ SourceRecord``. Unlike ``input_source_numbers`` (a live name→number
302
+ index), this record **survives deletion of the underlying file** so
303
+ that:
304
+
305
+ * executed-scenario folders left on disk (``output_parquet/<name>_N``)
306
+ can still be attributed to a named file (drives the greyed-out
307
+ "ghost" rows in the input-source list), and
308
+ * re-adding the same file reclaims its original number instead of
309
+ drifting to a new one (the renumbering bug).
310
+
311
+ A record is garbage-collected from settings.yaml on the next
312
+ ``refresh()`` once **neither** a live file **nor** any executed-
313
+ scenario results reference its number — so empty ghosts never
314
+ accumulate. The "retired" state is derived per refresh from
315
+ ``(has live file?, has results?)``; it is not persisted here.
316
+ """
317
+ name: str = ""
318
+ # Project-root-relative POSIX path (e.g. "input_sources/examples.sqlite"
319
+ # for an internal file, "../data/input.xlsx" for an external one).
320
+ # Empty when the path is unknown (legacy orphan with no recorded owner).
321
+ path: str = ""
322
+
323
+
324
+ @dataclass
325
+ class InputSourceInfo:
326
+ """Information about an input source file."""
327
+ name: str
328
+ file_type: str # "xlsx" or "sqlite"
329
+ number: int
330
+ status: str # "ok", "error", "empty", "editing", or "retired"
331
+ scenarios: list[str] = field(default_factory=list)
332
+ # Set when the source lives outside the project; stored as POSIX path
333
+ # relative to the project root (e.g. "../data/input.xlsx").
334
+ external_rel_path: str | None = None
335
+ # True for a "ghost" row: the input file is gone but executed-scenario
336
+ # results still reference this number. Such rows are informational
337
+ # (greyed out, not runnable) and disappear once the results are removed.
338
+ retired: bool = False
339
+ # Number of executed-scenario result folders that resolve to this
340
+ # source number (used for the ghost-row tooltip / GC decision).
341
+ result_count: int = 0
342
+
343
+
344
+ @dataclass
345
+ class ScenarioInfo:
346
+ """Information about an available scenario."""
347
+ name: str
348
+ source_number: int
349
+ source_name: str
350
+
351
+
352
+ @dataclass
353
+ class ExecutedScenarioInfo:
354
+ """Information about an executed scenario with results."""
355
+ name: str
356
+ source_number: int
357
+ timestamp: str
358
+ has_plots: bool = False
359
+ has_excel: bool = False
360
+ has_csvs: bool = False
361
+ has_comp_plots: bool = False
362
+ has_comp_excel: bool = False
@@ -0,0 +1,202 @@
1
+ """Manages Spine DB Editor instances and detects potential uncommitted changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class DbEditorManager:
16
+ """Manages communication with Spine DB Editor instances.
17
+
18
+ Tracks launched editor processes so the GUI can:
19
+ - Warn when a user tries to execute scenarios whose source database
20
+ is still open in an editor.
21
+ - Open additional databases by launching new editor instances.
22
+
23
+ Spine DB Editor (``spine-db-editor``) accepts multiple database URLs
24
+ on the command line and opens them as tabs (use ``-s`` for separate
25
+ tabs). However, there is no IPC mechanism to add a tab to an
26
+ already-running instance, so each call to :meth:`open_database`
27
+ launches a new process.
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ # source_name -> list of Popen objects (a source can be opened multiple times)
32
+ self._processes: dict[str, list[subprocess.Popen]] = {}
33
+ # Captured stdout/stderr per launched process, so an immediate crash
34
+ # (e.g. an incomplete Spine Toolbox install) can be shown in the GUI
35
+ # instead of only printing a traceback to the launching terminal.
36
+ self._launch_logs: dict[subprocess.Popen, Path] = {}
37
+
38
+ # ── Opening databases ─────────────────────────────────────────
39
+
40
+ def open_database(self, db_url: str, source_name: str) -> subprocess.Popen | None:
41
+ """Launch ``spine-db-editor`` for *db_url* and track the process.
42
+
43
+ The editor's own stdout/stderr is redirected to a temporary log file
44
+ so that, if it exits immediately, the caller can read the failure via
45
+ :meth:`read_launch_output` and surface it to the user.
46
+
47
+ Args:
48
+ db_url: SQLAlchemy-style database URL (e.g. ``sqlite:///path``).
49
+ source_name: Logical name used to track which input source is
50
+ being edited.
51
+
52
+ Returns:
53
+ The :class:`subprocess.Popen` object, or ``None`` if the
54
+ editor executable is not found on ``PATH``.
55
+ """
56
+ exe = shutil.which("spine-db-editor")
57
+ if exe is None:
58
+ logger.warning("spine-db-editor not found on PATH")
59
+ return None
60
+
61
+ log_path: Path | None = None
62
+ log_handle = None
63
+ try:
64
+ fd, name = tempfile.mkstemp(prefix="flextool_db_editor_", suffix=".log")
65
+ log_path = Path(name)
66
+ log_handle = os.fdopen(fd, "wb")
67
+ except OSError:
68
+ logger.debug("Could not create db-editor launch log", exc_info=True)
69
+ log_path = None
70
+
71
+ try:
72
+ if log_handle is not None:
73
+ proc = subprocess.Popen(
74
+ [exe, db_url], stdout=log_handle, stderr=subprocess.STDOUT
75
+ )
76
+ else:
77
+ proc = subprocess.Popen([exe, db_url])
78
+ except OSError:
79
+ logger.warning("Failed to launch spine-db-editor", exc_info=True)
80
+ if log_path is not None:
81
+ try:
82
+ log_path.unlink(missing_ok=True)
83
+ except OSError:
84
+ pass
85
+ return None
86
+ finally:
87
+ # The child inherits its own dup of the fd; close the parent copy.
88
+ if log_handle is not None:
89
+ log_handle.close()
90
+
91
+ if log_path is not None:
92
+ self._launch_logs[proc] = log_path
93
+ self._processes.setdefault(source_name, []).append(proc)
94
+ self._reap_dead(source_name)
95
+ return proc
96
+
97
+ def read_launch_output(self, proc: subprocess.Popen) -> str:
98
+ """Return (and consume) the captured launch output for *proc*.
99
+
100
+ Used by the GUI when an editor process died right after launch, to
101
+ show the user why. The temporary log is removed once read.
102
+ """
103
+ log_path = self._launch_logs.pop(proc, None)
104
+ if log_path is None:
105
+ return ""
106
+ text = ""
107
+ try:
108
+ if log_path.exists():
109
+ text = log_path.read_text(errors="replace")
110
+ except OSError:
111
+ logger.debug("Could not read db-editor launch log", exc_info=True)
112
+ finally:
113
+ try:
114
+ log_path.unlink(missing_ok=True)
115
+ except OSError:
116
+ pass
117
+ return text
118
+
119
+ # ── Status queries ────────────────────────────────────────────
120
+
121
+ def is_editor_running(self, source_name: str) -> bool:
122
+ """Return ``True`` if at least one editor process is alive for *source_name*."""
123
+ self._reap_dead(source_name)
124
+ procs = self._processes.get(source_name, [])
125
+ return any(p.poll() is None for p in procs)
126
+
127
+ def has_uncommitted_changes(self, db_path: Path) -> bool:
128
+ """Heuristic check for potential uncommitted changes.
129
+
130
+ The Spine DB Editor keeps uncommitted edits in an in-memory
131
+ SQLAlchemy session, so there is **no reliable** way to detect
132
+ them from outside the process. This method uses two heuristics:
133
+
134
+ 1. **Process tracking** -- if we launched an editor for the
135
+ database and it is still running, we conservatively assume
136
+ there *may* be uncommitted changes.
137
+ 2. **SQLite artefact files** -- the presence of a non-empty
138
+ ``-journal`` or ``-wal`` file alongside the database can
139
+ indicate an open write transaction (though these can also
140
+ linger after a crash).
141
+
142
+ Both checks are combined: if either signals a potential issue,
143
+ ``True`` is returned.
144
+ """
145
+ source_name = db_path.name
146
+
147
+ # Check 1: Is an editor process we launched still running?
148
+ if self.is_editor_running(source_name):
149
+ return True
150
+
151
+ # Check 2: SQLite journal / WAL artefacts
152
+ journal = db_path.parent / (db_path.name + "-journal")
153
+ wal = db_path.parent / (db_path.name + "-wal")
154
+ for artefact in (journal, wal):
155
+ try:
156
+ if artefact.exists() and artefact.stat().st_size > 0:
157
+ return True
158
+ except OSError:
159
+ pass
160
+
161
+ return False
162
+
163
+ # ── Cleanup ───────────────────────────────────────────────────
164
+
165
+ def cleanup(self) -> None:
166
+ """Stop tracking all processes (without killing them)."""
167
+ self._processes.clear()
168
+ for log_path in self._launch_logs.values():
169
+ try:
170
+ log_path.unlink(missing_ok=True)
171
+ except OSError:
172
+ pass
173
+ self._launch_logs.clear()
174
+
175
+ # ── Internals ─────────────────────────────────────────────────
176
+
177
+ def _reap_dead(self, source_name: str) -> None:
178
+ """Remove finished processes from the tracking list.
179
+
180
+ A process that exited cleanly has its launch log discarded here; a
181
+ non-zero exit keeps its log so :meth:`read_launch_output` can still
182
+ surface the failure to the user.
183
+ """
184
+ procs = self._processes.get(source_name)
185
+ if procs is None:
186
+ return
187
+ alive: list[subprocess.Popen] = []
188
+ for p in procs:
189
+ if p.poll() is None:
190
+ alive.append(p)
191
+ elif p.returncode == 0:
192
+ self._drop_log(p)
193
+ self._processes[source_name] = alive
194
+
195
+ def _drop_log(self, proc: subprocess.Popen) -> None:
196
+ """Discard the captured launch log for *proc*, if any."""
197
+ log_path = self._launch_logs.pop(proc, None)
198
+ if log_path is not None:
199
+ try:
200
+ log_path.unlink(missing_ok=True)
201
+ except OSError:
202
+ pass