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,1259 @@
1
+ """Non-modal "Calibrate investments" dialog.
2
+
3
+ The dialog gathers the representative-period build knobs and the calibration
4
+ controls for one or more scenarios and turns them into auxiliary jobs via
5
+ :mod:`flextool.gui.calibrate_jobs` — it never renders a command line itself
6
+ (the single argv renderers in :mod:`flextool.gui.calibrate_commands` do that,
7
+ for both the launcher and the live CLI previews, so the previewed text can
8
+ never drift from what runs).
9
+
10
+ Why non-modal
11
+ -------------
12
+ The dialog launches long-running solves that stream into the Execution window;
13
+ the user must be able to watch those while the dialog stays open. It is
14
+ therefore ``transient`` to its parent (stacks with it, no taskbar entry on
15
+ some WMs) but takes NO ``grab_set`` — matching the repo rule that ``grab_set``
16
+ in a Toplevel constructor can abort "not viewable", and the non-modal picker
17
+ precedent (``PlotSettingsPicker``).
18
+
19
+ Two independent tools
20
+ ---------------------
21
+ The dialog hosts two separate tools stacked in the same window: a
22
+ representative-periods builder (top) and the adequacy calibrator (bottom).
23
+ They share the scenario set and the solve checklist but are otherwise
24
+ independent — each has its own explanation, its own action button, and its own
25
+ live CLI preview. Both write their result into a NEW alternative, so any change
26
+ made here is undone by deleting that alternative.
27
+
28
+ Dependency injection
29
+ --------------------
30
+ Every external input — the scenarios, the DB-url resolver, the execution
31
+ manager, the python executable, the settings object and its save callback — is
32
+ injected through the constructor, never reached through globals, so the dialog
33
+ can be built headlessly with fakes in a test (see
34
+ ``tests/gui/test_calibrate_dialog.py``).
35
+
36
+ xlsx exclusion
37
+ --------------
38
+ RP and calibration both WRITE into the input database; for an xlsx-backed
39
+ scenario the GUI regenerates an intermediate sqlite from the xlsx on every
40
+ run, discarding those writes. So when ANY provided scenario is xlsx-backed the
41
+ action buttons are disabled with an explanatory reason, and the run/RP/copy
42
+ paths defensively skip xlsx scenarios too.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import logging
47
+ import tkinter as tk
48
+ from collections.abc import Callable, Sequence
49
+ from pathlib import Path
50
+ from tkinter import ttk
51
+ from typing import Any
52
+
53
+ from flextool.gui.calibrate_commands import (
54
+ build_calibrate_command,
55
+ build_rp_command,
56
+ command_to_display_string,
57
+ final_write_methods_from_settings,
58
+ overshoot_pct_to_multiplier,
59
+ )
60
+ from flextool.gui.calibrate_jobs import (
61
+ CalibJobSpec,
62
+ RpJobSpec,
63
+ _calib_dirs,
64
+ launch_calibration_jobs,
65
+ launch_rp_jobs,
66
+ )
67
+ from flextool.gui.hover_tooltip import attach_tooltip
68
+ from flextool.gui.solve_reader import read_scenario_solves
69
+ from flextool.representative_periods.scenario_stack import (
70
+ add_alternative_to_scenario,
71
+ create_scenario_with_alternative,
72
+ dedup_alternative_name,
73
+ existing_alternative_names,
74
+ )
75
+
76
+ logger = logging.getLogger(__name__)
77
+
78
+ # How long (ms) the action buttons stay disabled after a launch, to swallow
79
+ # an accidental double-click without permanently locking the button (the
80
+ # launcher itself serialises, but a duplicate submit would spawn a duplicate
81
+ # job with the same action_key).
82
+ _GUARD_MS = 2500
83
+
84
+ # Pixel width used to wrap the (verbose) hover tooltips so they never run off
85
+ # the screen edge.
86
+ _TIP_WRAP = 380
87
+
88
+ # ── Above-tool explanations (shown as a paragraph at the top of each tool) ──
89
+ _RP_EXPLANATION = (
90
+ "Representative periods compress a long timeline into a handful of short, "
91
+ "weighted periods that stand in for the whole span. Solving over these "
92
+ "instead of every time step makes investment runs far faster while keeping "
93
+ "the demand and weather patterns that drive the result. This tool clusters "
94
+ "the scenario's profiles and inflows and writes the periods into a NEW "
95
+ "alternative; the option below then decides whether that alternative is "
96
+ "left detached, added to the selected scenario(s) so their solve uses the "
97
+ "new periods, or used to spin up new scenarios. Nothing existing is "
98
+ "overwritten: the change lives entirely in the new alternative (and any "
99
+ "new scenario), so you can undo it any time by deleting them."
100
+ )
101
+ _CALIB_EXPLANATION = (
102
+ "Calibration repeatedly solves each scenario and nudges its energy-margin "
103
+ "adder until the system reaches an adequate capacity margin, so the "
104
+ "resulting investments are neither over- nor under-built. Each iteration "
105
+ "reads the previous solve's shortfall and resizes the margin accordingly. "
106
+ "If you built representative periods above and added them to the scenario, "
107
+ "calibration automatically solves over those periods too — so build them "
108
+ "first for a much faster calibration. The margins are written into a NEW "
109
+ "per-scenario alternative ('<scenario>_adeq_calib') and your original data "
110
+ "is left untouched — so, as with the periods above, you can undo everything "
111
+ "by deleting that alternative."
112
+ )
113
+
114
+ # ── Per-control hover tooltips (plain-English, verbose) ─────────────────────
115
+ _TIPS = {
116
+ "n_rp": (
117
+ "How many representative periods to keep. More periods reproduce the "
118
+ "original timeline more faithfully but make every solve slower. A "
119
+ "typical starting point is 5–20 (use more periods for better seasonal "
120
+ "representation, but with shorter periods to maintain solve speed)."
121
+ ),
122
+ "period_length": (
123
+ "How many time steps each representative period spans (e.g. 24 for a "
124
+ "day, 168 for a week). Longer periods capture within-period storage "
125
+ "and ramping behaviour but leave fewer distinct periods to choose "
126
+ "from."
127
+ ),
128
+ "force_sustained": (
129
+ "Also keep the period with the greatest SUSTAINED net load — a long "
130
+ "stretch where demand stays high while wind/solar stay low. Clustering "
131
+ "alone can miss such energy-adequacy stress periods; ticking this "
132
+ "guarantees the worst multi-hour lull is represented, which matters "
133
+ "when sizing storage and firm capacity."
134
+ ),
135
+ "force_peak": (
136
+ "Also keep the period containing the single highest INSTANTANEOUS net "
137
+ "load (the peak hour). This protects capacity adequacy — the moment "
138
+ "the system is most likely to fall short — even if that hour would "
139
+ "otherwise be averaged away by clustering."
140
+ ),
141
+ "force_window": (
142
+ "The length (in time steps) of the rolling window used to score the "
143
+ "'sustained net load' above. A smaller window reacts to short sharp "
144
+ "lulls; a larger one favours long multi-day droughts. Only relevant "
145
+ "when 'Force highest sustained net load' is ticked."
146
+ ),
147
+ "solves": (
148
+ "Which solves get their periods rebuilt. Only these solves have their "
149
+ "'period_timeset' repointed at the new representative periods; other "
150
+ "solves are left untouched. Investment solves are ticked by default "
151
+ "because that is where representative periods usually matter."
152
+ ),
153
+ "rp_mode_detached": (
154
+ "Create the new representative-period alternative but attach it to "
155
+ "nothing. The scenarios run exactly as before until you add the "
156
+ "alternative to a scenario yourself later. Deleting the alternative "
157
+ "fully reverts the change."
158
+ ),
159
+ "rp_mode_add": (
160
+ "Append the new representative-period alternative to each selected "
161
+ "scenario's alternative stack (at the bottom, so it wins — the "
162
+ "bottommost alternative overrides whatever the ones above it set), "
163
+ "meaning the scenario immediately uses the new periods on its next "
164
+ "run. Deleting the alternative fully reverts the change."
165
+ ),
166
+ "rp_mode_new_scenario": (
167
+ "Leave every selected scenario untouched and instead create a NEW "
168
+ "scenario for each — named '<scenario>_<alternative>' — that carries "
169
+ "the same alternative stack plus the new representative-period "
170
+ "alternative on top. Lets you compare the original and the "
171
+ "representative-period runs side by side; delete the new scenario "
172
+ "(and the alternative) to revert."
173
+ ),
174
+ "max_iterations": (
175
+ "The maximum number of solve-and-resize rounds the calibrator runs per "
176
+ "scenario. It stops early once the margin is adequate; this is just "
177
+ "the ceiling so a hard-to-satisfy case cannot loop forever."
178
+ ),
179
+ "sizing": (
180
+ "How the energy-margin adder is shaped. 'timed' places the extra "
181
+ "margin only in the specific periods/time steps that showed a "
182
+ "shortfall (targeted, usually cheaper). 'uniform' adds the same "
183
+ "constant margin to every time step (simpler, more conservative, might "
184
+ "not converge)."
185
+ ),
186
+ "overshoot": (
187
+ "A planning safety margin, in percent, applied on top of the sizing "
188
+ "each iteration. For example 20 means aim 20% above the bare adequate "
189
+ "level, trading a little extra cost for robustness. 0 turns the "
190
+ "overshoot off."
191
+ ),
192
+ "damping_first": (
193
+ "How strongly the FIRST iteration moves toward the newly computed "
194
+ "margin (1.0 = take the full step). The first step often has the "
195
+ "furthest to travel, so it is exposed separately from later steps."
196
+ ),
197
+ "damping_remaining": (
198
+ "How strongly every iteration AFTER the first moves toward the newly "
199
+ "computed margin (e.g. 0.5 = move halfway each round). Lower values "
200
+ "converge more smoothly and avoid overshoot oscillation; higher values "
201
+ "converge faster but can bounce."
202
+ ),
203
+ "stall_fraction": (
204
+ "How sensitive the run is to a resource cap. If an iteration's "
205
+ "shortfall barely improves — changing by less than this fraction — the "
206
+ "calibrator treats the case as capped (e.g. a hard resource or "
207
+ "transmission limit) and stops rather than spending more iterations "
208
+ "chasing a target it cannot reach."
209
+ ),
210
+ "keep_artifacts": (
211
+ "Keep the intermediate files (per-iteration solves, logs, warm-start "
212
+ "caches) instead of cleaning them up. Useful for debugging a "
213
+ "calibration that behaves unexpectedly; leave off for normal runs to "
214
+ "save disk space."
215
+ ),
216
+ }
217
+
218
+
219
+ def _rp_on_success(
220
+ rp_mode: str, db_url: str, scenario_name: str, alt: str
221
+ ) -> Callable[[str], None] | None:
222
+ """Build the post-RP-subprocess hook for the chosen disposition.
223
+
224
+ Returns a ``scenario -> None`` callback run on the worker thread after the
225
+ RP preprocess succeeds, or ``None`` for the detached mode (nothing to do):
226
+
227
+ * ``"add"`` — append the RP alternative onto the scenario's stack;
228
+ * ``"new_scenario"`` — clone the scenario into ``<scenario>_<alt>`` carrying
229
+ the RP alternative, leaving the original untouched;
230
+ * anything else (``"detached"``) — no hook.
231
+
232
+ Kept at module scope (not a per-iteration lambda) so the captured
233
+ ``db_url`` / ``alt`` are bound explicitly rather than by late-binding a loop
234
+ variable.
235
+ """
236
+ if rp_mode == "add":
237
+ def _add(scenario: str) -> None:
238
+ add_alternative_to_scenario(db_url, scenario, alt)
239
+ return _add
240
+ if rp_mode == "new_scenario":
241
+ new_scen = f"{scenario_name}_{alt}"
242
+ def _new(scenario: str) -> None:
243
+ create_scenario_with_alternative(db_url, scenario, new_scen, alt)
244
+ return _new
245
+ return None
246
+
247
+
248
+ class CalibrateDialog(tk.Toplevel):
249
+ """Non-modal dialog to build representative periods and run calibrations.
250
+
251
+ Parameters
252
+ ----------
253
+ parent:
254
+ Owning widget; the window is ``transient`` to it (and centred on it,
255
+ so it opens on the parent's monitor) but NOT modal.
256
+ scenarios:
257
+ The scenarios to operate on. Each must expose a ``name`` attribute and
258
+ an ``is_xlsx`` attribute (``bool``; xlsx-backed scenarios are excluded
259
+ from running — see the module docstring).
260
+ project_path:
261
+ Project root; passed to the launchers, which derive per-scenario work /
262
+ output directories under it.
263
+ settings:
264
+ The :class:`~flextool.gui.data_models.ProjectSettings` whose ``calib_*``
265
+ fields seed every widget and receive every change.
266
+ execution_mgr:
267
+ The ``ExecutionManager`` the launchers register aux jobs on.
268
+ python_exe:
269
+ Interpreter used to spawn the RP / calibrate subprocesses.
270
+ resolve_db_url:
271
+ ``scenario -> db_url`` — resolves a (non-xlsx) scenario object to the
272
+ sqlite URL RP / calibrate read and write.
273
+ save_settings:
274
+ Zero-arg callback persisting ``settings`` to disk; invoked after every
275
+ change and on close.
276
+ show_execution_window:
277
+ Optional zero-arg callback that opens the Execution-jobs window (or
278
+ raises it if already open). Invoked right after an RP / calibration
279
+ launch so the user sees the job stream immediately. ``None`` (the
280
+ headless-test default) skips it.
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ parent: tk.Misc,
286
+ *,
287
+ scenarios: Sequence[Any],
288
+ project_path: Path,
289
+ settings: Any,
290
+ execution_mgr: Any,
291
+ python_exe: str,
292
+ resolve_db_url: Callable[[Any], str],
293
+ save_settings: Callable[[], None],
294
+ show_execution_window: Callable[[], None] | None = None,
295
+ ) -> None:
296
+ super().__init__(parent)
297
+ self.title("Calibrate investments")
298
+
299
+ self._scenarios = list(scenarios)
300
+ self._project_path = Path(project_path)
301
+ self._settings = settings
302
+ self._execution_mgr = execution_mgr
303
+ self._python_exe = python_exe
304
+ self._resolve_db_url = resolve_db_url
305
+ self._save_settings = save_settings
306
+ self._show_execution_window = show_execution_window
307
+
308
+ # Non-modal: transient (stacks with the parent) but NO grab_set.
309
+ self.transient(parent)
310
+
311
+ # In-flight guards: once a launch fires the matching button is disabled
312
+ # for _GUARD_MS to swallow a double-click, then re-enabled.
313
+ self._rp_guarded = False
314
+ self._calib_guarded = False
315
+
316
+ # Lazily-seeded cache of alternative names already present per db_url,
317
+ # used to de-duplicate the derived RP alternative name (append _2/_3…).
318
+ # An RP launch optimistically records the names it reserves here so the
319
+ # next preview / launch sees them without another DB read.
320
+ self._existing_alts: dict[str, set[str]] = {}
321
+
322
+ # Tk variables (created before traces are wired).
323
+ self._var_n_rp = tk.StringVar()
324
+ self._var_period_length = tk.StringVar()
325
+ self._var_force_sustained = tk.BooleanVar()
326
+ self._var_force_peak = tk.BooleanVar()
327
+ self._var_force_window = tk.StringVar()
328
+ self._var_rp_mode = tk.StringVar()
329
+ self._var_max_iterations = tk.StringVar()
330
+ self._var_sizing = tk.StringVar()
331
+ self._var_overshoot_pct = tk.StringVar()
332
+ self._var_damping_first = tk.StringVar()
333
+ self._var_damping_remaining = tk.StringVar()
334
+ self._var_stall_fraction = tk.StringVar()
335
+ self._var_keep_artifacts = tk.BooleanVar()
336
+ self._reason_var = tk.StringVar(value="")
337
+
338
+ # Solve-checklist BooleanVars, keyed by solve name (union across
339
+ # scenarios), and the union order for stable display.
340
+ self._solve_vars: dict[str, tk.BooleanVar] = {}
341
+ self._solve_order: list[str] = []
342
+
343
+ self._seed_from_settings()
344
+ self._build_solve_selection()
345
+
346
+ self._advanced_visible = tk.BooleanVar(value=False)
347
+
348
+ self._build_widgets()
349
+ self._wire_traces()
350
+ self._refresh_run_state()
351
+ self._refresh_cli_preview()
352
+
353
+ self.protocol("WM_DELETE_WINDOW", self._on_close)
354
+ self.bind("<Escape>", lambda _e: self._on_close())
355
+ # One wheel handler on the toplevel catches events bubbling up from any
356
+ # descendant (X11 Button-4/5 + Windows/macOS MouseWheel), routed to the
357
+ # body or the solve list by _on_wheel.
358
+ for seq in ("<MouseWheel>", "<Button-4>", "<Button-5>"):
359
+ self.bind(seq, self._on_wheel)
360
+
361
+ # Cap the height to the screen (small-screen guard) and centre on the
362
+ # parent. Guarded: a headless / detached parent may not report geometry.
363
+ self.update_idletasks()
364
+ self._size_and_center(parent)
365
+
366
+ # ── Seed / model helpers ──────────────────────────────────────────
367
+ def _seed_from_settings(self) -> None:
368
+ """Initialise every scalar widget var from the settings object."""
369
+ s = self._settings
370
+ self._var_n_rp.set(str(s.calib_rp_n_rp))
371
+ self._var_period_length.set(str(s.calib_rp_period_length))
372
+ self._var_force_sustained.set(bool(s.calib_rp_force_sustained))
373
+ self._var_force_peak.set(bool(s.calib_rp_force_peak))
374
+ self._var_force_window.set(str(s.calib_rp_force_window))
375
+ self._var_rp_mode.set(s.calib_rp_scenario_mode or "add")
376
+ self._var_max_iterations.set(str(s.calib_max_iterations))
377
+ self._var_sizing.set(s.calib_sizing or "timed")
378
+ self._var_overshoot_pct.set(str(s.calib_overshoot_pct))
379
+ self._var_damping_first.set(str(s.calib_damping_first))
380
+ self._var_damping_remaining.set(str(s.calib_damping_remaining))
381
+ self._var_stall_fraction.set(str(s.calib_stall_fraction))
382
+ self._var_keep_artifacts.set(bool(s.calib_keep_artifacts))
383
+
384
+ def _build_solve_selection(self) -> None:
385
+ """Union the solves across scenarios; seed each check state.
386
+
387
+ Same-named solves collapse to one row (first appearance keeps the
388
+ order; ``has_invest_periods`` is OR-ed so an invest solve in any
389
+ scenario is treated as invest). Initial check state: if the solve name
390
+ is already in ``settings.calib_selected_solves`` use that; otherwise
391
+ default-check iff it is an investment solve.
392
+ """
393
+ prior = set(self._settings.calib_selected_solves or [])
394
+ invest_flag: dict[str, bool] = {}
395
+ # Per-scenario solve names, so the RP launch/preview passes each
396
+ # scenario ONLY the selected solves that actually belong to it — a
397
+ # union-checklist solve from another scenario must never reach a
398
+ # scenario that lacks it (the preprocess errors on such a solve).
399
+ self._scenario_solves: dict[str, list[str]] = {}
400
+ for sc in self._scenarios:
401
+ if getattr(sc, "is_xlsx", False):
402
+ continue
403
+ name = self._scenario_name(sc)
404
+ try:
405
+ url = self._resolve_db_url(sc)
406
+ solves = read_scenario_solves(url, name)
407
+ except Exception as exc: # a bad DB must not break the dialog
408
+ logger.warning(
409
+ "Could not read solves for scenario %r: %s", name, exc,
410
+ )
411
+ continue
412
+ self._scenario_solves[name] = [info.name for info in solves]
413
+ for info in solves:
414
+ if info.name not in invest_flag:
415
+ self._solve_order.append(info.name)
416
+ invest_flag[info.name] = info.has_invest_periods
417
+ else:
418
+ invest_flag[info.name] |= info.has_invest_periods
419
+
420
+ for name in self._solve_order:
421
+ # calib_selected_solves lists only the CHECKED solves, so presence
422
+ # means checked; a never-seen solve defaults to checked iff invest.
423
+ checked = True if name in prior else invest_flag[name]
424
+ self._solve_vars[name] = tk.BooleanVar(value=checked)
425
+
426
+ @staticmethod
427
+ def _scenario_name(scenario: Any) -> str:
428
+ return getattr(scenario, "name", str(scenario))
429
+
430
+ def _runnable_scenarios(self) -> list[Any]:
431
+ """Non-xlsx scenarios — the ones RP / calibrate may write into."""
432
+ return [
433
+ sc for sc in self._scenarios if not getattr(sc, "is_xlsx", False)
434
+ ]
435
+
436
+ def _selected_solves(self) -> list[str]:
437
+ return [n for n in self._solve_order if self._solve_vars[n].get()]
438
+
439
+ # ── RP alternative naming ──────────────────────────────────────────
440
+ def _base_alt_name(self, scenario_name: str) -> str:
441
+ """Derive the RP alternative base name (before de-duplication).
442
+
443
+ Keyed on the SCENARIO name, plus the representative-period count and
444
+ the period length, e.g. ``coal_rp_40rp_54h`` for scenario ``coal``,
445
+ 40 periods of 54 steps. The scenario — not the solve — is the
446
+ informative discriminator: each scenario is clustered separately from
447
+ its own VRE/demand series, so its representative periods are scenario-
448
+ specific. The solve(s) the periods were built for are recorded in the
449
+ description instead. Distinct scenarios never collide (names are
450
+ unique); a repeat build of the SAME scenario/count/length de-dups to
451
+ ``_2``/``_3``.
452
+ """
453
+ s = self._settings
454
+ return (
455
+ f"{scenario_name}_rp_{s.calib_rp_n_rp}rp_"
456
+ f"{s.calib_rp_period_length}h"
457
+ )
458
+
459
+ def _alt_description(self, scenario_name: str, solves: Sequence[str]) -> str:
460
+ """Verbose description written onto the RP alternative."""
461
+ s = self._settings
462
+ if solves:
463
+ solve_txt = "solve(s) " + ", ".join(f"'{x}'" for x in solves)
464
+ else:
465
+ solve_txt = "all solves carrying a period_timeset"
466
+ return (
467
+ "Representative periods generated with the Calibrate-investments "
468
+ f"tool for scenario '{scenario_name}': {s.calib_rp_n_rp} "
469
+ f"representative period(s) of {s.calib_rp_period_length} time steps "
470
+ f"each, built for {solve_txt}. This alternative can be deleted to "
471
+ "undo the change."
472
+ )
473
+
474
+ def _taken_alt_names(self, db_url: str) -> set[str]:
475
+ """Cached set of alternative names already in *db_url* (lazy-seeded)."""
476
+ if db_url not in self._existing_alts:
477
+ try:
478
+ self._existing_alts[db_url] = existing_alternative_names(db_url)
479
+ except Exception as exc: # a bad DB must not break the preview
480
+ logger.warning(
481
+ "Could not read alternatives for %s: %s", db_url, exc
482
+ )
483
+ self._existing_alts[db_url] = set()
484
+ return self._existing_alts[db_url]
485
+
486
+ def _applicable_solves(self, scenario_name: str) -> list[str]:
487
+ """Selected solves that actually belong to *scenario_name* (in order).
488
+
489
+ The solve checklist is the UNION across scenarios; a scenario only
490
+ gets the selected solves it actually runs. A solve selected but absent
491
+ from this scenario is dropped, so the RP preprocess never receives a
492
+ solve it cannot repoint.
493
+ """
494
+ selected = set(self._selected_solves())
495
+ scen = set(self._scenario_solves.get(scenario_name, []))
496
+ return [n for n in self._solve_order if n in selected and n in scen]
497
+
498
+ def _rp_plan(
499
+ self, *, commit: bool
500
+ ) -> list[tuple[Any, str, str, list[str], str, str]]:
501
+ """Plan one RP job per runnable scenario that has an applicable solve.
502
+
503
+ Returns ``[(scenario, name, db_url, applicable_solves, alt_name,
504
+ description), …]``. A scenario contributes an entry ONLY when at least
505
+ one selected solve belongs to it — so with no matching solve the
506
+ scenario is skipped rather than launched with a solve it cannot use.
507
+
508
+ The alternative name encodes that scenario's applicable solve(s) and is
509
+ de-duplicated (``_2``/``_3``) against the per-db cache; names are
510
+ allocated sequentially through a per-db overlay so two scenarios that
511
+ share a database and a base name get ``base`` then ``base_2``. With
512
+ ``commit`` the reserved names are folded into the persistent cache so
513
+ the next preview / launch sees them (the launch path); the preview
514
+ passes ``commit=False`` so merely looking never reserves.
515
+ """
516
+ overlay: dict[str, set[str]] = {}
517
+ plan: list[tuple[Any, str, str, list[str], str, str]] = []
518
+ for sc in self._runnable_scenarios():
519
+ name = self._scenario_name(sc)
520
+ applicable = self._applicable_solves(name)
521
+ if not applicable:
522
+ continue
523
+ db_url = self._resolve_db_url(sc)
524
+ base = self._base_alt_name(name)
525
+ taken = set(self._taken_alt_names(db_url)) | overlay.get(db_url, set())
526
+ alt = dedup_alternative_name(base, taken)
527
+ overlay.setdefault(db_url, set()).add(alt)
528
+ desc = self._alt_description(name, applicable)
529
+ plan.append((sc, name, db_url, applicable, alt, desc))
530
+ if commit:
531
+ for db_url, names in overlay.items():
532
+ self._existing_alts.setdefault(db_url, set()).update(names)
533
+ return plan
534
+
535
+ # ── Layout ────────────────────────────────────────────────────────
536
+ def _build_widgets(self) -> None:
537
+ pad = dict(padx=10, pady=(6, 0))
538
+
539
+ # Fixed footer (reason + Close) pinned to the bottom OUTSIDE the scroll
540
+ # region, so the Close button is always reachable however tall the body
541
+ # grows. Packed first (side=bottom) to reserve its space.
542
+ self._footer_area = ttk.Frame(self)
543
+ self._footer_area.pack(side="bottom", fill="x")
544
+ ttk.Label(
545
+ self._footer_area, textvariable=self._reason_var,
546
+ foreground="gray", wraplength=560, justify="left",
547
+ ).pack(fill="x", padx=10, pady=(6, 4))
548
+ close_row = ttk.Frame(self._footer_area)
549
+ close_row.pack(fill="x", padx=10, pady=(0, 6))
550
+ ttk.Button(close_row, text="Close", command=self._on_close).pack(
551
+ side="right"
552
+ )
553
+
554
+ # The body scrolls vertically inside a height-capped window — the fixed
555
+ # content (explanations + previews) alone can exceed a 1024-tall screen.
556
+ outer = self._build_scroll_body()
557
+
558
+ # ── 1) Representative periods ─────────────────────────────────
559
+ rp = ttk.LabelFrame(
560
+ outer, text="1 · Build representative periods (optional)"
561
+ )
562
+ rp.pack(fill="x", **pad)
563
+
564
+ # No explicit foreground — a hardcoded gray vanishes against a dark
565
+ # theme; the default label colour tracks the theme in both modes.
566
+ ttk.Label(
567
+ rp, text=_RP_EXPLANATION, wraplength=560, justify="left",
568
+ ).pack(fill="x", padx=8, pady=(8, 4))
569
+
570
+ row = ttk.Frame(rp)
571
+ row.pack(fill="x", padx=8, pady=(4, 4))
572
+ n_lbl = ttk.Label(row, text="Periods (n_rp):")
573
+ n_lbl.pack(side="left")
574
+ n_ent = ttk.Entry(row, textvariable=self._var_n_rp, width=8)
575
+ n_ent.pack(side="left", padx=(4, 16))
576
+ pl_lbl = ttk.Label(row, text="Period length (steps):")
577
+ pl_lbl.pack(side="left")
578
+ pl_ent = ttk.Entry(row, textvariable=self._var_period_length, width=8)
579
+ pl_ent.pack(side="left", padx=(4, 0))
580
+ for w in (n_lbl, n_ent):
581
+ attach_tooltip(w, _TIPS["n_rp"], wraplength=_TIP_WRAP)
582
+ for w in (pl_lbl, pl_ent):
583
+ attach_tooltip(w, _TIPS["period_length"], wraplength=_TIP_WRAP)
584
+
585
+ frow = ttk.Frame(rp)
586
+ frow.pack(fill="x", padx=8, pady=4)
587
+ cb_sust = ttk.Checkbutton(
588
+ frow, text="Force highest sustained net load",
589
+ variable=self._var_force_sustained,
590
+ )
591
+ cb_sust.pack(side="left")
592
+ cb_peak = ttk.Checkbutton(
593
+ frow, text="Force instantaneous peak",
594
+ variable=self._var_force_peak,
595
+ )
596
+ cb_peak.pack(side="left", padx=(16, 0))
597
+ attach_tooltip(cb_sust, _TIPS["force_sustained"], wraplength=_TIP_WRAP)
598
+ attach_tooltip(cb_peak, _TIPS["force_peak"], wraplength=_TIP_WRAP)
599
+
600
+ wrow = ttk.Frame(rp)
601
+ wrow.pack(fill="x", padx=8, pady=4)
602
+ w_lbl = ttk.Label(wrow, text="Window:")
603
+ w_lbl.pack(side="left")
604
+ w_ent = ttk.Entry(wrow, textvariable=self._var_force_window, width=8)
605
+ w_ent.pack(side="left", padx=(4, 16))
606
+ for w in (w_lbl, w_ent):
607
+ attach_tooltip(w, _TIPS["force_window"], wraplength=_TIP_WRAP)
608
+
609
+ # Solve checklist (bounded, scrollable — a long solve list must not
610
+ # push the dialog past small-screen height).
611
+ solves_lbl = ttk.Label(rp, text="Solves to (re)build periods for:")
612
+ solves_lbl.pack(anchor="w", padx=8, pady=(6, 0))
613
+ attach_tooltip(solves_lbl, _TIPS["solves"], wraplength=_TIP_WRAP)
614
+ self._build_solve_checklist(rp)
615
+
616
+ # Three-way disposition of the freshly built RP alternative.
617
+ rb_detached = ttk.Radiobutton(
618
+ rp,
619
+ text="Just make new alternative with the new representative periods",
620
+ value="detached", variable=self._var_rp_mode,
621
+ )
622
+ rb_detached.pack(anchor="w", padx=8, pady=(4, 0))
623
+ attach_tooltip(
624
+ rb_detached, _TIPS["rp_mode_detached"], wraplength=_TIP_WRAP
625
+ )
626
+ rb_add = ttk.Radiobutton(
627
+ rp,
628
+ text="Add the new alternative to the selected scenario(s)",
629
+ value="add", variable=self._var_rp_mode,
630
+ )
631
+ rb_add.pack(anchor="w", padx=8, pady=(2, 0))
632
+ attach_tooltip(rb_add, _TIPS["rp_mode_add"], wraplength=_TIP_WRAP)
633
+ rb_new = ttk.Radiobutton(
634
+ rp,
635
+ text="Make new scenario(s) with the new alternative",
636
+ value="new_scenario", variable=self._var_rp_mode,
637
+ )
638
+ rb_new.pack(anchor="w", padx=8, pady=(2, 2))
639
+ attach_tooltip(
640
+ rb_new, _TIPS["rp_mode_new_scenario"], wraplength=_TIP_WRAP
641
+ )
642
+
643
+ self._rp_button = ttk.Button(
644
+ rp, text="Create new representative periods",
645
+ command=self._on_create_rp,
646
+ )
647
+ self._rp_button.pack(anchor="w", padx=8, pady=(4, 4))
648
+
649
+ # RP CLI preview (live). The commands are typically far wider than the
650
+ # dialog, so the Text is wrap="none" with a horizontal scrollbar.
651
+ self._rp_cli_text = self._make_cli_preview(
652
+ rp, "Representative-periods CLI command:", self._on_copy_rp
653
+ )
654
+
655
+ # ── separator between the two tools ───────────────────────────
656
+ ttk.Separator(outer, orient="horizontal").pack(
657
+ fill="x", padx=10, pady=10
658
+ )
659
+
660
+ # ── 2) Calibration settings ───────────────────────────────────
661
+ cal = ttk.LabelFrame(outer, text="2 · Calibrate investments")
662
+ cal.pack(fill="x", **pad)
663
+
664
+ ttk.Label(
665
+ cal, text=_CALIB_EXPLANATION, wraplength=560, justify="left",
666
+ ).pack(fill="x", padx=8, pady=(8, 4))
667
+
668
+ crow = ttk.Frame(cal)
669
+ crow.pack(fill="x", padx=8, pady=(4, 4))
670
+ it_lbl = ttk.Label(crow, text="Max. iterations:")
671
+ it_lbl.pack(side="left")
672
+ it_ent = ttk.Entry(crow, textvariable=self._var_max_iterations, width=8)
673
+ it_ent.pack(side="left", padx=(4, 16))
674
+ for w in (it_lbl, it_ent):
675
+ attach_tooltip(w, _TIPS["max_iterations"], wraplength=_TIP_WRAP)
676
+ sz_lbl = ttk.Label(crow, text="Sizing:")
677
+ sz_lbl.pack(side="left")
678
+ rb_timed = ttk.Radiobutton(
679
+ crow, text="timed", value="timed", variable=self._var_sizing,
680
+ )
681
+ rb_timed.pack(side="left", padx=(4, 0))
682
+ rb_uniform = ttk.Radiobutton(
683
+ crow, text="uniform", value="uniform", variable=self._var_sizing,
684
+ )
685
+ rb_uniform.pack(side="left", padx=(4, 0))
686
+ for w in (sz_lbl, rb_timed, rb_uniform):
687
+ attach_tooltip(w, _TIPS["sizing"], wraplength=_TIP_WRAP)
688
+
689
+ self._advanced_button = ttk.Button(
690
+ cal, text="Advanced ▸", width=14,
691
+ command=self._toggle_advanced,
692
+ )
693
+ self._advanced_button.pack(anchor="w", padx=8, pady=(4, 2))
694
+
695
+ self._advanced_frame = ttk.Frame(cal)
696
+ # Not packed yet — shown by _toggle_advanced.
697
+ self._build_advanced(self._advanced_frame)
698
+
699
+ cb_keep = ttk.Checkbutton(
700
+ cal, text="Keep per-iteration artifacts",
701
+ variable=self._var_keep_artifacts,
702
+ )
703
+ cb_keep.pack(anchor="w", padx=8, pady=(4, 2))
704
+ attach_tooltip(cb_keep, _TIPS["keep_artifacts"], wraplength=_TIP_WRAP)
705
+
706
+ self._run_button = ttk.Button(
707
+ cal, text="Run calibrations", command=self._on_run,
708
+ )
709
+ self._run_button.pack(anchor="w", padx=8, pady=(4, 4))
710
+
711
+ # Calibration CLI preview (live), same wide-scrolling treatment.
712
+ self._calib_cli_text = self._make_cli_preview(
713
+ cal, "Calibration CLI command:", self._on_copy_calib
714
+ )
715
+ # (The reason label + Close button live in the fixed footer, built at
716
+ # the top of this method outside the scroll region.)
717
+
718
+ def _build_scroll_body(self) -> ttk.Frame:
719
+ """Create the vertically-scrollable body and return its inner frame.
720
+
721
+ A canvas hosts an inner ``ttk.Frame`` (returned) that every section is
722
+ packed into; a vertical scrollbar and the shared wheel handler
723
+ (``_on_wheel``) scroll it. The inner frame is kept exactly as wide as
724
+ the canvas so nothing needs horizontal scrolling at the dialog level
725
+ (the CLI previews scroll horizontally within themselves).
726
+ """
727
+ holder = ttk.Frame(self)
728
+ holder.pack(side="top", fill="both", expand=True)
729
+ canvas = tk.Canvas(holder, highlightthickness=0)
730
+ vsb = ttk.Scrollbar(holder, orient="vertical", command=canvas.yview)
731
+ canvas.configure(yscrollcommand=vsb.set)
732
+ vsb.pack(side="right", fill="y")
733
+ canvas.pack(side="left", fill="both", expand=True)
734
+
735
+ inner = ttk.Frame(canvas, padding=10)
736
+ win = canvas.create_window((0, 0), window=inner, anchor="nw")
737
+ inner.bind(
738
+ "<Configure>",
739
+ lambda _e: canvas.configure(scrollregion=canvas.bbox("all")),
740
+ )
741
+ canvas.bind(
742
+ "<Configure>", lambda e: canvas.itemconfigure(win, width=e.width)
743
+ )
744
+ self._body_canvas = canvas
745
+ self._scroll_inner = inner
746
+ return inner
747
+
748
+ def _on_wheel(self, event: tk.Event) -> None: # type: ignore[type-arg]
749
+ """Route a wheel event to the solve list if over it, else the body.
750
+
751
+ Bound once on the toplevel (so it catches wheel events bubbling up from
752
+ any descendant) — no per-widget Enter/Leave juggling. Handles both the
753
+ X11 ``Button-4/5`` events and the ``MouseWheel`` ``delta`` of
754
+ Windows/macOS.
755
+ """
756
+ num = getattr(event, "num", 0)
757
+ if num == 4:
758
+ step = -1
759
+ elif num == 5:
760
+ step = 1
761
+ else:
762
+ step = -int(event.delta / 120)
763
+ target = self._body_canvas
764
+ node = getattr(event, "widget", None)
765
+ solve_canvas = getattr(self, "_solve_canvas", None)
766
+ while node is not None:
767
+ if node is solve_canvas:
768
+ target = solve_canvas
769
+ break
770
+ node = getattr(node, "master", None)
771
+ if target is not None:
772
+ target.yview_scroll(step, "units")
773
+
774
+ def _build_advanced(self, frame: ttk.Frame) -> None:
775
+ """Build the (initially hidden) advanced-knobs subframe."""
776
+ def _num_row(label: str, var: tk.StringVar, tip: str) -> None:
777
+ r = ttk.Frame(frame)
778
+ r.pack(fill="x", padx=8, pady=2)
779
+ lbl = ttk.Label(r, text=label, width=26, anchor="w")
780
+ lbl.pack(side="left")
781
+ ent = ttk.Entry(r, textvariable=var, width=10)
782
+ ent.pack(side="left")
783
+ for w in (lbl, ent):
784
+ attach_tooltip(w, tip, wraplength=_TIP_WRAP)
785
+
786
+ _num_row(
787
+ "Planning safety margin (%):", self._var_overshoot_pct,
788
+ _TIPS["overshoot"],
789
+ )
790
+ _num_row("Damping first:", self._var_damping_first, _TIPS["damping_first"])
791
+ _num_row(
792
+ "Damping remaining:", self._var_damping_remaining,
793
+ _TIPS["damping_remaining"],
794
+ )
795
+ _num_row(
796
+ "Resource-cap sensitivity:", self._var_stall_fraction,
797
+ _TIPS["stall_fraction"],
798
+ )
799
+
800
+ def _toggle_advanced(self) -> None:
801
+ if self._advanced_visible.get():
802
+ self._advanced_frame.pack_forget()
803
+ self._advanced_visible.set(False)
804
+ self._advanced_button.configure(text="Advanced ▸")
805
+ else:
806
+ self._advanced_frame.pack(
807
+ fill="x", after=self._advanced_button, padx=0, pady=(0, 4)
808
+ )
809
+ self._advanced_visible.set(True)
810
+ self._advanced_button.configure(text="Advanced ▾")
811
+
812
+ # Max solve rows shown before the checklist starts scrolling.
813
+ _MAX_SOLVE_ROWS = 5
814
+
815
+ def _build_solve_checklist(self, parent: tk.Misc) -> None:
816
+ """Render the solve checklist inside a height-bounded, scrollable box.
817
+
818
+ The list shows at most ``_MAX_SOLVE_ROWS`` solves; beyond that a
819
+ vertical scrollbar appears and the box stops growing, so a scenario
820
+ with many solves can never stretch the dialog past a small screen.
821
+ """
822
+ if not self._solve_order:
823
+ ttk.Label(
824
+ parent, text="(no solves found for the selected scenarios)",
825
+ foreground="gray",
826
+ ).pack(anchor="w", padx=16, pady=(2, 4))
827
+ return
828
+
829
+ from flextool.gui.ui_metrics import get_metrics
830
+
831
+ m = get_metrics(self)
832
+ visible = min(len(self._solve_order), self._MAX_SOLVE_ROWS)
833
+ overflow = len(self._solve_order) > self._MAX_SOLVE_ROWS
834
+
835
+ container = ttk.Frame(parent)
836
+ container.pack(fill="x", padx=16, pady=(2, 4))
837
+ canvas = tk.Canvas(
838
+ container, highlightthickness=0, height=m.row_height * visible,
839
+ )
840
+ canvas.pack(side="left", fill="x", expand=True)
841
+ sb = ttk.Scrollbar(
842
+ container, orient="vertical", command=canvas.yview
843
+ )
844
+ canvas.configure(yscrollcommand=sb.set)
845
+
846
+ rows = ttk.Frame(canvas)
847
+ win = canvas.create_window((0, 0), window=rows, anchor="nw")
848
+ # Keep the scrollregion current and the inner frame as wide as the
849
+ # canvas (so the checkbuttons fill the row and hover reads naturally).
850
+ rows.bind(
851
+ "<Configure>",
852
+ lambda _e: canvas.configure(scrollregion=canvas.bbox("all")),
853
+ )
854
+ canvas.bind(
855
+ "<Configure>",
856
+ lambda e: canvas.itemconfigure(win, width=e.width),
857
+ )
858
+ # The shared toplevel wheel handler (_on_wheel) scrolls this canvas
859
+ # when the pointer is over it — no per-widget wheel binding needed.
860
+ self._solve_canvas = canvas
861
+
862
+ for name in self._solve_order:
863
+ cb = ttk.Checkbutton(
864
+ rows, text=name, variable=self._solve_vars[name],
865
+ )
866
+ cb.pack(anchor="w", fill="x")
867
+ attach_tooltip(cb, _TIPS["solves"], wraplength=_TIP_WRAP)
868
+
869
+ # The scrollbar is only useful (and only shown) when the list overflows.
870
+ if overflow:
871
+ sb.pack(side="right", fill="y")
872
+
873
+ def _make_cli_preview(
874
+ self, parent: tk.Misc, label: str, copy_cmd: Callable[[], None]
875
+ ) -> tk.Text:
876
+ """Build a read-only, horizontally-scrollable CLI-preview + Copy button.
877
+
878
+ Returns the disabled :class:`tk.Text` widget so the caller can keep a
879
+ handle for live refreshes. ``wrap="none"`` plus the horizontal
880
+ scrollbar lets the (usually very long) command lines scroll instead of
881
+ being clipped by the dialog width.
882
+ """
883
+ ttk.Label(parent, text=label).pack(anchor="w", padx=8)
884
+ row = ttk.Frame(parent)
885
+ row.pack(fill="both", expand=True, padx=8, pady=(2, 8))
886
+ text_frame = ttk.Frame(row)
887
+ text_frame.pack(side="left", fill="both", expand=True)
888
+ text = tk.Text(text_frame, height=3, wrap="none")
889
+ hsb = ttk.Scrollbar(
890
+ text_frame, orient="horizontal", command=text.xview
891
+ )
892
+ text.configure(xscrollcommand=hsb.set)
893
+ text.pack(side="top", fill="both", expand=True)
894
+ hsb.pack(side="bottom", fill="x")
895
+ text.configure(state="disabled")
896
+ ttk.Button(row, text="Copy", width=8, command=copy_cmd).pack(
897
+ side="left", padx=(6, 0), anchor="n"
898
+ )
899
+ return text
900
+
901
+ # Vertical margin left free below/above the window on a small screen
902
+ # (title bar + panel/taskbar) when the content would otherwise fill it.
903
+ _SCREEN_V_MARGIN = 96
904
+
905
+ def _size_and_center(self, parent: tk.Misc) -> None:
906
+ """Size the window to content but cap the height to the screen.
907
+
908
+ The content width is used as-is; the height is ``min(content, screen -
909
+ margin)`` so on a short screen (≈1024 px) the body scrolls instead of
910
+ the window running off the top and bottom. The window is then centred
911
+ on the parent (placing it on the parent's monitor).
912
+
913
+ The body lives inside a Canvas, which does NOT propagate its scrolled
914
+ content's requested size to the toplevel — so the natural size is
915
+ measured from the inner content frame plus the fixed footer, not from
916
+ ``self.winfo_reqheight()`` (which would report only the canvas default).
917
+ """
918
+ try:
919
+ # +18 px leaves room for the vertical scrollbar beside the canvas.
920
+ dw = self._scroll_inner.winfo_reqwidth() + 18
921
+ content_h = (
922
+ self._scroll_inner.winfo_reqheight()
923
+ + self._footer_area.winfo_reqheight()
924
+ )
925
+ screen_h = self.winfo_screenheight()
926
+ cap_h = min(content_h, max(400, screen_h - self._SCREEN_V_MARGIN))
927
+ self.geometry(f"{dw}x{cap_h}")
928
+ except tk.TclError:
929
+ return
930
+ try:
931
+ px = parent.winfo_rootx()
932
+ py = parent.winfo_rooty()
933
+ pw = parent.winfo_width()
934
+ ph = parent.winfo_height()
935
+ x = px + (pw - dw) // 2
936
+ y = max(0, py + (ph - cap_h) // 2)
937
+ self.geometry(f"+{x}+{y}")
938
+ except tk.TclError:
939
+ pass
940
+
941
+ # ── Persistence ───────────────────────────────────────────────────
942
+ def _wire_traces(self) -> None:
943
+ """Flush to settings on every widget change (and, later, on close)."""
944
+ every = [
945
+ self._var_n_rp, self._var_period_length, self._var_force_sustained,
946
+ self._var_force_peak, self._var_force_window,
947
+ self._var_rp_mode,
948
+ self._var_max_iterations, self._var_sizing, self._var_overshoot_pct,
949
+ self._var_damping_first, self._var_damping_remaining,
950
+ self._var_stall_fraction, self._var_keep_artifacts,
951
+ ]
952
+ for var in (*every, *self._solve_vars.values()):
953
+ var.trace_add("write", lambda *_a: self._flush())
954
+
955
+ @staticmethod
956
+ def _as_int(var: tk.StringVar, fallback: int) -> int:
957
+ """Parse *var* as int, keeping *fallback* on partial / bad input."""
958
+ try:
959
+ return int(float(var.get().strip()))
960
+ except (ValueError, tk.TclError):
961
+ return fallback
962
+
963
+ @staticmethod
964
+ def _as_float(var: tk.StringVar, fallback: float) -> float:
965
+ try:
966
+ return float(var.get().strip())
967
+ except (ValueError, tk.TclError):
968
+ return fallback
969
+
970
+ def _flush(self) -> None:
971
+ """Read every widget into ``settings``, persist, and refresh previews.
972
+
973
+ Numeric fields tolerate mid-edit / empty text by keeping the last good
974
+ value, so a partially typed entry never wipes a setting.
975
+ """
976
+ s = self._settings
977
+ s.calib_rp_n_rp = self._as_int(self._var_n_rp, s.calib_rp_n_rp)
978
+ s.calib_rp_period_length = self._as_int(
979
+ self._var_period_length, s.calib_rp_period_length
980
+ )
981
+ s.calib_rp_force_sustained = bool(self._var_force_sustained.get())
982
+ s.calib_rp_force_peak = bool(self._var_force_peak.get())
983
+ s.calib_rp_force_window = self._as_int(
984
+ self._var_force_window, s.calib_rp_force_window
985
+ )
986
+ rp_mode = self._var_rp_mode.get()
987
+ if rp_mode in ("detached", "add", "new_scenario"):
988
+ s.calib_rp_scenario_mode = rp_mode
989
+ s.calib_max_iterations = self._as_int(
990
+ self._var_max_iterations, s.calib_max_iterations
991
+ )
992
+ sizing = self._var_sizing.get()
993
+ if sizing in ("timed", "uniform"):
994
+ s.calib_sizing = sizing
995
+ s.calib_overshoot_pct = self._as_float(
996
+ self._var_overshoot_pct, s.calib_overshoot_pct
997
+ )
998
+ s.calib_damping_first = self._as_float(
999
+ self._var_damping_first, s.calib_damping_first
1000
+ )
1001
+ s.calib_damping_remaining = self._as_float(
1002
+ self._var_damping_remaining, s.calib_damping_remaining
1003
+ )
1004
+ s.calib_stall_fraction = self._as_float(
1005
+ self._var_stall_fraction, s.calib_stall_fraction
1006
+ )
1007
+ s.calib_keep_artifacts = bool(self._var_keep_artifacts.get())
1008
+ s.calib_selected_solves = self._selected_solves()
1009
+
1010
+ self._refresh_cli_preview()
1011
+
1012
+ try:
1013
+ self._save_settings()
1014
+ except Exception: # persistence must never break the UI
1015
+ logger.exception("Saving calibrate settings failed")
1016
+
1017
+ # ── Enable / disable ──────────────────────────────────────────────
1018
+ def _refresh_run_state(self) -> None:
1019
+ """Set the disabled reason and the button states.
1020
+
1021
+ Both action buttons are disabled when there are no scenarios or ANY
1022
+ scenario is xlsx-backed (its DB writes would be discarded). The
1023
+ per-action in-flight guard disables only the just-clicked button.
1024
+ """
1025
+ xlsx = [
1026
+ self._scenario_name(sc)
1027
+ for sc in self._scenarios
1028
+ if getattr(sc, "is_xlsx", False)
1029
+ ]
1030
+ if not self._scenarios:
1031
+ reason = "No scenarios selected — nothing to calibrate."
1032
+ elif xlsx:
1033
+ reason = (
1034
+ "Disabled: xlsx-backed scenario(s) "
1035
+ f"{', '.join(xlsx)} regenerate their database on each run, "
1036
+ "which would discard the representative-period / calibration "
1037
+ "writes. Convert them to a Spine database first."
1038
+ )
1039
+ else:
1040
+ reason = ""
1041
+ self._reason_var.set(reason)
1042
+
1043
+ base_enabled = reason == ""
1044
+ self._rp_button.configure(
1045
+ state="normal" if base_enabled and not self._rp_guarded
1046
+ else "disabled"
1047
+ )
1048
+ self._run_button.configure(
1049
+ state="normal" if base_enabled and not self._calib_guarded
1050
+ else "disabled"
1051
+ )
1052
+
1053
+ def _guard_rp(self) -> None:
1054
+ self._rp_guarded = True
1055
+ self._refresh_run_state()
1056
+ self.after(_GUARD_MS, self._unguard_rp)
1057
+
1058
+ def _unguard_rp(self) -> None:
1059
+ self._rp_guarded = False
1060
+ if self.winfo_exists():
1061
+ self._refresh_run_state()
1062
+
1063
+ def _guard_calib(self) -> None:
1064
+ self._calib_guarded = True
1065
+ self._refresh_run_state()
1066
+ self.after(_GUARD_MS, self._unguard_calib)
1067
+
1068
+ def _unguard_calib(self) -> None:
1069
+ self._calib_guarded = False
1070
+ if self.winfo_exists():
1071
+ self._refresh_run_state()
1072
+
1073
+ def _reveal_execution_window(self) -> None:
1074
+ """Open / raise the Execution-jobs window so the launch is visible."""
1075
+ if self._show_execution_window is None:
1076
+ return
1077
+ try:
1078
+ self._show_execution_window()
1079
+ except Exception: # never let a UI-raise failure break the launch
1080
+ logger.exception("Opening the execution window failed")
1081
+
1082
+ # ── RP flow ───────────────────────────────────────────────────────
1083
+ def _on_create_rp(self) -> None:
1084
+ """Launch one RP-preprocess job per runnable scenario.
1085
+
1086
+ Each job carries an ``on_success(scenario)`` hook whose behaviour
1087
+ follows the disposition radio; it fires AFTER the RP subprocess
1088
+ finishes successfully, on the worker thread:
1089
+
1090
+ * ``"detached"`` — no hook; the alternative is created but attached to
1091
+ nothing.
1092
+ * ``"add"`` — append the freshly written RP alternative onto that
1093
+ scenario's stack so a subsequent calibration sees the new periods.
1094
+ * ``"new_scenario"`` — clone the scenario into a new
1095
+ ``"<scenario>_<alternative>"`` scenario carrying the RP alternative,
1096
+ leaving the original untouched.
1097
+ """
1098
+ self._flush()
1099
+ s = self._settings
1100
+ rp_mode = self._var_rp_mode.get()
1101
+ # Reserve (commit) the per-scenario names so the preview immediately
1102
+ # advances (base → base_2) and a second launch cannot collide.
1103
+ plan = self._rp_plan(commit=True)
1104
+ if not plan:
1105
+ return
1106
+ specs: list[RpJobSpec] = []
1107
+ for _sc, name, db_url, applicable, alt, desc in plan:
1108
+ on_success = _rp_on_success(rp_mode, db_url, name, alt)
1109
+ specs.append(
1110
+ RpJobSpec(
1111
+ db_url=db_url,
1112
+ scenario=name,
1113
+ n_rp=s.calib_rp_n_rp,
1114
+ period_length=s.calib_rp_period_length,
1115
+ force_sustained=s.calib_rp_force_sustained,
1116
+ force_peak=s.calib_rp_force_peak,
1117
+ force_window=s.calib_rp_force_window,
1118
+ solves=applicable,
1119
+ alternative_name=alt,
1120
+ alternative_description=desc,
1121
+ on_success=on_success,
1122
+ )
1123
+ )
1124
+ launch_rp_jobs(
1125
+ self._execution_mgr,
1126
+ python_exe=self._python_exe,
1127
+ project_path=self._project_path,
1128
+ jobs=specs,
1129
+ )
1130
+ self._guard_rp()
1131
+ self._reveal_execution_window()
1132
+ self._refresh_cli_preview()
1133
+
1134
+ # ── Run flow ──────────────────────────────────────────────────────
1135
+ def _on_run(self) -> None:
1136
+ """Launch one calibration job per runnable scenario."""
1137
+ self._flush()
1138
+ runnable = self._runnable_scenarios()
1139
+ if not runnable:
1140
+ return
1141
+ s = self._settings
1142
+ specs: list[CalibJobSpec] = []
1143
+ for sc in runnable:
1144
+ specs.append(
1145
+ CalibJobSpec(
1146
+ db_url=self._resolve_db_url(sc),
1147
+ scenario=self._scenario_name(sc),
1148
+ iterations=s.calib_max_iterations,
1149
+ sizing=s.calib_sizing,
1150
+ overshoot_pct=s.calib_overshoot_pct,
1151
+ damping_first=s.calib_damping_first,
1152
+ damping_remaining=s.calib_damping_remaining,
1153
+ stall_fraction=s.calib_stall_fraction,
1154
+ debug=s.calib_keep_artifacts,
1155
+ # Regenerate the same formats a regular run would (the
1156
+ # project's "File outputs" choices), from the final parquet.
1157
+ final_write_methods=final_write_methods_from_settings(s),
1158
+ )
1159
+ )
1160
+ launch_calibration_jobs(
1161
+ self._execution_mgr,
1162
+ python_exe=self._python_exe,
1163
+ project_path=self._project_path,
1164
+ jobs=specs,
1165
+ )
1166
+ self._guard_calib()
1167
+ self._reveal_execution_window()
1168
+
1169
+ # ── CLI previews ──────────────────────────────────────────────────
1170
+ def _render_rp_cli(self) -> str:
1171
+ """Render the RP command(s) exactly as "Create…" would run them."""
1172
+ s = self._settings
1173
+ plan = self._rp_plan(commit=False)
1174
+ if not plan:
1175
+ # Distinguish "no runnable scenario" from "no selected solve
1176
+ # applies" so the empty preview is not mysterious.
1177
+ if self._runnable_scenarios():
1178
+ return (
1179
+ "# No selected solve belongs to the selected scenario(s) — "
1180
+ "tick a solve that the scenario actually runs."
1181
+ )
1182
+ return ""
1183
+ lines: list[str] = []
1184
+ for _sc, name, db_url, applicable, alt, desc in plan:
1185
+ argv = build_rp_command(
1186
+ self._python_exe, db_url, name,
1187
+ n_rp=s.calib_rp_n_rp,
1188
+ period_length=s.calib_rp_period_length,
1189
+ force_sustained=s.calib_rp_force_sustained,
1190
+ force_peak=s.calib_rp_force_peak,
1191
+ force_window=s.calib_rp_force_window,
1192
+ solves=applicable,
1193
+ alternative_name=alt,
1194
+ alternative_description=desc,
1195
+ )
1196
+ lines.append(command_to_display_string(argv))
1197
+ return "\n".join(lines)
1198
+
1199
+ def _render_calib_cli(self) -> str:
1200
+ """Render the calibrate command(s) exactly as "Run…" would run them."""
1201
+ s = self._settings
1202
+ lines: list[str] = []
1203
+ for sc in self._runnable_scenarios():
1204
+ name = self._scenario_name(sc)
1205
+ db_url = self._resolve_db_url(sc)
1206
+ # create=False: previewing must not litter empty work directories.
1207
+ warm, work, out = _calib_dirs(
1208
+ self._project_path, name, create=False
1209
+ )
1210
+ argv = build_calibrate_command(
1211
+ self._python_exe, db_url, name,
1212
+ iterations=s.calib_max_iterations,
1213
+ sizing=s.calib_sizing,
1214
+ overshoot=overshoot_pct_to_multiplier(s.calib_overshoot_pct),
1215
+ damping_first=s.calib_damping_first,
1216
+ damping_remaining=s.calib_damping_remaining,
1217
+ stall_fraction=s.calib_stall_fraction,
1218
+ warm_start_cache_dir=warm,
1219
+ work_dir=work,
1220
+ output_location=out,
1221
+ debug=s.calib_keep_artifacts,
1222
+ final_write_methods=final_write_methods_from_settings(s),
1223
+ )
1224
+ lines.append(command_to_display_string(argv))
1225
+ return "\n".join(lines)
1226
+
1227
+ @staticmethod
1228
+ def _set_text(widget: tk.Text, text: str) -> None:
1229
+ widget.configure(state="normal")
1230
+ widget.delete("1.0", "end")
1231
+ widget.insert("1.0", text)
1232
+ widget.configure(state="disabled")
1233
+
1234
+ def _refresh_cli_preview(self) -> None:
1235
+ """Re-render both live CLI previews from the current settings."""
1236
+ # Guard: called from _flush, which can fire via a trace before the text
1237
+ # widgets are built.
1238
+ if not hasattr(self, "_rp_cli_text"):
1239
+ return
1240
+ try:
1241
+ self._set_text(self._rp_cli_text, self._render_rp_cli())
1242
+ self._set_text(self._calib_cli_text, self._render_calib_cli())
1243
+ except tk.TclError:
1244
+ pass
1245
+
1246
+ def _on_copy_rp(self) -> None:
1247
+ self._flush()
1248
+ self.clipboard_clear()
1249
+ self.clipboard_append(self._render_rp_cli())
1250
+
1251
+ def _on_copy_calib(self) -> None:
1252
+ self._flush()
1253
+ self.clipboard_clear()
1254
+ self.clipboard_append(self._render_calib_cli())
1255
+
1256
+ # ── Close ─────────────────────────────────────────────────────────
1257
+ def _on_close(self) -> None:
1258
+ self._flush()
1259
+ self.destroy()