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,459 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import tkinter as tk
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+ from tkinter import messagebox, ttk
8
+
9
+ from flextool.gui.check_tree import CheckTreeController
10
+ from flextool.gui.config_parser import parse_plot_configs
11
+ from flextool.gui.dialogs.file_picker import FilePickerDialog
12
+ from flextool.gui.data_models import PlotSettings, ProjectSettings
13
+ from flextool.gui.settings_io import save_project_settings
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _flextool_root() -> Path:
19
+ """Return the user's working directory (where project files live).
20
+
21
+ Historically this was the repo root (three levels up from ``gui/``).
22
+ In a wheel install that path is inside ``site-packages/`` and not
23
+ user-relevant; CWD is the workspace.
24
+ """
25
+ return Path.cwd()
26
+
27
+
28
+ def _relative_config_path(abs_path: Path) -> str:
29
+ """Return the path relative to CWD when possible."""
30
+ try:
31
+ return str(abs_path.relative_to(Path.cwd()))
32
+ except ValueError:
33
+ return str(abs_path)
34
+
35
+
36
+ class _PlotSection:
37
+ """Widget group for one plot-settings section (single or comparison).
38
+
39
+ Manages a ``ttk.LabelFrame`` containing start-time / duration entries,
40
+ a config-file selector, and a list of config checkboxes.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ parent: tk.Misc,
46
+ label: str,
47
+ settings: PlotSettings,
48
+ default_config_file: str,
49
+ show_dispatch: bool = False,
50
+ project_path: Path | None = None,
51
+ ) -> None:
52
+ self._settings = settings
53
+ self._default_config_file = default_config_file
54
+ self._project_path = project_path
55
+
56
+ self.frame = ttk.LabelFrame(parent, text=label, padding=10)
57
+ self.frame.columnconfigure(1, weight=1)
58
+
59
+ # ── Start time ─────────────────────────────────────────────
60
+ row = 0
61
+ ttk.Label(self.frame, text="Start timestep (integer):").grid(
62
+ row=row, column=0, sticky="w", pady=(0, 4),
63
+ )
64
+ self._start_var = tk.StringVar(value=str(settings.start_time))
65
+ start_entry = ttk.Entry(self.frame, textvariable=self._start_var, width=10)
66
+ start_entry.grid(row=row, column=1, sticky="w", padx=(10, 0), pady=(0, 4))
67
+ _register_int_validation(start_entry)
68
+
69
+ # ── Duration ───────────────────────────────────────────────
70
+ row = 1
71
+ ttk.Label(self.frame, text="Duration (number of timesteps):").grid(
72
+ row=row, column=0, sticky="w", pady=(0, 4),
73
+ )
74
+ self._duration_var = tk.StringVar(value=str(settings.duration))
75
+ dur_entry = ttk.Entry(self.frame, textvariable=self._duration_var, width=10)
76
+ dur_entry.grid(row=row, column=1, sticky="w", padx=(10, 0), pady=(0, 4))
77
+ _register_int_validation(dur_entry)
78
+
79
+ # ── Config file selector ───────────────────────────────────
80
+ row = 2
81
+ config_file = settings.config_file or default_config_file
82
+ self._config_path = _resolve_config_path(config_file)
83
+
84
+ config_row = ttk.Frame(self.frame)
85
+ config_row.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(4, 4))
86
+ config_row.columnconfigure(1, weight=1)
87
+
88
+ ttk.Label(config_row, text="Plot config file:").grid(row=0, column=0, sticky="w")
89
+ self._config_label_var = tk.StringVar(
90
+ value=_relative_config_path(self._config_path)
91
+ )
92
+ ttk.Label(config_row, textvariable=self._config_label_var).grid(
93
+ row=0, column=1, sticky="w", padx=(5, 5),
94
+ )
95
+ ttk.Button(config_row, text="Change", command=self._on_change_config).grid(
96
+ row=0, column=2, sticky="e",
97
+ )
98
+
99
+ # ── Dispatch checkbox (comparison section only) ───────────
100
+ self._dispatch_var: tk.BooleanVar | None = None
101
+ if show_dispatch:
102
+ row = 3
103
+ dispatch_row = ttk.Frame(self.frame)
104
+ dispatch_row.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(4, 2))
105
+
106
+ self._dispatch_var = tk.BooleanVar(value=settings.dispatch_plots)
107
+ ttk.Checkbutton(
108
+ dispatch_row, text="Dispatch plots", variable=self._dispatch_var,
109
+ ).pack(side="left")
110
+
111
+ # ── "Just one file per plot" checkbox ─────────────────────
112
+ row = 4 if show_dispatch else 3
113
+ self._only_first_file_var = tk.BooleanVar(value=settings.only_first_file)
114
+ ttk.Checkbutton(
115
+ self.frame, text="Just one file per plot", variable=self._only_first_file_var,
116
+ ).grid(row=row, column=0, columnspan=2, sticky="w", pady=(4, 2))
117
+
118
+ # ── Active configs list (Treeview with checkboxes) ─────────
119
+ row += 1
120
+ ttk.Label(self.frame, text="Active configs:").grid(
121
+ row=row, column=0, sticky="w", pady=(4, 2),
122
+ )
123
+
124
+ row += 1
125
+ self.frame.rowconfigure(row, weight=1)
126
+ from flextool.gui.ui_metrics import get_metrics
127
+ _metrics = get_metrics(self)
128
+ self._lh = _metrics.lh
129
+ cw = _metrics.cw
130
+
131
+ config_frame = ttk.Frame(self.frame)
132
+ config_frame.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=(0, 4))
133
+ config_frame.columnconfigure(0, weight=1)
134
+ config_frame.rowconfigure(0, weight=1)
135
+
136
+ self._config_tree = ttk.Treeview(
137
+ config_frame,
138
+ columns=("check", "name"),
139
+ show="headings",
140
+ selectmode="extended",
141
+ height=6,
142
+ )
143
+ self._config_tree.heading("check", text="\u25bd")
144
+ self._config_tree.heading("name", text="Config")
145
+ self._config_tree.column("check", width=int(cw * 3.45), minwidth=int(cw * 3.45), stretch=False)
146
+ self._config_tree.column("name", width=cw * 20, minwidth=cw * 10, stretch=True)
147
+ self._config_tree.grid(row=0, column=0, sticky="nsew")
148
+
149
+ config_scroll = ttk.Scrollbar(config_frame, orient="vertical", command=self._config_tree.yview)
150
+ config_scroll.grid(row=0, column=1, sticky="ns")
151
+ self._config_tree.configure(yscrollcommand=config_scroll.set)
152
+
153
+ self._config_check_ctrl = CheckTreeController(
154
+ self._config_tree,
155
+ check_column="check",
156
+ checked_glyph=self._CHECK_ON,
157
+ unchecked_glyph=self._CHECK_OFF,
158
+ )
159
+
160
+ self._populate_configs()
161
+
162
+ # ── Public helpers ────────────────────────────────────────────
163
+
164
+ # Unicode checkbox characters (same as main window)
165
+ _CHECK_ON = "\u25a0" # ■
166
+ _CHECK_OFF = "\u25a1" # □
167
+
168
+ def collect(self) -> PlotSettings:
169
+ """Return a ``PlotSettings`` built from the current widget state."""
170
+ start = _parse_int(self._start_var.get(), self._settings.start_time)
171
+ duration = _parse_int(self._duration_var.get(), self._settings.duration)
172
+ config_file = _relative_config_path(self._config_path)
173
+ active: list[str] = []
174
+ for item in self._config_tree.get_children():
175
+ values = self._config_tree.item(item, "values")
176
+ if values and values[0] == self._CHECK_ON:
177
+ active.append(values[1])
178
+ dispatch = self._dispatch_var.get() if self._dispatch_var is not None else self._settings.dispatch_plots
179
+ return PlotSettings(
180
+ start_time=start,
181
+ duration=duration,
182
+ config_file=config_file,
183
+ active_configs=active,
184
+ dispatch_plots=dispatch,
185
+ only_first_file=self._only_first_file_var.get(),
186
+ )
187
+
188
+ # ── Internal ──────────────────────────────────────────────────
189
+
190
+ def _populate_configs(self) -> None:
191
+ """Populate the config Treeview from the YAML file."""
192
+ for item in self._config_tree.get_children():
193
+ self._config_tree.delete(item)
194
+
195
+ config_names = parse_plot_configs(self._config_path)
196
+ if not config_names:
197
+ return
198
+
199
+ saved_active = self._settings.active_configs
200
+ saved_config_file = self._settings.config_file or self._default_config_file
201
+ current_rel = _relative_config_path(self._config_path)
202
+ is_same_file = (current_rel == saved_config_file)
203
+
204
+ for name in config_names:
205
+ if is_same_file and saved_active:
206
+ check = self._CHECK_ON if name in saved_active else self._CHECK_OFF
207
+ else:
208
+ check = self._CHECK_ON
209
+ self._config_tree.insert("", "end", values=(check, name))
210
+
211
+ def _on_change_config(self) -> None:
212
+ """Open a file chooser to select a different YAML config file."""
213
+ root = _flextool_root()
214
+ initial_dir = self._config_path.parent
215
+ if not initial_dir.is_dir():
216
+ initial_dir = root
217
+
218
+ # Determine dialog size from the top-level window
219
+ try:
220
+ toplevel = self.frame.winfo_toplevel()
221
+ main_window_width = toplevel.winfo_width()
222
+ screen_height = toplevel.winfo_screenheight()
223
+ except Exception:
224
+ main_window_width = 700
225
+ screen_height = 800
226
+
227
+ picker = FilePickerDialog(
228
+ self.frame,
229
+ title="Select plot config file",
230
+ initialdir=str(initial_dir),
231
+ filetypes=[
232
+ ("YAML files", "*.yaml *.yml"),
233
+ ("All files", "*"),
234
+ ],
235
+ multiple=False,
236
+ width=main_window_width,
237
+ height=int(screen_height * 0.75),
238
+ )
239
+ filepath = picker.result
240
+ if not filepath:
241
+ return
242
+
243
+ new_path = Path(filepath) if not isinstance(filepath, Path) else filepath
244
+ if not new_path.is_file():
245
+ messagebox.showerror(
246
+ "Invalid file",
247
+ f"Cannot read:\n{new_path}",
248
+ parent=self.frame,
249
+ )
250
+ return
251
+
252
+ self._config_path = new_path
253
+ self._config_label_var.set(_relative_config_path(new_path))
254
+ self._populate_configs()
255
+
256
+
257
+ class PlotDialog(tk.Toplevel):
258
+ """Modal dialog for configuring plot settings.
259
+
260
+ A "Colors, order..." button (top row) opens the shared per-project
261
+ ``plot_settings.yaml`` editor, which applies to ALL plots. Below it
262
+ a single section holds the plot-generation settings (start-time,
263
+ duration, config-file selection, dispatch toggle and active-config
264
+ checkboxes). Single-scenario and comparison plots used to take
265
+ separate config files; they now share ``default_plots.yaml``, so one
266
+ section drives both — its values are written to both the single and
267
+ comparison ``PlotSettings`` (still consumed by separate output
268
+ commands) on OK.
269
+ """
270
+
271
+ def __init__(
272
+ self,
273
+ parent: tk.Tk,
274
+ project_path: Path,
275
+ settings: ProjectSettings,
276
+ ) -> None:
277
+ super().__init__(parent)
278
+ self.title("Plot settings")
279
+ self._project_path = project_path
280
+ self._settings = settings
281
+
282
+ # ── Modal behaviour ──────────────────────────────────────
283
+ self.transient(parent)
284
+ self.grab_set()
285
+
286
+ # ── Font metrics for DPI-aware sizing ───────────────────
287
+ from flextool.gui.ui_metrics import get_metrics
288
+ _metrics = get_metrics(self)
289
+ cw: int = _metrics.cw
290
+ lh: int = _metrics.lh
291
+
292
+ # ── Dialog size ──────────────────────────────────────────
293
+ # One section now (was two side-by-side), so roughly half-width.
294
+ self.geometry(f"{cw * 104}x{lh * 42}")
295
+ self.resizable(True, True)
296
+ self.minsize(cw * 76, lh * 28)
297
+
298
+ self._build_widgets()
299
+
300
+ # Close via window-manager "X"
301
+ self.protocol("WM_DELETE_WINDOW", self._on_ok)
302
+
303
+ # Centre on parent
304
+ self.update_idletasks()
305
+ px = parent.winfo_rootx()
306
+ py = parent.winfo_rooty()
307
+ pw = parent.winfo_width()
308
+ ph = parent.winfo_height()
309
+ w = self.winfo_width()
310
+ h = self.winfo_height()
311
+ x = px + (pw - w) // 2
312
+ y = py + (ph - h) // 2
313
+ self.geometry(f"+{x}+{y}")
314
+
315
+ # Block until closed
316
+ parent.wait_window(self)
317
+
318
+ # ── Widget construction ──────────────────────────────────────
319
+
320
+ def _build_widgets(self) -> None:
321
+ self.columnconfigure(0, weight=1)
322
+ self.rowconfigure(1, weight=1) # the settings section expands
323
+
324
+ # ── "Colors, order..." on its own row at the top ─────────
325
+ # ``plot_settings.yaml`` (colors / order / sign) applies to ALL
326
+ # plots — single, comparison and dispatch — so its editor sits in
327
+ # its own row above the (shared) plot-generation settings rather
328
+ # than tucked into a corner.
329
+ top_frame = ttk.Frame(self)
330
+ top_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=(10, 5))
331
+ ttk.Button(
332
+ top_frame, text="Colors, order...", command=self._on_change_colors,
333
+ ).pack(side="left")
334
+
335
+ # ── Plot-generation settings (single + comparison share one) ─
336
+ # Single-scenario and comparison plots used to take separate
337
+ # config files; they now share ``default_plots.yaml``, so one
338
+ # section drives both. ``dispatch_plots`` (comparison-only) is
339
+ # carried from the comparison settings into this merged section.
340
+ section_settings = replace(
341
+ self._settings.single_plot_settings,
342
+ dispatch_plots=self._settings.comparison_plot_settings.dispatch_plots,
343
+ )
344
+ self._section = _PlotSection(
345
+ self,
346
+ label="Plot settings:",
347
+ settings=section_settings,
348
+ default_config_file="templates/default_plots.yaml",
349
+ show_dispatch=True,
350
+ project_path=self._project_path,
351
+ )
352
+ self._section.frame.grid(
353
+ row=1, column=0, sticky="nsew", padx=10, pady=(0, 5),
354
+ )
355
+
356
+ # ── Button row ───────────────────────────────────────────
357
+ btn_frame = ttk.Frame(self)
358
+ btn_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=(5, 10))
359
+
360
+ ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
361
+ side="right",
362
+ )
363
+
364
+ # ── Actions ──────────────────────────────────────────────────
365
+
366
+ def _on_change_colors(self) -> None:
367
+ """Open the shared per-project plot-settings (colors/order) editor.
368
+
369
+ Always edits the PROJECT's ``plot_settings.yaml`` (seeding it from
370
+ the bundled default if absent), never the bundled package file. No
371
+ re-render is needed here — this dialog has no live preview; the
372
+ edited file is used on the next plot generation.
373
+ """
374
+ if self._project_path is None:
375
+ messagebox.showinfo(
376
+ "No project",
377
+ "No project is loaded.",
378
+ parent=self,
379
+ )
380
+ return
381
+
382
+ from flextool.gui.dialogs.plot_settings_picker import PlotSettingsPicker
383
+ from flextool.gui.project_utils import seed_plot_settings
384
+
385
+ # No live preview in the batch dialog → no on_apply callback.
386
+ project_file = seed_plot_settings(self._project_path)
387
+ PlotSettingsPicker(self, project_file)
388
+
389
+ def _on_ok(self) -> None:
390
+ """Save settings and close the dialog."""
391
+ # One section drives both single-scenario and comparison plots;
392
+ # write its values into both PlotSettings (still consumed by
393
+ # separate output commands). ``variant_durations`` is not edited
394
+ # here, so carry each object's existing values forward.
395
+ collected = self._section.collect()
396
+ self._settings.single_plot_settings = replace(
397
+ collected,
398
+ variant_durations=self._settings.single_plot_settings.variant_durations,
399
+ )
400
+ self._settings.comparison_plot_settings = replace(
401
+ collected,
402
+ variant_durations=self._settings.comparison_plot_settings.variant_durations,
403
+ )
404
+
405
+ try:
406
+ save_project_settings(self._project_path, self._settings)
407
+ except OSError as exc:
408
+ logger.error("Failed to save plot settings: %s", exc)
409
+ messagebox.showerror(
410
+ "Save error",
411
+ f"Could not save settings:\n{exc}",
412
+ parent=self,
413
+ )
414
+
415
+ self.grab_release()
416
+ self.destroy()
417
+
418
+
419
+ # ── Helpers ──────────────────────────────────────────────────────────
420
+
421
+
422
+ def _resolve_config_path(config_file: str) -> Path:
423
+ """Resolve a config file string to an absolute ``Path``.
424
+
425
+ Absolute paths are returned as-is. A bare name like
426
+ ``default_plots.yaml`` or the legacy
427
+ ``templates/default_plots.yaml`` is resolved against the bundled
428
+ ``schemas/`` package data. Any other relative path is
429
+ resolved against the user's CWD.
430
+ """
431
+ p = Path(config_file)
432
+ if p.is_absolute():
433
+ return p
434
+ if p.name in {"default_plots.yaml", "default_plot_settings.yaml"}:
435
+ from flextool._resources import package_data_path
436
+ return package_data_path(f"schemas/{p.name}")
437
+ return Path.cwd() / p
438
+
439
+
440
+ def _parse_int(text: str, fallback: int) -> int:
441
+ """Parse *text* as a non-negative integer, returning *fallback* on failure."""
442
+ try:
443
+ value = int(text)
444
+ return max(0, value)
445
+ except (ValueError, TypeError):
446
+ return fallback
447
+
448
+
449
+ def _register_int_validation(entry: ttk.Entry) -> None:
450
+ """Register a validation callback that only allows digits."""
451
+ vcmd = (entry.winfo_toplevel().register(_validate_int), "%P")
452
+ entry.configure(validate="key", validatecommand=vcmd)
453
+
454
+
455
+ def _validate_int(new_value: str) -> bool:
456
+ """Return ``True`` if *new_value* is empty or consists entirely of digits."""
457
+ if new_value == "":
458
+ return True
459
+ return new_value.isdigit()