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,424 @@
1
+ """Reusable launcher for RP-preprocess and calibration auxiliary jobs.
2
+
3
+ The ``CalibrateDialog`` (a separate task) resolves per-scenario knobs and then
4
+ calls :func:`launch_rp_jobs` / :func:`launch_calibration_jobs` from here. This
5
+ module is the ONLY place that turns those resolved knobs into running
6
+ subprocesses; it does not re-render command lines — it delegates to the single
7
+ argv renderers in :mod:`flextool.gui.calibrate_commands`.
8
+
9
+ Why NOT the scheduler
10
+ ---------------------
11
+ ``ExecutionManager``'s scheduler dispatches only ``JobType.SCENARIO`` jobs
12
+ (``_pick_next_pending`` filters SCENARIO; ``any_active`` is SCENARIO-only). RP
13
+ and calibration runs therefore use the *auxiliary-job* pattern:
14
+ ``add_auxiliary_job`` registers a tracked, streamable job entry, and THIS module
15
+ runs the subprocess in its own background thread — draining stdout line by line
16
+ into ``append_stdout`` and finalising with ``finish_job``. Aux jobs are
17
+ rendered generically by the execution window (only SCENARIO is special-cased),
18
+ so they reuse ``JobType.OUTPUT_ACTION`` and carry a descriptive
19
+ ``display_name``; no new ``JobType`` is required.
20
+
21
+ Concurrency cap
22
+ ---------------
23
+ Aux jobs bypass the scheduler's ``max_workers`` gate, so naively threading N
24
+ calibrations would launch all N heavy solves at once and thrash memory. Each
25
+ job kind is funnelled through a single module-global FIFO worker
26
+ (:class:`_SerialRunner`): calibrations run strictly one at a time, and RP jobs
27
+ (lighter, but serialized for the same simplicity) likewise. The cap is the
28
+ runner, not an incidental side effect — submitting M jobs enqueues M callables
29
+ that the one worker thread drains sequentially. Separate runners for RP and
30
+ calibration mean the two kinds don't block each other, only themselves.
31
+
32
+ Watchdog interaction
33
+ --------------------
34
+ ``MemoryWatchdog`` picks its kill victim as the RUNNING job with the largest
35
+ ``rss - memory_cap_gb`` overage under global memory pressure. A long
36
+ calibration has ``memory_cap_gb == 0`` (no reliable estimate), so its overage
37
+ would equal its whole RSS — it would be the automatic victim and get killed
38
+ mid-run. We take the smallest, least-surprising fix: mark these aux jobs
39
+ ``watchdog_exempt = True`` (a one-field addition on ``ExecutionJob``). The
40
+ watchdog still tracks their peak RSS but never selects them to kill. We chose
41
+ this over faking a ``memory_cap_gb`` estimate (Option A) because a made-up
42
+ estimate would still be fragile — a calibration that legitimately grows past it
43
+ would become the victim again — whereas the exemption states the intent
44
+ directly: these user-initiated, non-scenario runs are not memory-managed.
45
+
46
+ Thread safety
47
+ -------------
48
+ All subprocess work happens off the Tk thread. ``append_stdout`` and
49
+ ``finish_job`` lock internally, so the worker can call them freely.
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import logging
54
+ import os
55
+ import queue
56
+ import re
57
+ import subprocess
58
+ import threading
59
+ from collections.abc import Callable, Sequence
60
+ from dataclasses import dataclass, field
61
+ from pathlib import Path
62
+
63
+ from flextool.gui.calibrate_commands import (
64
+ build_calibrate_command,
65
+ build_rp_command,
66
+ command_to_display_string,
67
+ overshoot_pct_to_multiplier,
68
+ )
69
+ from flextool.gui.execution_manager import ExecutionManager, JobType
70
+
71
+ logger = logging.getLogger(__name__)
72
+
73
+
74
+ # --------------------------------------------------------------------------- #
75
+ # Job specifications (what the dialog hands us)
76
+ # --------------------------------------------------------------------------- #
77
+
78
+ @dataclass
79
+ class RpJobSpec:
80
+ """One representative-periods preprocess run.
81
+
82
+ ``db_url`` / ``scenario`` identify the target; the remaining fields are the
83
+ resolved knobs consumed verbatim by :func:`build_rp_command`. RP writes back
84
+ into the database, so no per-scenario output directories are needed.
85
+ """
86
+
87
+ db_url: str
88
+ scenario: str
89
+ n_rp: int
90
+ period_length: int
91
+ force_sustained: bool = False
92
+ force_peak: bool = False
93
+ force_window: int | None = None
94
+ solves: Sequence[str] = field(default_factory=list)
95
+ alternative_name: str | None = None
96
+ alternative_description: str | None = None
97
+ # Optional post-success hook, invoked with this spec's ``scenario`` AFTER
98
+ # the RP subprocess exits 0 and the job is finalised SUCCESS. The dialog
99
+ # uses it to append the freshly written RP alternative onto the scenario's
100
+ # stack (``add_alternative_to_scenario``) — a step that must NOT run before
101
+ # the async subprocess finishes, or "Run calibrations" would not see the
102
+ # new periods. It runs on the RP worker thread (off the Tk thread); any
103
+ # exception it raises is caught and streamed into the job log rather than
104
+ # crashing the worker (see ``_run_aux_subprocess``).
105
+ on_success: Callable[[str], None] | None = None
106
+
107
+
108
+ @dataclass
109
+ class CalibJobSpec:
110
+ """One calibration run.
111
+
112
+ ``db_url`` / ``scenario`` identify the target. ``overshoot_pct`` is the
113
+ dialog's planning-safety-margin PERCENT (0 = off); the launcher converts it
114
+ to the CLI multiplier via :func:`overshoot_pct_to_multiplier`. The three
115
+ per-scenario directories are derived under the project path by the launcher
116
+ (see :func:`_calib_dirs`) — leave them ``None`` to use the derived defaults,
117
+ or set them to override.
118
+ """
119
+
120
+ db_url: str
121
+ scenario: str
122
+ iterations: int
123
+ sizing: str = "uniform"
124
+ overshoot_pct: float = 0.0
125
+ damping_first: float = 1.0
126
+ damping_remaining: float = 0.5
127
+ stall_fraction: float = 0.05
128
+ debug: bool = False
129
+ # Which formats the calibrator regenerates from the final parquet after the
130
+ # loop (the project's "File outputs" choices, parquet excluded — always
131
+ # present). ``None`` = inherit the CLI default (csv); ``[]`` = skip (leave
132
+ # results parquet-only); non-empty = those formats.
133
+ final_write_methods: list[str] | None = None
134
+ warm_start_cache_dir: str | None = None
135
+ work_dir: str | None = None
136
+ output_location: str | None = None
137
+
138
+
139
+ # --------------------------------------------------------------------------- #
140
+ # Per-scenario directory derivation
141
+ # --------------------------------------------------------------------------- #
142
+
143
+ _SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
144
+
145
+
146
+ def _safe_name(scenario: str) -> str:
147
+ """Filesystem-safe slug for a scenario name (spaces / slashes → ``_``)."""
148
+ slug = _SAFE_RE.sub("_", scenario).strip("_")
149
+ return slug or "scenario"
150
+
151
+
152
+ def _calib_dirs(
153
+ project_path: Path, scenario: str, *, create: bool = True
154
+ ) -> tuple[str, str, str]:
155
+ """Return ``(warm_start_cache_dir, work_dir, output_location)`` for a scenario.
156
+
157
+ * ``work_dir`` — ``<project>/work/calibrate_<scenario>`` (per-scenario, so
158
+ concurrent-nothing but repeat runs of different scenarios don't collide).
159
+ * ``warm_start_cache_dir`` — ``<work_dir>/warm_start_cache`` (the calibrate
160
+ CLI's own default relative to the work dir; made explicit here so the
161
+ copied command line is self-contained).
162
+ * ``output_location`` — the project root. The calibrate CLI writes solve
163
+ results under ``<output-location>/output_parquet/<scenario>/`` and report
164
+ CSVs under ``<output-location>/calibration_reports/<scenario>/``, matching
165
+ the scenario runner's convention of one shared output root (both trees are
166
+ per-scenario so calibrating several scenarios never overwrites).
167
+
168
+ When ``create`` is True (the launch path) all three directories are created
169
+ eagerly so the subprocess never races to ``mkdir`` a parent. The live CLI
170
+ preview passes ``create=False`` so refreshing it on every keystroke does
171
+ not litter empty directories.
172
+ """
173
+ safe = _safe_name(scenario)
174
+ work_dir = project_path / "work" / f"calibrate_{safe}"
175
+ warm_start = work_dir / "warm_start_cache"
176
+ output_location = project_path
177
+ if create:
178
+ for d in (work_dir, warm_start, output_location / "output_parquet"):
179
+ d.mkdir(parents=True, exist_ok=True)
180
+ return str(warm_start), str(work_dir), str(output_location)
181
+
182
+
183
+ # --------------------------------------------------------------------------- #
184
+ # Serial FIFO worker (the concurrency cap)
185
+ # --------------------------------------------------------------------------- #
186
+
187
+ class _SerialRunner:
188
+ """A single background thread draining a FIFO queue of callables.
189
+
190
+ Guarantees that at most one submitted callable runs at a time and that they
191
+ run in submission order. The worker is a lazily-started daemon thread that
192
+ parks on the queue when idle; it dies with the process. ``submit`` is
193
+ thread-safe. ``wait`` (test helper) blocks until the queue is drained.
194
+ """
195
+
196
+ def __init__(self, name: str) -> None:
197
+ self._name = name
198
+ self._q: queue.Queue = queue.Queue()
199
+ self._lock = threading.Lock()
200
+ self._thread: threading.Thread | None = None
201
+
202
+ def submit(self, fn) -> None:
203
+ self._q.put(fn)
204
+ with self._lock:
205
+ if self._thread is None or not self._thread.is_alive():
206
+ self._thread = threading.Thread(
207
+ target=self._loop, name=self._name, daemon=True
208
+ )
209
+ self._thread.start()
210
+
211
+ def _loop(self) -> None:
212
+ while True:
213
+ fn = self._q.get()
214
+ try:
215
+ fn()
216
+ except Exception: # never let the worker thread die
217
+ logger.exception("%s: job callable raised", self._name)
218
+ finally:
219
+ self._q.task_done()
220
+
221
+ def wait(self, timeout: float | None = None) -> None:
222
+ """Block until every submitted callable has finished (test helper)."""
223
+ # queue.Queue.join() has no timeout; emulate one so tests can't hang.
224
+ if timeout is None:
225
+ self._q.join()
226
+ return
227
+ deadline = threading.Event()
228
+ t = threading.Timer(timeout, deadline.set)
229
+ t.daemon = True
230
+ t.start()
231
+ try:
232
+ while self._q.unfinished_tasks and not deadline.is_set():
233
+ deadline.wait(0.02)
234
+ finally:
235
+ t.cancel()
236
+
237
+
238
+ # Module-global runners: one per job kind, each serialising its own kind across
239
+ # every launch call for the process lifetime.
240
+ _CALIBRATE_RUNNER = _SerialRunner("flextool-calibrate")
241
+ _RP_RUNNER = _SerialRunner("flextool-rp")
242
+
243
+
244
+ # --------------------------------------------------------------------------- #
245
+ # The worker body: run one aux-job subprocess to completion
246
+ # --------------------------------------------------------------------------- #
247
+
248
+ def _run_aux_subprocess(
249
+ mgr: ExecutionManager,
250
+ *,
251
+ display_name: str,
252
+ action_key: str,
253
+ argv: list[str],
254
+ cwd: str,
255
+ on_success: Callable[[], None] | None = None,
256
+ ) -> None:
257
+ """Register an auxiliary job, run ``argv`` as a subprocess, stream + finalise.
258
+
259
+ Robust by construction: any exception is caught, logged into the job's own
260
+ stream, and the job is finalised FAILED — this never raises out of the
261
+ worker thread. A nonzero exit (including the known intermittent exit-144)
262
+ finalises FAILED with the streamed log left intact.
263
+
264
+ ``on_success`` (optional) is a zero-arg callable run AFTER the job is
265
+ finalised SUCCESS, on this same worker thread. It is where post-run wiring
266
+ (e.g. appending the RP alternative to the scenario stack) belongs, so it
267
+ cannot run before the subprocess has actually finished. Its own failure is
268
+ caught and streamed into the job log — it never propagates out of the
269
+ worker and never flips the already-recorded SUCCESS.
270
+ """
271
+ job = mgr.add_auxiliary_job(JobType.OUTPUT_ACTION, display_name, action_key)
272
+ # Mark exempt from the memory watchdog (long run, no cap estimate).
273
+ job.watchdog_exempt = True
274
+
275
+ # Log the exact command first, mirroring the scenario runner.
276
+ mgr.append_stdout(job.job_id, command_to_display_string(argv))
277
+ mgr.append_stdout(job.job_id, "")
278
+
279
+ success = False
280
+ try:
281
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
282
+ proc = subprocess.Popen(
283
+ argv,
284
+ stdout=subprocess.PIPE,
285
+ stderr=subprocess.STDOUT,
286
+ text=True,
287
+ bufsize=1,
288
+ cwd=cwd,
289
+ env=env,
290
+ )
291
+ with mgr._lock:
292
+ job.process = proc
293
+ assert proc.stdout is not None
294
+ for line in proc.stdout:
295
+ mgr.append_stdout(job.job_id, line.rstrip("\n"))
296
+ return_code = proc.wait()
297
+ success = return_code == 0
298
+ if not success:
299
+ mgr.append_stdout(
300
+ job.job_id, f"Process exited with code {return_code}"
301
+ )
302
+ except Exception as exc: # never propagate out of the worker
303
+ logger.exception("Auxiliary job '%s' failed", display_name)
304
+ mgr.append_stdout(job.job_id, f"Job failed with exception: {exc}")
305
+ success = False
306
+ finally:
307
+ with mgr._lock:
308
+ job.process = None
309
+ mgr.finish_job(job.job_id, success)
310
+
311
+ # Post-success wiring runs only after a clean, finalised success. A
312
+ # failure here is surfaced in the job log (not raised) so a broken scenario
313
+ # hand-off is visible without killing the worker or the other queued jobs.
314
+ if success and on_success is not None:
315
+ try:
316
+ on_success()
317
+ except Exception as exc:
318
+ logger.exception("Post-success hook for '%s' failed", display_name)
319
+ mgr.append_stdout(
320
+ job.job_id, f"Post-run wiring failed: {exc}"
321
+ )
322
+
323
+
324
+ # --------------------------------------------------------------------------- #
325
+ # Public entry points
326
+ # --------------------------------------------------------------------------- #
327
+
328
+ def launch_rp_jobs(
329
+ mgr: ExecutionManager,
330
+ *,
331
+ python_exe: str,
332
+ project_path: Path,
333
+ jobs: Sequence[RpJobSpec],
334
+ runner: _SerialRunner | None = None,
335
+ ) -> None:
336
+ """Enqueue one serialized RP-preprocess aux job per scenario.
337
+
338
+ Returns immediately; the jobs run one at a time on the RP runner. The
339
+ optional ``runner`` override exists for tests (inject a local
340
+ :class:`_SerialRunner` to wait on it deterministically).
341
+ """
342
+ run = runner if runner is not None else _RP_RUNNER
343
+ cwd = str(project_path)
344
+ for spec in jobs:
345
+ argv = build_rp_command(
346
+ python_exe,
347
+ spec.db_url,
348
+ spec.scenario,
349
+ n_rp=spec.n_rp,
350
+ period_length=spec.period_length,
351
+ force_sustained=spec.force_sustained,
352
+ force_peak=spec.force_peak,
353
+ force_window=spec.force_window,
354
+ solves=list(spec.solves),
355
+ alternative_name=spec.alternative_name,
356
+ alternative_description=spec.alternative_description,
357
+ )
358
+ display_name = f"RP: {spec.scenario}"
359
+ action_key = f"rp:{spec.scenario}"
360
+ # Bind the spec's (scenario)->None hook into the zero-arg callable the
361
+ # worker invokes, capturing this spec's scenario name. ``None`` when no
362
+ # hook was supplied.
363
+ hook = spec.on_success
364
+ scenario = spec.scenario
365
+ on_success = (
366
+ (lambda h=hook, s=scenario: h(s)) if hook is not None else None
367
+ )
368
+ run.submit(
369
+ lambda mgr=mgr, dn=display_name, ak=action_key, av=argv,
370
+ os_=on_success: (
371
+ _run_aux_subprocess(
372
+ mgr, display_name=dn, action_key=ak, argv=av, cwd=cwd,
373
+ on_success=os_,
374
+ )
375
+ )
376
+ )
377
+
378
+
379
+ def launch_calibration_jobs(
380
+ mgr: ExecutionManager,
381
+ *,
382
+ python_exe: str,
383
+ project_path: Path,
384
+ jobs: Sequence[CalibJobSpec],
385
+ runner: _SerialRunner | None = None,
386
+ ) -> None:
387
+ """Enqueue one serialized calibration aux job per scenario.
388
+
389
+ Returns immediately; calibrations run strictly one at a time on the
390
+ calibration runner. Per-scenario directories are derived (and created)
391
+ under ``project_path`` unless the spec overrides them. The optional
392
+ ``runner`` override exists for tests.
393
+ """
394
+ run = runner if runner is not None else _CALIBRATE_RUNNER
395
+ cwd = str(project_path)
396
+ for spec in jobs:
397
+ warm_start, work_dir, output_location = _calib_dirs(
398
+ project_path, spec.scenario
399
+ )
400
+ argv = build_calibrate_command(
401
+ python_exe,
402
+ spec.db_url,
403
+ spec.scenario,
404
+ iterations=spec.iterations,
405
+ sizing=spec.sizing,
406
+ overshoot=overshoot_pct_to_multiplier(spec.overshoot_pct),
407
+ damping_first=spec.damping_first,
408
+ damping_remaining=spec.damping_remaining,
409
+ stall_fraction=spec.stall_fraction,
410
+ warm_start_cache_dir=spec.warm_start_cache_dir or warm_start,
411
+ work_dir=spec.work_dir or work_dir,
412
+ output_location=spec.output_location or output_location,
413
+ debug=spec.debug,
414
+ final_write_methods=spec.final_write_methods,
415
+ )
416
+ display_name = f"Calibrate: {spec.scenario}"
417
+ action_key = f"calibrate:{spec.scenario}"
418
+ run.submit(
419
+ lambda mgr=mgr, dn=display_name, ak=action_key, av=argv: (
420
+ _run_aux_subprocess(
421
+ mgr, display_name=dn, action_key=ak, argv=av, cwd=cwd
422
+ )
423
+ )
424
+ )
@@ -0,0 +1,142 @@
1
+ """Reusable controller for Treeview widgets with a checkbox column."""
2
+ from __future__ import annotations
3
+
4
+ import tkinter as tk
5
+ from tkinter import ttk
6
+ from typing import Callable
7
+
8
+
9
+ class CheckTreeController:
10
+ """Wires consistent check/uncheck behaviour onto a ttk.Treeview.
11
+
12
+ The tree must have a column dedicated to a check glyph (default
13
+ column id ``"check"``). The controller installs:
14
+
15
+ - A ``<Button-1>`` handler that toggles a single row's check **only
16
+ if the click landed inside the check column**. Clicks anywhere
17
+ else fall through to the Treeview's default selection logic, so
18
+ shift/ctrl multi-select keeps working.
19
+ - A ``<space>`` handler that applies the multi-select toggle rule:
20
+ all selected rows checked -> uncheck all
21
+ all selected rows unchecked -> check all
22
+ mixed -> check all
23
+
24
+ The controller never persists state itself; it calls back into the
25
+ caller via ``on_toggle(changed_iids)`` so the host widget can save
26
+ settings, refresh the display, etc.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ tree: ttk.Treeview,
32
+ *,
33
+ check_column: str = "check",
34
+ checked_glyph: str = "■",
35
+ unchecked_glyph: str = "□",
36
+ on_toggle: Callable[[list[str]], None] | None = None,
37
+ can_check: Callable[[str], bool] | None = None,
38
+ ) -> None:
39
+ self._tree = tree
40
+ self._check_column = check_column
41
+ self._checked = checked_glyph
42
+ self._unchecked = unchecked_glyph
43
+ self._on_toggle = on_toggle
44
+ # Optional predicate: rows for which it returns False can never be
45
+ # checked/unchecked (e.g. informational "ghost" rows).
46
+ self._can_check = can_check
47
+
48
+ tree.bind("<Button-1>", self._on_click, add="+")
49
+ tree.bind("<space>", self._on_space, add="+")
50
+ tree.bind("<Key-space>", self._on_space, add="+")
51
+
52
+ # ---- public helpers --------------------------------------------------
53
+
54
+ def is_checked(self, iid: str) -> bool:
55
+ try:
56
+ value = self._tree.set(iid, self._check_column)
57
+ except tk.TclError:
58
+ return False
59
+ return value == self._checked
60
+
61
+ def set_checked(self, iid: str, value: bool, *, notify: bool = True) -> None:
62
+ glyph = self._checked if value else self._unchecked
63
+ try:
64
+ self._tree.set(iid, self._check_column, glyph)
65
+ except tk.TclError:
66
+ return
67
+ if notify and self._on_toggle is not None:
68
+ self._on_toggle([iid])
69
+
70
+ def toggle_selected(self) -> None:
71
+ sel = list(self._tree.selection())
72
+ if not sel:
73
+ return
74
+ sel = [iid for iid in sel if self._can_check is None or self._can_check(iid)]
75
+ if not sel:
76
+ return
77
+ all_checked = all(self.is_checked(iid) for iid in sel)
78
+ target = not all_checked # all checked -> uncheck all; else check all
79
+ changed: list[str] = []
80
+ for iid in sel:
81
+ if self.is_checked(iid) != target:
82
+ self._tree.set(
83
+ iid,
84
+ self._check_column,
85
+ self._checked if target else self._unchecked,
86
+ )
87
+ changed.append(iid)
88
+ if changed and self._on_toggle is not None:
89
+ self._on_toggle(changed)
90
+
91
+ # ---- internal handlers ----------------------------------------------
92
+
93
+ def _on_click(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
94
+ # Only intercept clicks that land in the check column on a row.
95
+ # Anything else (heading, body of other columns, blank area) is
96
+ # passed through so default Treeview behaviour (single/extend
97
+ # selection) handles it.
98
+ region = self._tree.identify("region", event.x, event.y)
99
+ if region != "cell":
100
+ return None
101
+ column = self._tree.identify_column(event.x)
102
+ # column is like "#1"; resolve to the column id
103
+ col_index_str = column.lstrip("#")
104
+ try:
105
+ col_index = int(col_index_str) - 1
106
+ except ValueError:
107
+ return None
108
+ columns = self._tree["columns"]
109
+ if col_index < 0 or col_index >= len(columns):
110
+ return None
111
+ if columns[col_index] != self._check_column:
112
+ return None
113
+ iid = self._tree.identify_row(event.y)
114
+ if not iid:
115
+ return None
116
+ if self._can_check is not None and not self._can_check(iid):
117
+ return "break" # row is not checkable (e.g. ghost row)
118
+ new_value = not self.is_checked(iid)
119
+ self.set_checked(iid, new_value, notify=True)
120
+ return "break" # don't let default click also alter selection
121
+
122
+ def _on_space(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
123
+ self.toggle_selected()
124
+ return "break"
125
+
126
+
127
+ def install_check_tree(
128
+ tree: ttk.Treeview,
129
+ on_toggle: Callable[[list[str]], None] | None = None,
130
+ *,
131
+ check_column: str = "check",
132
+ checked_glyph: str = "■",
133
+ unchecked_glyph: str = "□",
134
+ ) -> CheckTreeController:
135
+ """Convenience: build + return a controller in one call."""
136
+ return CheckTreeController(
137
+ tree,
138
+ check_column=check_column,
139
+ checked_glyph=checked_glyph,
140
+ unchecked_glyph=unchecked_glyph,
141
+ on_toggle=on_toggle,
142
+ )
@@ -0,0 +1,83 @@
1
+ """Format CLI command lists for readable log output."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def _is_program_group(group: list[str]) -> bool:
6
+ """True when *group* holds the program invocation (``python -m mod`` or
7
+ a ``…/script.py`` form)."""
8
+ return any(tok == "-m" or tok.endswith(".py") for tok in group)
9
+
10
+
11
+ def _split_program_positionals(group: list[str]) -> tuple[list[str], list[str]]:
12
+ """Split a program-invocation *group* into ``(head, positionals)``.
13
+
14
+ ``head`` is the interpreter + ``-m module`` (or ``script.py``) prefix;
15
+ ``positionals`` are the obligatory positional arguments that follow it
16
+ (e.g. the input-DB URL passed to ``cmd_run_flextool``). Flags already
17
+ start their own group upstream, so everything after the program entry
18
+ in this group is positional.
19
+ """
20
+ end: int | None = None
21
+ for i, tok in enumerate(group):
22
+ if tok == "-m" and i + 1 < len(group):
23
+ end = i + 2 # interpreter … -m module
24
+ break
25
+ if tok.endswith(".py"):
26
+ end = i + 1
27
+ break
28
+ if end is None:
29
+ return group, []
30
+ return group[:end], group[end:]
31
+
32
+
33
+ def format_cmd_for_log(cmd: list[str]) -> str:
34
+ """Format a command list into a multi-line string with bash \\ continuations.
35
+
36
+ Groups each ``--flag`` with its argument values on the same line, and
37
+ breaks the program's obligatory positional argument(s) onto their own
38
+ lines, indented one space deeper than flag continuations so they read
39
+ as arguments to the program rather than flags. The result is
40
+ copy-pasteable into a bash terminal.
41
+
42
+ Example::
43
+
44
+ systemd-run --user --scope --quiet \\
45
+ -- /path/python -m flextool.cli.cmd_run_flextool \\
46
+ sqlite:////path/to/input_data.sqlite \\
47
+ --scenario-name DES-Inv \\
48
+ --work-folder /path/to/work
49
+ """
50
+ if not cmd:
51
+ return ''
52
+
53
+ # Group tokens: each ``--flag`` (and the bare ``--`` separator) starts a
54
+ # new group that carries its trailing argument values.
55
+ groups: list[list[str]] = []
56
+ current: list[str] = []
57
+ for token in cmd:
58
+ if token.startswith('--') and current:
59
+ groups.append(current)
60
+ current = [token]
61
+ else:
62
+ current.append(token)
63
+ if current:
64
+ groups.append(current)
65
+
66
+ # Render each group to a (indent, text) continuation line; positional
67
+ # arguments of the program get a deeper indent than flag continuations.
68
+ rendered: list[str] = []
69
+ for idx, group in enumerate(groups):
70
+ indent = '' if idx == 0 else ' '
71
+ if _is_program_group(group):
72
+ head, positionals = _split_program_positionals(group)
73
+ rendered.append(indent + ' '.join(head))
74
+ for pos in positionals:
75
+ rendered.append(' ' + pos)
76
+ else:
77
+ rendered.append(indent + ' '.join(group))
78
+
79
+ if len(rendered) <= 1:
80
+ return rendered[0].strip() if rendered else ''
81
+
82
+ # Join with ``\`` continuations; indents are already baked into each line.
83
+ return ' \\\n'.join(rendered)