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,4394 @@
1
+ """ResultViewer — Toplevel window for browsing and displaying result plots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ import tkinter as tk
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from pathlib import Path
11
+ from tkinter import ttk
12
+ from typing import TYPE_CHECKING
13
+
14
+ import matplotlib.pyplot as plt
15
+ import pandas as pd
16
+ import yaml
17
+
18
+ from flextool.lean_parquet import read_lean_parquet
19
+ from flextool.gui.check_tree import CheckTreeController
20
+ from flextool.gui.data_models import ProjectSettings
21
+ from flextool.gui.network_graph import build_network_figure
22
+ from flextool.gui.plot_canvas import PlotCanvas
23
+ from flextool.gui.plot_config_reader import (
24
+ PlotConfigData, PlotEntry, PlotGroup, PlotVariant, parse_plot_config,
25
+ )
26
+ from flextool.gui.settings_io import save_project_settings
27
+ from flextool.plot_outputs.config import PlotConfig, PLOT_FIELD_NAMES, _is_single_config, flatten_new_format
28
+ from flextool.plot_outputs.orchestrator import prepare_plot_data
29
+ from flextool.plot_outputs.color_template import resolve_plot_settings_path
30
+ from flextool.scenario_comparison.data_models import DispatchMappings, TimeSeriesResults
31
+ from flextool.scenario_comparison.db_reader import (
32
+ build_scenario_folders_from_dir, collect_parquet_files, combine_parquet_files,
33
+ )
34
+ from flextool.scenario_comparison.dispatch_data import (
35
+ available_dispatch_nodes,
36
+ build_dispatch_tree,
37
+ prepare_dispatch_data,
38
+ prepare_node_dispatch_data,
39
+ )
40
+ from flextool.scenario_comparison.dispatch_mappings import (
41
+ load_dispatch_mappings,
42
+ resolve_data_scenario_tag,
43
+ )
44
+ from flextool.scenario_comparison.dispatch_plots import _build_dispatch_figure
45
+
46
+ if TYPE_CHECKING:
47
+ from flextool.gui.data_models import PlotSettings
48
+ from flextool.plot_outputs.plan import PlotPlan
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ # Dispatch tree iid prefixes. ``dispatch_`` parents are nodeGroups (aggregate
53
+ # dispatch); ``dispatchnode_`` children are individual nodes. The two prefixes
54
+ # are deliberately DISJOINT (``dispatchnode_`` does NOT start with
55
+ # ``dispatch_``) so a nodeGroup and a node with the same bare name can never
56
+ # collide. ``_is_dispatch_iid`` recognises either; handlers route via
57
+ # ``_display_dispatch_iid``.
58
+ _DISPATCH_GROUP_PREFIX = "dispatch_"
59
+ _DISPATCH_NODE_PREFIX = "dispatchnode_"
60
+ _DISPATCH_UNGROUPED_LABEL = "Ungrouped nodes"
61
+
62
+
63
+ def _is_dispatch_iid(iid: str) -> bool:
64
+ """True for any dispatch-tree iid (nodeGroup parent or node child)."""
65
+ return iid.startswith(_DISPATCH_NODE_PREFIX) or iid.startswith(
66
+ _DISPATCH_GROUP_PREFIX
67
+ )
68
+
69
+
70
+ class ResultViewer(tk.Toplevel):
71
+ """Non-modal window for browsing and displaying result plots.
72
+
73
+ This window provides scenario selection, a plot tree parsed from
74
+ the YAML config, a variant panel, and a placeholder for future
75
+ matplotlib plot rendering.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ master: tk.Tk,
81
+ project_path: Path,
82
+ settings: ProjectSettings,
83
+ scenario_db_map: dict[str, Path] | None = None,
84
+ desired_viewer_scenarios: list[str] | None = None,
85
+ ) -> None:
86
+ super().__init__(master)
87
+ self.title("Result Viewer")
88
+
89
+ self._project_path = project_path
90
+ self._settings = settings
91
+ self._scenario_db_map = scenario_db_map or {}
92
+ self._viewer_settings = settings.viewer_settings
93
+
94
+ # Plot config data
95
+ self._plot_groups: list[PlotGroup] = []
96
+ # Map tree item iid -> PlotEntry for quick lookup
97
+ self._tree_entry_map: dict[str, PlotEntry] = {}
98
+ # All unique variant letters across all config entries (created once)
99
+ self._all_variant_letters: list[str] = []
100
+ # Tooltip toplevel
101
+ self._tooltip: tk.Toplevel | None = None
102
+
103
+ # Focus model:
104
+ # _focus_col: variant letter index the cursor is on (-1 = in tree, not canvas)
105
+ # _active_entry_iid: which entry is currently displayed (solid blue)
106
+ # _active_variant: which variant letter is displayed (solid blue)
107
+ self._focus_col: int = -1 # -1 means focus is in the tree
108
+ self._active_entry_iid: str = ""
109
+ self._active_variant: str = self._viewer_settings.last_variant or ""
110
+
111
+ # Mode variable: "single", "comparison", "network"
112
+ self._mode = tk.StringVar(value=self._viewer_settings.last_mode or "single")
113
+
114
+ # File navigation state
115
+ self._file_index = 0
116
+ self._file_count = 1
117
+
118
+ # Per-variant slider state.
119
+ # Starts are session-local (not persisted): {letter: start}.
120
+ # Durations are persisted in settings.<plot>.variant_durations and
121
+ # are looked up there directly — see ``_active_plot_settings``.
122
+ self._variant_start_state: dict[str, int] = {}
123
+ self._last_slider_variant: str | None = None
124
+
125
+ # Template-supplied default durations (per variant letter).
126
+ # Populated in ``_populate_plot_tree`` whenever the YAML config is
127
+ # (re)loaded. Values are int or the sentinel string ``"all"``.
128
+ self._template_default_durations: dict[str, int | str] = {}
129
+
130
+ # Guard flag — when True, ``_on_time_range_changed``'s persistence
131
+ # path is skipped. Set around programmatic ``_duration_var.set(...)``
132
+ # calls inside ``_update_time_range`` so a data-driven clamp does
133
+ # not overwrite the user's saved intent.
134
+ self._suppress_duration_save: bool = False
135
+
136
+ # Pending after() id for debounced duration save, or None if no
137
+ # save is currently scheduled.
138
+ self._duration_save_after_id: str | None = None
139
+
140
+ # Caches for parquet pipeline.
141
+ #
142
+ # Phase E generalises ``_parquet_cache_key`` from a fixed
143
+ # ``(scenario, result_key)`` tuple to a free-form tuple so
144
+ # both single-scenario reads and the comparison-mode lazy
145
+ # plan-parquet union can share the slot. Concretely:
146
+ #
147
+ # * single mode key: ``(scenario, result_key)``
148
+ # * comparison-union: ``("_comparison_union", result_key,
149
+ # sub_config, viewer_scenarios_tuple)``
150
+ #
151
+ # ``_parquet_cache_df`` holds the unioned DataFrame so repeated
152
+ # renders for the same plot don't re-read the per-scenario plan
153
+ # parquets. The key prefix disambiguates the two flavours so
154
+ # cache lookups never alias.
155
+ self._yaml_cache: dict[Path, dict] = {}
156
+ self._break_times_cache: dict[str, set[str] | None] = {}
157
+ self._parquet_cache_key: tuple = ("", "")
158
+ self._parquet_cache_df: pd.DataFrame | None = None
159
+
160
+ # Live plan cache: PlotPlan computed from full data, reused across
161
+ # slider changes. Cleared on entry/variant/scenario change.
162
+ self._live_plan: 'PlotPlan | None' = None
163
+ self._live_plan_key: tuple[str, str, str] = ("", "", "")
164
+
165
+ # One-shot flag: when set, the next single-mode render bypasses any
166
+ # pre-computed DISK plot-plan and recomputes the live plan from the
167
+ # freshly-read template. Set by :meth:`_on_change_colors` after a
168
+ # color edit when the cached/disk plan cannot be recolored in place
169
+ # (e.g. a legacy disk plan written before the plan carried
170
+ # ``color_category`` / ``color_entity_class`` hints — an in-place
171
+ # rebuild there silently reverts to palette colors and ignores the
172
+ # edited ``plot_settings.yaml``). Cleared once consumed.
173
+ self._force_plan_recompute: bool = False
174
+
175
+ # Cross-scenario axis-bounds manifest. Loaded lazily on first
176
+ # use and reloaded whenever the on-disk file's mtime advances
177
+ # (so batch runs in the background pick up naturally on the next
178
+ # render). ``None`` means no manifest was found on disk;
179
+ # callers fall back to the per-plan ranges in that case.
180
+ self._axis_manifest: dict | None = None
181
+ self._axis_manifest_mtime: float = 0.0
182
+
183
+ # Guard against recursive replots from time range updates
184
+ self._updating_time_range = False
185
+
186
+ # Async figure building
187
+ self._executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="plot")
188
+ self._render_gen = 0 # incremented on each replot; stale results discarded
189
+ # Pending "Rendering…" placeholder after() id. The placeholder is
190
+ # shown only if a render takes longer than _PLACEHOLDER_DELAY_MS, so
191
+ # quick switches flip straight between figures (better for spotting
192
+ # plot-to-plot differences) instead of flashing text on every switch.
193
+ self._placeholder_after_id: str | None = None
194
+ self._figure_cache: dict[tuple, plt.Figure] = {} # prefetched figures
195
+ self._figure_cache_lock = threading.Lock()
196
+
197
+ # Availability manifest for three-level variant display
198
+ self._current_availability: set[tuple[str, str]] = set()
199
+
200
+ # Comparison tree state — the on-disk set the last regen was
201
+ # built for may differ from the viewer's current ticks until a
202
+ # regen is triggered.
203
+ self._comp_needs_regen: bool = False
204
+
205
+ # Monotonic generation counter for comparison combine requests.
206
+ # Each user-driven state change bumps this; background workers
207
+ # capture the value at submit time, and the GUI completion
208
+ # callback drops results whose captured gen is no longer the
209
+ # latest. Prevents stale data from leaking onto the plot when
210
+ # the user toggles checkboxes faster than combines complete.
211
+ # Tk callbacks run single-threaded on the main thread, so plain
212
+ # attribute access is safe here.
213
+ self._comp_request_gen: int = 0
214
+
215
+ # Viewer scenarios (NOT current scenarios) scheduled for the next
216
+ # comparison combine, set by :meth:`refresh_to_viewer_scenarios`
217
+ # so :meth:`_regenerate_comparison` knows which subdirs to fold
218
+ # into the combined parquets. ``None`` means "no scheduled
219
+ # refresh — fall back to the legacy current-tree-based path".
220
+ self._scheduled_viewer_scenarios: list[str] | None = None
221
+
222
+ # Dispatch mode state
223
+ self._dispatch_mappings: DispatchMappings | None = None
224
+ self._dispatch_results: TimeSeriesResults | None = None
225
+ self._dispatch_scenario: str = "" # folder name dispatch data is loaded for
226
+ self._dispatch_data_tag: str = "" # in-data scenario tag (may differ from folder)
227
+ self._dispatch_ylims: dict[str, tuple[float, float]] = {} # accumulated per-nodeGroup
228
+ self._dispatch_columns: dict[str, list[str]] = {} # accumulated column order
229
+ # mtime (ns) of the plot-settings file the frozen dispatch order was
230
+ # derived from; when the file changes (dialog save / live edit) the
231
+ # accumulated order is stale and must be dropped so the next render
232
+ # re-derives it — see _maybe_invalidate_dispatch_order_on_settings_change.
233
+ self._dispatch_order_settings_mtime: int | None = None
234
+ # Last dispatch iid actually rendered (node / nodeGroup). A live
235
+ # colors/order edit refreshes via _trigger_replot, which normally reads
236
+ # the tree selection; this is the fallback when the selection is empty
237
+ # so the dispatch plot still updates.
238
+ self._last_dispatch_iid: str | None = None
239
+ # Dispatch node-child iid → node name. A node can be a member of
240
+ # several nodeGroups, so its child iids must be unique per parent
241
+ # (Treeview iids are global); this map recovers the node name from
242
+ # the unique iid for the renderer.
243
+ self._dispatch_node_iids: dict[str, str] = {}
244
+
245
+ # ── Font metrics for DPI-aware sizing ────────────────────────
246
+ from flextool.gui.ui_metrics import get_metrics
247
+ _metrics = get_metrics(self)
248
+ cw: int = _metrics.cw
249
+ lh: int = _metrics.lh
250
+
251
+ # ── Window sizing & positioning ──────────────────────────────
252
+ self._line_height = lh
253
+ self._char_width = cw
254
+ self.minsize(cw * 80, lh * 30)
255
+
256
+ master.update_idletasks()
257
+ main_x = master.winfo_x()
258
+ main_y = master.winfo_y()
259
+ main_w = master.winfo_width()
260
+ screen_w = master.winfo_screenwidth()
261
+ screen_h = master.winfo_screenheight()
262
+
263
+ # Confine the default placement to the monitor the main window is on.
264
+ # winfo_screenwidth/height span ALL monitors, so sizing to them
265
+ # stretches this window across every display in a multi-monitor
266
+ # setup. Park this window on the main window's monitor first, then
267
+ # probe that monitor's work area (see current_monitor_bounds).
268
+ from flextool.gui.ui_metrics import current_monitor_bounds
269
+ self.geometry(f"+{main_x}+{main_y}")
270
+ bounds = current_monitor_bounds(self)
271
+ # Reserve taskbar/dock space: screeninfo reports the full monitor
272
+ # rectangle, so the margin is subtracted here for both paths.
273
+ if bounds is not None:
274
+ mon_x, mon_y, mon_w, mon_h = bounds
275
+ else:
276
+ mon_x, mon_y, mon_w, mon_h = 0, 0, screen_w, screen_h
277
+ usable_h = mon_h - lh * 4
278
+
279
+ # Tile to the right of the main window only when the right-hand gap is
280
+ # genuinely roomy — at least half the monitor. The old bar (cw*80, the
281
+ # bare-minimum window width) tiled into a cramped sliver whenever the
282
+ # main window left any gap at all; on a single 1920-wide display that
283
+ # right strip is too narrow to be useful. Below the bar (the common
284
+ # case, incl. a maximized main window that would otherwise push this
285
+ # off the right edge into an unrecoverable black box) we overlap the
286
+ # main window instead, leaving the leftmost ~10% uncovered; that
287
+ # position is always fully on-screen.
288
+ mon_right = mon_x + mon_w
289
+ viewer_x = main_x + main_w
290
+ right_room = mon_right - viewer_x
291
+ if viewer_x >= mon_x and right_room >= mon_w // 2:
292
+ self.geometry(f"{right_room}x{usable_h}+{viewer_x}+{mon_y}")
293
+ else:
294
+ left_gap = mon_x + int(mon_w * 0.10)
295
+ viewer_w = mon_right - left_gap
296
+ self.geometry(f"{viewer_w}x{usable_h}+{left_gap}+{mon_y}")
297
+
298
+ # Restore the saved geometry for the current monitor configuration,
299
+ # rescaled/clamped so a value saved at a different resolution does
300
+ # not run off-screen. Overrides the default placement above.
301
+ from flextool.gui.ui_metrics import monitor_signature, resolve_saved_geometry
302
+ clamped = resolve_saved_geometry(
303
+ self._viewer_settings.window_geometry,
304
+ monitor_signature(self),
305
+ self._viewer_settings.layout_cw,
306
+ cw,
307
+ screen_w, screen_h,
308
+ min_w=cw * 80, min_h=lh * 30,
309
+ )
310
+ if clamped is not None:
311
+ try:
312
+ self.geometry(clamped)
313
+ except tk.TclError:
314
+ pass
315
+
316
+ # ── Build layout ─────────────────────────────────────────────
317
+ self.columnconfigure(0, weight=1)
318
+ self.rowconfigure(0, weight=1)
319
+
320
+ self._paned = ttk.PanedWindow(self, orient="horizontal")
321
+ self._paned.grid(row=0, column=0, sticky="nsew")
322
+
323
+ self._build_left_column()
324
+ self._build_right_column()
325
+
326
+ # ── Configure tree tags and selection highlight ──────────────
327
+ self._plot_tree.tag_configure("disabled", foreground="grey")
328
+
329
+ # Make the plot-tree selection stand out in both light and dark
330
+ # themes. Scoped to PlotTree.Treeview so the main window's
331
+ # "selected !focus" -> blue mapping on the global Treeview style
332
+ # is not overwritten when this viewer opens.
333
+ style = ttk.Style()
334
+ style.map(
335
+ "PlotTree.Treeview",
336
+ background=[
337
+ ("selected !focus", "#2074d5"),
338
+ ("selected", "#2074d5"),
339
+ ],
340
+ foreground=[
341
+ ("selected !focus", "#ffffff"),
342
+ ("selected", "#ffffff"),
343
+ ],
344
+ )
345
+
346
+ # Theme-aware colors for variant grid
347
+ self._fg_color = style.lookup("TLabel", "foreground") or "black"
348
+ self._bg_color = style.lookup("TLabel", "background") or "white"
349
+
350
+ # ── Resolve config paths ─────────────────────────────────────
351
+ self._single_config_path = self._resolve_config_path(
352
+ self._settings.single_plot_settings.config_file,
353
+ "templates/default_plots.yaml",
354
+ )
355
+ # Comparison mode now reads the same merged config as single mode;
356
+ # entries with ``scenario_rule`` set carry the comparison-mode info.
357
+ self._comparison_config_path = self._resolve_config_path(
358
+ self._settings.comparison_plot_settings.config_file,
359
+ "templates/default_plots.yaml",
360
+ )
361
+
362
+ # ── Initial population ───────────────────────────────────────
363
+ self._populate_scenarios()
364
+ self._on_mode_changed()
365
+
366
+ # ── Tab focus cycling ────────────────────────────────────────
367
+ self._scenario_listbox.bind("<Tab>", self._focus_plot_tree)
368
+ self._plot_tree.bind("<Tab>", self._focus_variant_canvas)
369
+
370
+ # ── Global key bindings ──────────────────────────────────────
371
+ # Use bind_all so these work even when child widgets (Treeview,
372
+ # Listbox) have focus — they consume Prior/Next for scrolling
373
+ # before Toplevel bindings fire (especially on Windows).
374
+ self.bind_all("<Prior>", self._on_prev_file_event)
375
+ self.bind_all("<Next>", self._on_next_file_event)
376
+ self.bind_all("<Left>", self._on_prev_file_event)
377
+ self.bind_all("<Right>", self._on_next_file_event)
378
+
379
+ # Panel focus shortcuts: s = Scenarios, p = Plots
380
+ # Bind on specific widgets so Treeview type-ahead doesn't consume them
381
+ for w in (self._plot_tree, self._variant_canvas, self._scenario_listbox):
382
+ w.bind("<Key-s>", self._on_focus_scenarios_event)
383
+ w.bind("<Key-p>", self._on_focus_plots_event)
384
+ # Also bind at toplevel level for when buttons/controls have focus
385
+ self.bind("<Key-s>", self._on_focus_scenarios_event)
386
+ self.bind("<Key-p>", self._on_focus_plots_event)
387
+
388
+ # ── Window close ─────────────────────────────────────────────
389
+ self.protocol("WM_DELETE_WINDOW", self._on_close)
390
+
391
+ # ── Restore saved sash positions after layout is ready ───────
392
+ self.after(50, self._restore_sash_position)
393
+
394
+ # ── Cold-open auto-refresh ───────────────────────────────────
395
+ # When the main window passed an explicit "desired viewer
396
+ # scenarios" set (Phase B contract), reconcile against
397
+ # ``_metadata.json`` and rebuild the combined parquets only when
398
+ # the two differ. ``None`` skips the reconciliation and leaves
399
+ # whatever's on disk untouched.
400
+ if desired_viewer_scenarios is not None:
401
+ self.after(0, lambda d=list(desired_viewer_scenarios):
402
+ self.refresh_to_viewer_scenarios(d))
403
+
404
+ # ------------------------------------------------------------------
405
+ # Layout builders
406
+ # ------------------------------------------------------------------
407
+
408
+ def _build_left_column(self) -> None:
409
+ """Build the left column: scenario listbox + plot tree."""
410
+ left = ttk.Frame(self._paned, padding=5)
411
+ # Request a wider default left pane (40 chars)
412
+ left.configure(width=self._char_width * 40)
413
+ self._paned.add(left, weight=0)
414
+
415
+ left.columnconfigure(0, weight=1)
416
+ left.rowconfigure(0, weight=1)
417
+
418
+ # Vertical PanedWindow so user can resize Scenarios vs Plots
419
+ self._left_paned = ttk.PanedWindow(left, orient="vertical")
420
+ self._left_paned.grid(row=0, column=0, sticky="nsew")
421
+
422
+ # ── Scenario listbox ─────────────────────────────────────────
423
+ from flextool.gui.hover_tooltip import attach_tooltip
424
+ # Use the named-font string so size changes via _set_font_size
425
+ # reach the label live.
426
+ _lf_font = "TkDefaultFont"
427
+ scen_label = ttk.Label(left, text=" Scenarios [S] ", font=_lf_font)
428
+ scen_frame = ttk.LabelFrame(self._left_paned, labelwidget=scen_label, padding=5)
429
+ attach_tooltip(scen_label, (
430
+ "Scenarios available in the chosen results.\n"
431
+ "\n"
432
+ " \u2022 S \u2014 focus this list\n"
433
+ " \u2022 \u2191\u2193 \u2014 navigate scenarios"
434
+ ))
435
+ self._left_paned.add(scen_frame, weight=0)
436
+ scen_frame.columnconfigure(0, weight=1)
437
+ scen_frame.rowconfigure(0, weight=1)
438
+
439
+ self._scenario_listbox = tk.Listbox(
440
+ scen_frame,
441
+ selectmode="browse",
442
+ height=8,
443
+ exportselection=False,
444
+ )
445
+ self._scenario_listbox.grid(row=0, column=0, sticky="nsew")
446
+
447
+ scen_scroll = ttk.Scrollbar(
448
+ scen_frame, orient="vertical", command=self._scenario_listbox.yview
449
+ )
450
+ scen_scroll.grid(row=0, column=1, sticky="ns")
451
+ self._scenario_listbox.configure(yscrollcommand=scen_scroll.set)
452
+
453
+ self._scenario_listbox.bind("<<ListboxSelect>>", self._on_scenario_selected)
454
+
455
+ # ── Comparison scenarios tree (hidden by default) ────────────
456
+ # A ttk.Treeview with a check column + name column replaces the
457
+ # former list of ttk.Checkbuttons so the user gets the familiar
458
+ # multi-select pattern: click / Shift+Click / Ctrl+Click select
459
+ # rows, Space smart-toggles check on the whole selection,
460
+ # Ctrl-A selects every row.
461
+ comp_outer = ttk.Frame(scen_frame)
462
+ comp_outer.grid(row=0, column=0, columnspan=2, sticky="nsew")
463
+ comp_outer.columnconfigure(0, weight=1)
464
+ comp_outer.rowconfigure(0, weight=1)
465
+ comp_outer.grid_remove() # hidden initially
466
+ self._comp_outer_frame = comp_outer
467
+
468
+ self._comp_tree = ttk.Treeview(
469
+ comp_outer,
470
+ columns=("check", "name"),
471
+ show="headings",
472
+ selectmode="extended",
473
+ )
474
+ self._comp_tree.heading("check", text="")
475
+ self._comp_tree.heading("name", text="Scenario")
476
+ _cw = self._char_width
477
+ self._comp_tree.column("check", width=int(_cw * 3.45), minwidth=int(_cw * 3.45), stretch=False)
478
+ self._comp_tree.column("name", width=_cw * 20, minwidth=_cw * 12, stretch=True)
479
+ self._comp_tree.grid(row=0, column=0, sticky="nsew")
480
+
481
+ comp_scroll = ttk.Scrollbar(
482
+ comp_outer, orient="vertical", command=self._comp_tree.yview,
483
+ )
484
+ comp_scroll.grid(row=0, column=1, sticky="ns")
485
+ self._comp_tree.configure(yscrollcommand=comp_scroll.set)
486
+
487
+ self._comp_tree.bind("<Control-a>", self._on_comp_tree_ctrl_a)
488
+ self._comp_tree.bind("<Control-A>", self._on_comp_tree_ctrl_a)
489
+ self._comp_tree.bind("<Alt-Up>", self._on_comp_tree_alt_up)
490
+ self._comp_tree.bind("<Alt-Down>", self._on_comp_tree_alt_down)
491
+ self._comp_check_ctrl = CheckTreeController(
492
+ self._comp_tree,
493
+ check_column="check",
494
+ checked_glyph=self._COMP_CHECK_ON,
495
+ unchecked_glyph=self._COMP_CHECK_OFF,
496
+ on_toggle=self._on_comp_tree_toggled,
497
+ )
498
+ from flextool.gui.tree_reorder import DragReorderController
499
+ self._comp_drag = DragReorderController(
500
+ self._comp_tree,
501
+ check_column="check",
502
+ on_reorder=self._on_comp_tree_reordered,
503
+ )
504
+ attach_tooltip(
505
+ self._comp_tree, "Drag to reorder, or Alt+Up/Down",
506
+ )
507
+
508
+ # ── Plot tree + variant canvas ───────────────────────────────
509
+ plots_label = ttk.Label(left, text=" Plots [P] ", font=_lf_font)
510
+ tree_frame = ttk.LabelFrame(self._left_paned, labelwidget=plots_label, padding=5)
511
+ attach_tooltip(plots_label, (
512
+ "Available plots (plots without data greyed out).\n"
513
+ "\n"
514
+ " \u2022 P \u2014 focus this list\n"
515
+ " \u2022 \u2191\u2193\u2190\u2192 \u2014 navigate plots\n"
516
+ " \u2022 PgUp/PgDn \u2014 prev/next file"
517
+ ))
518
+ self._left_paned.add(tree_frame, weight=1)
519
+ tree_frame.columnconfigure(0, weight=1)
520
+ # column 1 = variant canvas (fixed width, set later)
521
+ # column 2 = scrollbar (fixed width)
522
+ tree_frame.rowconfigure(0, weight=1)
523
+
524
+ # Use a per-widget style so the variant-canvas focus dance below
525
+ # (which hides this tree's selection while the canvas has focus)
526
+ # doesn't bleed into the global "Treeview" style and disturb the
527
+ # main window's input_sources / available / executed / jobs
528
+ # selection rendering.
529
+ self._plot_tree = ttk.Treeview(
530
+ tree_frame,
531
+ show="tree",
532
+ selectmode="browse",
533
+ style="PlotTree.Treeview",
534
+ )
535
+ self._plot_tree.grid(row=0, column=0, sticky="nsew")
536
+
537
+ # Variant canvas — sits to the right of the tree, shares the scrollbar
538
+ self._variant_canvas = tk.Canvas(
539
+ tree_frame, width=0, highlightthickness=0,
540
+ )
541
+ self._variant_canvas.grid(row=0, column=1, sticky="ns")
542
+ self._variant_canvas.configure(takefocus=True)
543
+
544
+ # Shared vertical scrollbar
545
+ tree_scroll = ttk.Scrollbar(tree_frame, orient="vertical")
546
+ tree_scroll.grid(row=0, column=2, sticky="ns")
547
+
548
+ def _tree_yscroll(*args):
549
+ tree_scroll.set(*args)
550
+ self._schedule_variant_redraw()
551
+
552
+ tree_scroll.configure(command=self._plot_tree.yview)
553
+ self._plot_tree.configure(yscrollcommand=_tree_yscroll)
554
+
555
+ # Pending redraw id (to coalesce rapid scroll events)
556
+ self._variant_redraw_id: str | None = None
557
+
558
+ self._plot_tree.bind("<<TreeviewSelect>>", self._on_tree_selected)
559
+ self._plot_tree.bind("<Motion>", self._on_tree_motion)
560
+ self._plot_tree.bind("<Leave>", self._hide_tooltip)
561
+ self._plot_tree.bind("<Up>", self._on_tree_key_up)
562
+ self._plot_tree.bind("<Down>", self._on_tree_key_down)
563
+ self._plot_tree.bind("<<TreeviewOpen>>", lambda e: self._schedule_variant_redraw())
564
+ self._plot_tree.bind("<<TreeviewClose>>", lambda e: self._schedule_variant_redraw())
565
+ self._plot_tree.bind("<Configure>", lambda e: self._schedule_variant_redraw())
566
+
567
+ # Canvas click and keyboard bindings
568
+ self._variant_canvas.bind("<Button-1>", self._on_variant_canvas_click)
569
+ self._variant_canvas.bind("<FocusIn>", self._on_canvas_focus_in)
570
+ self._variant_canvas.bind("<FocusOut>", lambda e: self._redraw_variant_grid())
571
+ self._variant_canvas.bind("<Left>", self._on_variant_left)
572
+ self._variant_canvas.bind("<Right>", self._on_variant_right)
573
+ self._variant_canvas.bind("<Up>", self._on_variant_key_up)
574
+ self._variant_canvas.bind("<Down>", self._on_variant_key_down)
575
+ self._variant_canvas.bind("<Tab>", self._focus_scenario_listbox)
576
+
577
+ # Tree focus management
578
+ self._plot_tree.bind("<FocusIn>", self._on_tree_focus_in)
579
+
580
+ # Tree Right arrow → move focus to variant canvas
581
+ self._plot_tree.bind("<Right>", self._on_tree_key_right)
582
+ self._plot_tree.bind("<Left>", self._on_tree_key_left)
583
+
584
+ def _build_right_column(self) -> None:
585
+ """Build the right column: compact control bar + plot area."""
586
+ right = ttk.Frame(self._paned, padding=5)
587
+ self._paned.add(right, weight=1)
588
+
589
+ right.columnconfigure(0, weight=1)
590
+ right.rowconfigure(1, weight=1) # plot area gets all extra space
591
+
592
+ # ── Combined control frame ───────────────────────────────────
593
+ self._control_frame = ttk.Frame(right, padding=(5, 2))
594
+ self._control_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5))
595
+ self._control_frame.columnconfigure(2, weight=1) # time frame fills remaining
596
+
597
+ # Col 0: File navigation (Prev on top, Next on bottom)
598
+ file_nav_frame = ttk.Frame(self._control_frame)
599
+ file_nav_frame.grid(row=0, column=0, rowspan=2, sticky="ns", padx=(0, 5))
600
+
601
+ self._prev_file_btn = ttk.Button(
602
+ file_nav_frame, text="\u25c0 Prev", width=6,
603
+ command=self._on_prev_file,
604
+ )
605
+ self._prev_file_btn.pack(side="top", pady=(0, 1))
606
+
607
+ self._file_label = ttk.Label(file_nav_frame, text="File 1/1", anchor="center")
608
+ self._file_label.pack(side="top", pady=1)
609
+
610
+ self._next_file_btn = ttk.Button(
611
+ file_nav_frame, text="Next \u25b6", width=6,
612
+ command=self._on_next_file,
613
+ )
614
+ self._next_file_btn.pack(side="top", pady=(1, 0))
615
+
616
+ self._update_file_nav()
617
+
618
+ # Col 1: Mode radio buttons (stacked vertically)
619
+ mode_frame = ttk.Frame(self._control_frame)
620
+ mode_frame.grid(row=0, column=1, rowspan=2, sticky="ns", padx=(0, 10))
621
+
622
+ for text, value in [("Single", "single"), ("Comparison", "comparison"), ("Dispatch", "dispatch"), ("Network", "network")]:
623
+ rb = ttk.Radiobutton(
624
+ mode_frame, text=text, variable=self._mode, value=value,
625
+ command=self._on_mode_changed,
626
+ )
627
+ rb.pack(side="top", anchor="w")
628
+
629
+ # Col 2: Start slider + Duration spinbox (stacked, slider fills width)
630
+ time_frame = ttk.Frame(self._control_frame)
631
+ time_frame.grid(row=0, column=2, rowspan=2, sticky="nsew", padx=(0, 10))
632
+ time_frame.columnconfigure(1, weight=1)
633
+
634
+ ttk.Label(time_frame, text="Start").grid(row=0, column=0, sticky="w", padx=(0, 5))
635
+ self._start_var = tk.IntVar(value=self._settings.single_plot_settings.start_time)
636
+ self._start_scale = ttk.Scale(
637
+ time_frame, from_=0, to=8760, orient="horizontal",
638
+ variable=self._start_var,
639
+ )
640
+ self._start_scale.grid(row=0, column=1, sticky="ew")
641
+
642
+ ttk.Label(time_frame, text="Duration").grid(row=1, column=0, sticky="w", padx=(0, 5))
643
+ self._duration_steps = (1, 2, 3, 4, 6, 12, 24, 72, 168, 240, 336, 504, 672, 1344, 2688, 5376, 8760)
644
+ self._duration_var = tk.IntVar(value=self._settings.single_plot_settings.duration or 168)
645
+ self._duration_spin = ttk.Spinbox(
646
+ time_frame, values=self._duration_steps,
647
+ textvariable=self._duration_var, width=6,
648
+ )
649
+ self._duration_spin.grid(row=1, column=1, sticky="w")
650
+
651
+ self._change_colors_btn = ttk.Button(
652
+ time_frame, text="Colors, order...", command=self._on_change_colors,
653
+ )
654
+ self._change_colors_btn.grid(
655
+ row=2, column=0, columnspan=2, sticky="w", pady=(4, 0),
656
+ )
657
+
658
+ # Bind changes to trigger replot
659
+ self._start_var.trace_add("write", self._on_time_range_changed)
660
+ self._duration_var.trace_add("write", self._on_time_range_changed)
661
+
662
+ # ── Plot canvas ──────────────────────────────────────────────
663
+ self._plot_canvas = PlotCanvas(right)
664
+ self._plot_canvas._cache.max_gb = self._viewer_settings.cache_gb
665
+ self._plot_canvas.grid(row=1, column=0, sticky="nsew")
666
+ self._plot_canvas.show_message("Select a plot to display")
667
+
668
+ # ------------------------------------------------------------------
669
+ # Config path resolution
670
+ # ------------------------------------------------------------------
671
+
672
+ def _resolve_config_path(self, user_config: str, default_relative: str) -> Path:
673
+ """Resolve a plot config path.
674
+
675
+ If *user_config* is set and exists, use it. Otherwise fall back
676
+ to the bundled default that ships with the package (no longer a
677
+ repo-root path — works in wheel installs).
678
+ """
679
+ if user_config:
680
+ p = Path(user_config)
681
+ if p.is_absolute() and p.is_file():
682
+ return p
683
+ # Try relative to project
684
+ candidate = self._project_path / user_config
685
+ if candidate.is_file():
686
+ return candidate
687
+
688
+ # ``default_relative`` historically pointed at the repo-root
689
+ # ``templates/`` dir; the canonical YAML now lives inside the
690
+ # package as ``schemas/`` and is fetched via
691
+ # ``package_data_path`` so wheel installs find it too.
692
+ from flextool._resources import package_data_path
693
+ basename = Path(default_relative).name
694
+ return package_data_path(f"schemas/{basename}")
695
+
696
+ # ------------------------------------------------------------------
697
+ # Scenario discovery
698
+ # ------------------------------------------------------------------
699
+
700
+ def _scan_scenarios(self) -> list[str]:
701
+ """List scenario subdirectories in output_parquet/ that are checked.
702
+
703
+ Subdirectories are either the bare scenario name (when this
704
+ source owns it) or ``<scenario_name>_<source_number>`` (when
705
+ another source owns the bare name). The checked set in project
706
+ settings uses the compound key ``<source_number>|<scenario_name>``
707
+ — we translate between the two forms here using the
708
+ bare-ownership map so the viewer's internal identifier remains
709
+ the on-disk subdir.
710
+
711
+ Order is taken from ``settings.executed_scenario_order``: saved
712
+ names that still exist on disk come first (in saved order),
713
+ followed by any newly-seen names in alphabetical order. The
714
+ settings list is rewritten to match (drops missing names, appends
715
+ new ones) and persisted via the debounced save path.
716
+ """
717
+ from flextool.gui.scenario_key import resolve_source_number, format_key
718
+ parquet_dir = self._project_path / "output_parquet"
719
+ if not parquet_dir.is_dir():
720
+ return []
721
+ on_disk = sorted(
722
+ d.name for d in parquet_dir.iterdir()
723
+ if d.is_dir() and not d.name.startswith("_")
724
+ )
725
+ checked_keys = set(self._settings.checked_executed_scenarios)
726
+ if checked_keys:
727
+ bare_owners = self._settings.bare_output_owners
728
+ on_disk = [
729
+ s for s in on_disk
730
+ if format_key(*resolve_source_number(s, bare_owners)) in checked_keys
731
+ ]
732
+
733
+ saved_order = list(self._settings.executed_scenario_order)
734
+ on_disk_set = set(on_disk)
735
+ ordered: list[str] = [s for s in saved_order if s in on_disk_set]
736
+ ordered_set = set(ordered)
737
+ new_names = [s for s in on_disk if s not in ordered_set]
738
+ resolved = ordered + new_names
739
+
740
+ if saved_order != resolved:
741
+ self._settings.executed_scenario_order = list(resolved)
742
+ try:
743
+ self._schedule_settings_save()
744
+ except Exception: # noqa: BLE001
745
+ logger.warning(
746
+ "Failed to schedule executed_scenario_order save",
747
+ exc_info=True,
748
+ )
749
+
750
+ return resolved
751
+
752
+ def _populate_scenarios(self) -> None:
753
+ """Populate the scenario listbox."""
754
+ self._scenario_listbox.delete(0, "end")
755
+ scenarios = self._scan_scenarios()
756
+ for name in scenarios:
757
+ self._scenario_listbox.insert("end", name)
758
+
759
+ if not scenarios:
760
+ return
761
+
762
+ # Try to restore last selected scenario
763
+ target = self._viewer_settings.last_scenario
764
+ idx = 0
765
+ if target and target in scenarios:
766
+ idx = scenarios.index(target)
767
+
768
+ self._scenario_listbox.selection_set(idx)
769
+ self._scenario_listbox.see(idx)
770
+ self._scenario_listbox.event_generate("<<ListboxSelect>>")
771
+
772
+ def show_scenario_in_single_mode(self, scenario_name: str) -> None:
773
+ """Switch to single mode and select *scenario_name* in the list.
774
+
775
+ Used by the main window's "view" button so a click on one
776
+ executed scenario lands the user on its single-mode view. The
777
+ plot entry/variant restoration that follows is driven by the
778
+ viewer's existing last-entry/last-variant tracking, which is
779
+ intentionally not scenario-specific.
780
+ """
781
+ if self._mode.get() != "single":
782
+ self._mode.set("single")
783
+ self._on_mode_changed()
784
+ try:
785
+ entries = list(self._scenario_listbox.get(0, "end"))
786
+ except tk.TclError:
787
+ return
788
+ if scenario_name not in entries:
789
+ return
790
+ idx = entries.index(scenario_name)
791
+ self._scenario_listbox.selection_clear(0, "end")
792
+ self._scenario_listbox.selection_set(idx)
793
+ self._scenario_listbox.see(idx)
794
+ self._scenario_listbox.event_generate("<<ListboxSelect>>")
795
+
796
+ # ------------------------------------------------------------------
797
+ # Plot tree population
798
+ # ------------------------------------------------------------------
799
+
800
+ def _get_config_path_for_mode(self) -> Path:
801
+ """Return the config path for the current mode."""
802
+ mode = self._mode.get()
803
+ if mode == "comparison":
804
+ return self._comparison_config_path
805
+ return self._single_config_path
806
+
807
+ def _populate_plot_tree(self) -> None:
808
+ """Parse config YAML and build the plot tree."""
809
+ # Clear existing tree
810
+ for item in self._plot_tree.get_children():
811
+ self._plot_tree.delete(item)
812
+ self._tree_entry_map.clear()
813
+
814
+ mode = self._mode.get()
815
+ if mode == "network" or mode == "dispatch":
816
+ return
817
+
818
+ config_path = self._get_config_path_for_mode()
819
+ parsed = parse_plot_config(config_path)
820
+ if isinstance(parsed, PlotConfigData):
821
+ self._plot_groups = parsed.groups
822
+ self._template_default_durations = dict(parsed.default_durations)
823
+ else: # defensive: legacy list shape
824
+ self._plot_groups = list(parsed)
825
+ self._template_default_durations = {}
826
+
827
+ for group in self._plot_groups:
828
+ group_iid = f"group_{group.number}"
829
+ group_text = f"{group.number} {group.name}"
830
+ self._plot_tree.insert(
831
+ "", "end", iid=group_iid, text=group_text, open=True,
832
+ )
833
+
834
+ for entry in group.entries:
835
+ entry_iid = f"entry_{entry.number}"
836
+ label = f"{entry.number} {entry.full_name}"
837
+ self._plot_tree.insert(
838
+ group_iid, "end", iid=entry_iid, text=label,
839
+ )
840
+ self._tree_entry_map[entry_iid] = entry
841
+
842
+ # Collect all unique variant letters and size the canvas
843
+ self._collect_all_variant_letters()
844
+ self._update_variant_canvas_width()
845
+
846
+ # Grey out entries without matching parquet data
847
+ self._update_tree_availability()
848
+
849
+ # Try to restore last selected entry or select first available
850
+ self._restore_or_select_first_entry()
851
+
852
+ def _load_availability_from_dir(self, plan_dir: Path) -> set[tuple[str, str]]:
853
+ """Load availability manifest from plan_dir/_availability.json."""
854
+ import json
855
+
856
+ avail_path = plan_dir / "_availability.json"
857
+ if not avail_path.exists():
858
+ return set()
859
+ try:
860
+ with open(avail_path) as f:
861
+ data = json.load(f)
862
+ return {(r, s) for r, s in data.get("available", [])}
863
+ except (json.JSONDecodeError, OSError):
864
+ return set()
865
+
866
+ def _resolve_comparison_availability(self) -> set[tuple[str, str]]:
867
+ """Return the (result_key, sub_config) availability for comparison mode.
868
+
869
+ Resolution order (Phase C):
870
+
871
+ 1. If ``output_parquet_comparison/plot_plans/_availability.json``
872
+ exists AND ``output_parquet_comparison/_metadata.json`` records
873
+ the same viewer-scenarios set as
874
+ :meth:`_get_comparison_viewer_scenarios`, use the combined
875
+ availability file (authoritative for the actual combined plan
876
+ parquets).
877
+ 2. Else compute the **union** of each viewer scenario's per-scenario
878
+ ``output_parquet/<scenario>/plot_plans/_availability.json``.
879
+ This makes grey-out state accurate immediately after a "Update
880
+ view scenarios" press, before the slow combine has finished
881
+ rewriting the comparison availability file.
882
+ 3. Else return an empty set (everything greys out, no plans known).
883
+
884
+ A per-scenario file missing for one of the viewer scenarios is
885
+ treated as "no contribution" — the union proceeds with whatever
886
+ files are present. This silently drops coverage for that scenario
887
+ until its per-scenario run finishes; that's deliberate so the user
888
+ sees the most accurate available picture without erroring out.
889
+ """
890
+ comparison_plan_dir = (
891
+ self._project_path / "output_parquet_comparison" / "plot_plans"
892
+ )
893
+ comparison_avail_path = comparison_plan_dir / "_availability.json"
894
+
895
+ viewer_scenarios = self._get_comparison_viewer_scenarios()
896
+ viewer_set = {str(s) for s in viewer_scenarios}
897
+
898
+ if comparison_avail_path.exists():
899
+ metadata_scenarios = set(self._read_metadata_scenarios())
900
+ if metadata_scenarios == viewer_set and viewer_set:
901
+ return self._load_availability_from_dir(comparison_plan_dir)
902
+
903
+ if not viewer_set:
904
+ return set()
905
+
906
+ union: set[tuple[str, str]] = set()
907
+ for scenario in viewer_scenarios:
908
+ plan_dir = (
909
+ self._project_path / "output_parquet" / scenario / "plot_plans"
910
+ )
911
+ union |= self._load_availability_from_dir(plan_dir)
912
+ return union
913
+
914
+ def _update_tree_availability(self) -> None:
915
+ """Grey out entries that have no matching parquet/plan data for selected scenario(s).
916
+
917
+ Also stores the loaded availability set in ``self._current_availability``
918
+ so that the variant grid can distinguish *defined-but-unavailable* from
919
+ *available* variants.
920
+ """
921
+ mode = self._mode.get()
922
+
923
+ if mode == "comparison":
924
+ available_pairs = self._resolve_comparison_availability()
925
+ else:
926
+ scenarios = self._get_selected_scenarios()
927
+ if not scenarios:
928
+ # No scenario selected -- mark all as disabled
929
+ self._current_availability = set()
930
+ for iid in self._tree_entry_map:
931
+ self._plot_tree.item(iid, tags=("disabled",))
932
+ return
933
+
934
+ # Merge availability across all selected scenarios
935
+ available_pairs: set[tuple[str, str]] = set()
936
+ for scenario in scenarios:
937
+ plan_dir = self._project_path / "output_parquet" / scenario / "plot_plans"
938
+ available_pairs |= self._load_availability_from_dir(plan_dir)
939
+
940
+ self._current_availability = available_pairs
941
+
942
+ for iid, entry in self._tree_entry_map.items():
943
+ # An entry is available if ANY of its variants has data
944
+ has_any = any(
945
+ (v.result_key, v.sub_config) in available_pairs
946
+ or (v.result_key, "*") in available_pairs
947
+ for v in entry.variants
948
+ )
949
+ if has_any:
950
+ self._plot_tree.item(iid, tags=())
951
+ else:
952
+ self._plot_tree.item(iid, tags=("disabled",))
953
+
954
+ def _is_entry_disabled(self, iid: str) -> bool:
955
+ """Return True if the tree item has the 'disabled' tag."""
956
+ return "disabled" in self._plot_tree.item(iid, "tags")
957
+
958
+ def _restore_or_select_first_entry(self) -> None:
959
+ """Select the last entry from settings, or the first available entry."""
960
+ target_iid = f"entry_{self._viewer_settings.last_entry}" if self._viewer_settings.last_entry else ""
961
+
962
+ if target_iid and self._plot_tree.exists(target_iid) and not self._is_entry_disabled(target_iid):
963
+ self._plot_tree.selection_set(target_iid)
964
+ self._plot_tree.see(target_iid)
965
+ return
966
+
967
+ # Select first non-disabled entry
968
+ for group_iid in self._plot_tree.get_children():
969
+ for entry_iid in self._plot_tree.get_children(group_iid):
970
+ if not self._is_entry_disabled(entry_iid):
971
+ self._plot_tree.selection_set(entry_iid)
972
+ self._plot_tree.see(entry_iid)
973
+ return
974
+
975
+ # ------------------------------------------------------------------
976
+ # Scenario selection
977
+ # ------------------------------------------------------------------
978
+
979
+ def _get_selected_scenarios(self) -> list[str]:
980
+ """Return the list of currently selected scenario names."""
981
+ if self._mode.get() == "comparison":
982
+ return self._get_comparison_scenarios()
983
+ indices = self._scenario_listbox.curselection()
984
+ return [self._scenario_listbox.get(i) for i in indices]
985
+
986
+ def _on_scenario_selected(self, _event: tk.Event | None = None) -> None:
987
+ """Handle scenario listbox selection change."""
988
+ scenarios = self._get_selected_scenarios()
989
+ if scenarios:
990
+ self._viewer_settings.last_scenario = scenarios[0]
991
+
992
+ mode = self._mode.get()
993
+ if mode == "network":
994
+ self._render_network()
995
+ return
996
+ if mode == "dispatch":
997
+ # Re-populate dispatch tree (nodeGroups may differ per scenario)
998
+ self._dispatch_scenario = "" # force reload
999
+ self._populate_dispatch_tree()
1000
+ # Trigger replot if something is selected
1001
+ selection = self._plot_tree.selection()
1002
+ if selection and _is_dispatch_iid(selection[0]):
1003
+ if scenarios:
1004
+ self._display_dispatch_iid(scenarios[0], selection[0])
1005
+ return
1006
+ if mode == "comparison":
1007
+ # Scenario selection is informational in comparison mode
1008
+ self._trigger_replot()
1009
+ return
1010
+
1011
+ # Single mode
1012
+ self._update_tree_availability()
1013
+ # Re-select entry if current one became disabled
1014
+ selection = self._plot_tree.selection()
1015
+ if selection and self._is_entry_disabled(selection[0]):
1016
+ self._restore_or_select_first_entry()
1017
+ else:
1018
+ self._trigger_replot()
1019
+
1020
+ # ------------------------------------------------------------------
1021
+ # Tree selection
1022
+ # ------------------------------------------------------------------
1023
+
1024
+ def _on_tree_selected(self, _event: tk.Event | None = None) -> None:
1025
+ """Handle plot tree selection change."""
1026
+ selection = self._plot_tree.selection()
1027
+ if not selection:
1028
+ return
1029
+
1030
+ iid = selection[0]
1031
+
1032
+ # Skip group headers — let the tree handle expand/collapse
1033
+ if iid.startswith("group_"):
1034
+ return
1035
+
1036
+ # Handle dispatch mode. ``_display_dispatch_iid`` routes a node
1037
+ # child (``dispatchnode_``) vs a nodeGroup parent (``dispatch_``).
1038
+ if _is_dispatch_iid(iid):
1039
+ scenarios = self._get_selected_scenarios()
1040
+ if scenarios:
1041
+ self._display_dispatch_iid(scenarios[0], iid)
1042
+ return
1043
+
1044
+ # If disabled, find nearest non-disabled entry
1045
+ if self._is_entry_disabled(iid):
1046
+ self._restore_or_select_first_entry()
1047
+ return
1048
+
1049
+ entry = self._tree_entry_map.get(iid)
1050
+ if entry is None:
1051
+ return
1052
+
1053
+ # Update viewer settings
1054
+ self._viewer_settings.last_entry = entry.number
1055
+
1056
+ # Update active entry iid
1057
+ self._active_entry_iid = iid
1058
+
1059
+ # Reset file index and clear prefetched figures
1060
+ self._file_index = 0
1061
+ self._file_count = 1
1062
+ self._clear_figure_cache()
1063
+
1064
+ self._update_file_nav()
1065
+ self._populate_variant_panel(entry)
1066
+ self._trigger_replot()
1067
+
1068
+ # ------------------------------------------------------------------
1069
+ # Tree keyboard navigation
1070
+ # ------------------------------------------------------------------
1071
+
1072
+ def _get_all_visible_items(self) -> list[str]:
1073
+ """Return flat list of all visible tree items (groups + entries)."""
1074
+ visible: list[str] = []
1075
+ for group_iid in self._plot_tree.get_children():
1076
+ visible.append(group_iid)
1077
+ if self._plot_tree.item(group_iid, "open"):
1078
+ for entry_iid in self._plot_tree.get_children(group_iid):
1079
+ visible.append(entry_iid)
1080
+ return visible
1081
+
1082
+ def _should_skip_item(self, iid: str) -> bool:
1083
+ """Return True if this item should be skipped during navigation.
1084
+
1085
+ Open groups are skipped (their children are visible).
1086
+ Closed groups stop the cursor so the user can open them.
1087
+ Disabled (greyed-out) entries are always skipped.
1088
+ """
1089
+ if "disabled" in self._plot_tree.item(iid, "tags"):
1090
+ return True
1091
+ if not iid.startswith("group_"):
1092
+ return False
1093
+ return bool(self._plot_tree.item(iid, "open"))
1094
+
1095
+ def _on_tree_key_right(self, event: tk.Event) -> str:
1096
+ """Handle Right arrow in tree.
1097
+
1098
+ On a closed group: open it (default behavior).
1099
+ On an entry or open group: move focus to variant canvas.
1100
+ """
1101
+ selection = self._plot_tree.selection()
1102
+ if selection:
1103
+ iid = selection[0]
1104
+ if iid.startswith("group_") and not self._plot_tree.item(iid, "open"):
1105
+ # Let default handler open the branch
1106
+ self._plot_tree.item(iid, open=True)
1107
+ self._plot_tree.event_generate("<<TreeviewOpen>>")
1108
+ return "break"
1109
+ # Move focus to variant canvas
1110
+ self._variant_canvas.focus_set()
1111
+ return "break"
1112
+
1113
+ def _on_tree_key_left(self, event: tk.Event) -> str:
1114
+ """Handle Left arrow in tree.
1115
+
1116
+ On an open group: close it.
1117
+ On an entry: select the parent group if it's open, or do nothing.
1118
+ """
1119
+ selection = self._plot_tree.selection()
1120
+ if selection:
1121
+ iid = selection[0]
1122
+ if iid.startswith("group_") and self._plot_tree.item(iid, "open"):
1123
+ self._plot_tree.item(iid, open=False)
1124
+ self._plot_tree.event_generate("<<TreeviewClose>>")
1125
+ return "break"
1126
+ if iid.startswith("entry_"):
1127
+ # Move to parent group
1128
+ parent = self._plot_tree.parent(iid)
1129
+ if parent:
1130
+ self._plot_tree.selection_set(parent)
1131
+ self._plot_tree.see(parent)
1132
+ return "break"
1133
+ return "break"
1134
+
1135
+ def _on_tree_key_up(self, event: tk.Event) -> str:
1136
+ """Move selection up in the tree.
1137
+
1138
+ Skips open group headers (their children are navigable instead).
1139
+ Stops on closed group headers so user can open them.
1140
+ """
1141
+ visible = self._get_all_visible_items()
1142
+ selection = self._plot_tree.selection()
1143
+ if not selection:
1144
+ return "break"
1145
+ current = selection[0]
1146
+ if current not in visible:
1147
+ # Current item not visible (e.g., parent was closed) — select last visible
1148
+ if visible:
1149
+ self._plot_tree.selection_set(visible[-1])
1150
+ self._plot_tree.see(visible[-1])
1151
+ return "break"
1152
+ idx = visible.index(current)
1153
+ for new_idx in range(idx - 1, -1, -1):
1154
+ if not self._should_skip_item(visible[new_idx]):
1155
+ self._plot_tree.selection_set(visible[new_idx])
1156
+ self._plot_tree.see(visible[new_idx])
1157
+ self._plot_tree.event_generate("<<TreeviewSelect>>")
1158
+ return "break"
1159
+ return "break"
1160
+
1161
+ def _on_tree_key_down(self, event: tk.Event) -> str:
1162
+ """Move selection down in the tree.
1163
+
1164
+ Skips open group headers (their children are navigable instead).
1165
+ Stops on closed group headers so user can open them.
1166
+ """
1167
+ visible = self._get_all_visible_items()
1168
+ selection = self._plot_tree.selection()
1169
+ if not selection:
1170
+ return "break"
1171
+ current = selection[0]
1172
+ if current not in visible:
1173
+ # Current item not visible — select first visible
1174
+ if visible:
1175
+ self._plot_tree.selection_set(visible[0])
1176
+ self._plot_tree.see(visible[0])
1177
+ return "break"
1178
+ idx = visible.index(current)
1179
+ for new_idx in range(idx + 1, len(visible)):
1180
+ if not self._should_skip_item(visible[new_idx]):
1181
+ self._plot_tree.selection_set(visible[new_idx])
1182
+ self._plot_tree.see(visible[new_idx])
1183
+ self._plot_tree.event_generate("<<TreeviewSelect>>")
1184
+ return "break"
1185
+ return "break"
1186
+
1187
+ # ------------------------------------------------------------------
1188
+ # Tree tooltip
1189
+ # ------------------------------------------------------------------
1190
+
1191
+ def _on_tree_motion(self, event: tk.Event) -> None:
1192
+ """Show tooltip with full name when hovering over an entry."""
1193
+ item = self._plot_tree.identify_row(event.y)
1194
+ if not item or (
1195
+ not item.startswith("entry_") and not _is_dispatch_iid(item)
1196
+ ):
1197
+ self._hide_tooltip()
1198
+ return
1199
+
1200
+ # Dispatch items show the nodeGroup / node name. Check the node
1201
+ # prefix first; node iids are position-qualified, so recover the
1202
+ # node name from the map rather than slicing the prefix.
1203
+ if item.startswith(_DISPATCH_NODE_PREFIX):
1204
+ full_text = self._dispatch_node_iids.get(item, "")
1205
+ elif item.startswith(_DISPATCH_GROUP_PREFIX):
1206
+ full_text = item[len(_DISPATCH_GROUP_PREFIX):]
1207
+ else:
1208
+ entry = self._tree_entry_map.get(item)
1209
+ if entry is None:
1210
+ self._hide_tooltip()
1211
+ return
1212
+ full_text = self._entry_tooltip_text(entry)
1213
+ if self._tooltip is not None:
1214
+ try:
1215
+ self._tooltip_label.configure(text=full_text)
1216
+ self._tooltip.geometry(
1217
+ f"+{event.x_root + 15}+{event.y_root + 10}"
1218
+ )
1219
+ return
1220
+ except tk.TclError:
1221
+ self._tooltip = None
1222
+
1223
+ self._tooltip = tk.Toplevel(self)
1224
+ self._tooltip.wm_overrideredirect(True)
1225
+ self._tooltip.wm_geometry(f"+{event.x_root + 15}+{event.y_root + 10}")
1226
+
1227
+ # Use a plain tk.Label (not ttk) so we can set explicit colors
1228
+ # that work in both light and dark themes.
1229
+ self._tooltip_label = tk.Label(
1230
+ self._tooltip, text=full_text,
1231
+ background="#333333", foreground="#ffffff",
1232
+ relief="solid", borderwidth=1,
1233
+ padx=4, pady=2,
1234
+ justify="left", anchor="w",
1235
+ )
1236
+ self._tooltip_label.pack()
1237
+
1238
+ def _entry_tooltip_text(self, entry: PlotEntry) -> str:
1239
+ """Entry label plus, per temporal variant, the output's unit and
1240
+ semantics from the derived metadata catalog. Makes the
1241
+ rate-vs-annualized distinction (e.g. ``[t] MW · instantaneous`` vs
1242
+ ``[d] MWh/a · annualized``) visible on hover without opening the file.
1243
+ """
1244
+ from flextool.process_outputs._output_meta import result_variant_summary
1245
+
1246
+ lines = [f"{entry.number} {entry.full_name}"]
1247
+ seen: set[tuple[str, str]] = set()
1248
+ for variant in entry.variants:
1249
+ key = (variant.letter, variant.result_key)
1250
+ if key in seen:
1251
+ continue
1252
+ seen.add(key)
1253
+ summary = result_variant_summary(variant.result_key, variant.letter)
1254
+ if summary is None:
1255
+ continue
1256
+ unit, semantics, _desc = summary
1257
+ lines.append(f" [{variant.letter}] {unit or 'ratio'} · {semantics}")
1258
+ return "\n".join(lines)
1259
+
1260
+ def _hide_tooltip(self, _event: tk.Event | None = None) -> None:
1261
+ """Destroy the tooltip if it exists."""
1262
+ if self._tooltip is not None:
1263
+ try:
1264
+ self._tooltip.destroy()
1265
+ except tk.TclError:
1266
+ pass
1267
+ self._tooltip = None
1268
+
1269
+ # ------------------------------------------------------------------
1270
+ # Variant panel
1271
+ # ------------------------------------------------------------------
1272
+
1273
+ def _collect_all_variant_letters(self) -> None:
1274
+ """Collect all unique variant letters, respecting order_of_variants from config."""
1275
+ # Gather all letters that actually appear in the config
1276
+ all_letters: set[str] = set()
1277
+ for group in self._plot_groups:
1278
+ for entry in group.entries:
1279
+ for v in entry.variants:
1280
+ all_letters.add(v.letter)
1281
+
1282
+ # Read order_of_variants from the YAML config
1283
+ config_path = self._get_config_path_for_mode()
1284
+ config_order: list[str] = []
1285
+ if config_path in self._yaml_cache:
1286
+ config_order = self._yaml_cache[config_path].get("order_of_variants", [])
1287
+ elif config_path.is_file():
1288
+ try:
1289
+ with open(config_path, "r", encoding="utf-8") as f:
1290
+ data = yaml.safe_load(f)
1291
+ if isinstance(data, dict):
1292
+ config_order = data.get("order_of_variants", [])
1293
+ self._yaml_cache[config_path] = data
1294
+ except (yaml.YAMLError, OSError):
1295
+ pass
1296
+
1297
+ # Use config order for known letters, then append any extras
1298
+ ordered: list[str] = []
1299
+ for letter in config_order:
1300
+ if letter in all_letters:
1301
+ ordered.append(letter)
1302
+ all_letters.discard(letter)
1303
+ # Append any remaining letters not in the config order
1304
+ for letter in sorted(all_letters):
1305
+ ordered.append(letter)
1306
+
1307
+ self._all_variant_letters = ordered
1308
+
1309
+ def _update_variant_canvas_width(self) -> None:
1310
+ """Set the variant canvas width based on the number of variant letters."""
1311
+ if not self._all_variant_letters:
1312
+ self._variant_canvas.configure(width=0)
1313
+ return
1314
+ box_w = self._char_width * 2
1315
+ n_letters = len(self._all_variant_letters)
1316
+ self._variant_canvas.configure(width=n_letters * box_w + 2)
1317
+
1318
+ def _schedule_variant_redraw(self) -> None:
1319
+ """Schedule a variant grid redraw, coalescing rapid events."""
1320
+ if self._variant_redraw_id is not None:
1321
+ self.after_cancel(self._variant_redraw_id)
1322
+ self._variant_redraw_id = self.after(10, self._do_variant_redraw)
1323
+
1324
+ def _do_variant_redraw(self) -> None:
1325
+ """Execute the deferred variant grid redraw."""
1326
+ self._variant_redraw_id = None
1327
+ self._redraw_variant_grid()
1328
+
1329
+ def _redraw_variant_grid(self) -> None:
1330
+ """Redraw all variant boxes on the canvas to align with visible tree rows."""
1331
+ self._variant_canvas.delete("all")
1332
+
1333
+ if not self._all_variant_letters:
1334
+ return
1335
+
1336
+ box_w = self._char_width * 2
1337
+
1338
+ # Draw the letters in the *same* font the themed tree rows use, not
1339
+ # the bare TkDefaultFont. Under sv_ttk a ttk.Treeview renders its
1340
+ # cells in SunValleyBodyFont (≈14pt) while TkDefaultFont is ≈10pt,
1341
+ # so a hard-coded TkDefaultFont made the variant letters look 2-3
1342
+ # sizes smaller than the adjacent tree text on every platform.
1343
+ letter_font = str(
1344
+ ttk.Style().lookup("PlotTree.Treeview", "font")
1345
+ ) or "TkDefaultFont"
1346
+
1347
+ # Determine focus state
1348
+ try:
1349
+ canvas_has_focus = (self.focus_get() is self._variant_canvas)
1350
+ except KeyError:
1351
+ canvas_has_focus = False
1352
+ tree_selection = self._plot_tree.selection()
1353
+ selected_iid = tree_selection[0] if tree_selection else ""
1354
+
1355
+ for iid, entry in self._tree_entry_map.items():
1356
+ try:
1357
+ bbox = self._plot_tree.bbox(iid)
1358
+ except (tk.TclError, ValueError):
1359
+ continue
1360
+ if not bbox:
1361
+ continue # item not visible
1362
+
1363
+ _, y, _, row_h = bbox
1364
+ # Build a lookup from letter -> PlotVariant for this entry
1365
+ variant_by_letter: dict[str, object] = {v.letter: v for v in entry.variants}
1366
+
1367
+ for col_idx, letter in enumerate(self._all_variant_letters):
1368
+ x = col_idx * box_w
1369
+
1370
+ # Find the PlotVariant for this letter (if defined for this entry)
1371
+ variant = variant_by_letter.get(letter)
1372
+ is_defined = variant is not None
1373
+
1374
+ # Check if this specific variant has data (is available)
1375
+ is_available = False
1376
+ if is_defined:
1377
+ is_available = (
1378
+ (variant.result_key, variant.sub_config) in self._current_availability
1379
+ or (variant.result_key, "*") in self._current_availability
1380
+ )
1381
+
1382
+ # Active plot indicator (solid blue) -- always visible for the displayed plot
1383
+ is_active = (
1384
+ is_defined
1385
+ and iid == self._active_entry_iid
1386
+ and letter == self._active_variant
1387
+ )
1388
+
1389
+ # Focus cursor (dashed rectangle) -- shown on ANY cell when canvas has focus
1390
+ is_focused = (
1391
+ canvas_has_focus
1392
+ and iid == selected_iid
1393
+ and col_idx == self._focus_col
1394
+ )
1395
+
1396
+ # Visual states for defined variants
1397
+ if is_active:
1398
+ self._variant_canvas.create_rectangle(
1399
+ x + 1, y + 1, x + box_w - 1, y + row_h - 1,
1400
+ fill="#2074d5", outline="",
1401
+ )
1402
+ text_color = "#ffffff"
1403
+ elif not is_defined:
1404
+ text_color = None # no text for undefined
1405
+ elif not is_available:
1406
+ text_color = "grey"
1407
+ else:
1408
+ text_color = self._fg_color
1409
+
1410
+ # Focus rectangle drawn on ANY cell (including blank/grey)
1411
+ if is_focused:
1412
+ self._variant_canvas.create_rectangle(
1413
+ x + 2, y + 2, x + box_w - 2, y + row_h - 2,
1414
+ outline="#ff8800", width=2, dash=(4, 4),
1415
+ )
1416
+
1417
+ # Draw letter text (skip for undefined cells)
1418
+ if text_color is not None:
1419
+ display = letter if letter else "?"
1420
+ self._variant_canvas.create_text(
1421
+ x + box_w // 2, y + row_h // 2,
1422
+ text=display, fill=text_color, font=letter_font,
1423
+ )
1424
+
1425
+ def _on_variant_canvas_click(self, event: tk.Event) -> None:
1426
+ """Handle click on a variant box in the canvas."""
1427
+ self._variant_canvas.focus_set()
1428
+ if not self._all_variant_letters:
1429
+ return
1430
+
1431
+ box_w = self._char_width * 2
1432
+
1433
+ # Find which letter column was clicked
1434
+ col_idx = int(event.x / box_w)
1435
+ if col_idx < 0 or col_idx >= len(self._all_variant_letters):
1436
+ return
1437
+ self._focus_col = col_idx
1438
+
1439
+ # Find which entry row was clicked (match y to tree bbox)
1440
+ for iid, entry in self._tree_entry_map.items():
1441
+ try:
1442
+ bbox = self._plot_tree.bbox(iid)
1443
+ except (tk.TclError, ValueError):
1444
+ continue
1445
+ if not bbox:
1446
+ continue
1447
+ _, y, _, row_h = bbox
1448
+ if y <= event.y < y + row_h:
1449
+ # Select this entry in tree
1450
+ self._plot_tree.selection_set(iid)
1451
+ self._plot_tree.see(iid)
1452
+ # Try to activate the focused cell
1453
+ self._try_activate_focused()
1454
+ self._redraw_variant_grid()
1455
+ return
1456
+
1457
+ def _find_nearest_available(self, available: set[str]) -> str:
1458
+ """Find nearest available variant letter to the active one.
1459
+
1460
+ Searches left first, then right in the _all_variant_letters list.
1461
+ """
1462
+ if not available:
1463
+ return ""
1464
+ if self._active_variant in available:
1465
+ return self._active_variant
1466
+
1467
+ try:
1468
+ idx = self._all_variant_letters.index(self._active_variant)
1469
+ except ValueError:
1470
+ idx = 0
1471
+
1472
+ # Search left then right
1473
+ for offset in range(1, len(self._all_variant_letters)):
1474
+ left_idx = idx - offset
1475
+ if left_idx >= 0 and self._all_variant_letters[left_idx] in available:
1476
+ return self._all_variant_letters[left_idx]
1477
+ right_idx = idx + offset
1478
+ if right_idx < len(self._all_variant_letters) and self._all_variant_letters[right_idx] in available:
1479
+ return self._all_variant_letters[right_idx]
1480
+
1481
+ # Fallback
1482
+ return next(iter(available))
1483
+
1484
+ def _populate_variant_panel(self, entry: PlotEntry) -> None:
1485
+ """Update variant state for the given entry and redraw the grid.
1486
+
1487
+ If the active variant is not available for this entry, picks the
1488
+ nearest available variant. The active variant persists across
1489
+ tree navigation when possible.
1490
+ """
1491
+ available_with_data: set[str] = set()
1492
+ for v in entry.variants:
1493
+ is_avail = (
1494
+ (v.result_key, v.sub_config) in self._current_availability
1495
+ or (v.result_key, "*") in self._current_availability
1496
+ )
1497
+ if is_avail:
1498
+ available_with_data.add(v.letter)
1499
+
1500
+ if self._active_variant not in available_with_data and available_with_data:
1501
+ self._active_variant = self._find_nearest_available(available_with_data)
1502
+
1503
+ self._redraw_variant_grid()
1504
+
1505
+ def _on_variant_clicked(self, letter: str) -> None:
1506
+ """Handle variant selection (from canvas click or keyboard)."""
1507
+ selection = self._plot_tree.selection()
1508
+ if not selection:
1509
+ return
1510
+ iid = selection[0]
1511
+ if not iid.startswith("entry_"):
1512
+ return
1513
+ entry = self._tree_entry_map.get(iid)
1514
+ if not entry:
1515
+ return
1516
+
1517
+ # Only act if the variant is defined and available for the selected entry
1518
+ variant = next((v for v in entry.variants if v.letter == letter), None)
1519
+ if variant is None:
1520
+ return # not defined
1521
+ is_available = (
1522
+ (variant.result_key, variant.sub_config) in self._current_availability
1523
+ or (variant.result_key, "*") in self._current_availability
1524
+ )
1525
+ if not is_available:
1526
+ return # defined but no data
1527
+
1528
+ self._active_variant = letter
1529
+ self._active_entry_iid = iid
1530
+ self._viewer_settings.last_variant = letter
1531
+ self._redraw_variant_grid()
1532
+ self._file_index = 0
1533
+ self._clear_figure_cache()
1534
+ self._trigger_replot()
1535
+
1536
+ def _on_variant_left(self, event: tk.Event) -> str:
1537
+ """Navigate focus cursor left in the variant grid.
1538
+
1539
+ Moves to ANY cell position (including empty/undefined ones).
1540
+ At the leftmost position, returns focus to the tree.
1541
+ """
1542
+ if self._focus_col <= 0:
1543
+ # At leftmost -- return focus to tree
1544
+ self._plot_tree.focus_set()
1545
+ return "break"
1546
+ self._focus_col -= 1
1547
+ self._try_activate_focused()
1548
+ self._redraw_variant_grid()
1549
+ return "break"
1550
+
1551
+ def _on_variant_right(self, event: tk.Event) -> str:
1552
+ """Navigate focus cursor right in the variant grid.
1553
+
1554
+ Moves to ANY cell position (including empty/undefined ones).
1555
+ """
1556
+ if not self._all_variant_letters:
1557
+ return "break"
1558
+ if self._focus_col >= len(self._all_variant_letters) - 1:
1559
+ return "break" # at rightmost
1560
+ self._focus_col += 1
1561
+ self._try_activate_focused()
1562
+ self._redraw_variant_grid()
1563
+ return "break"
1564
+
1565
+ def _get_selected_entry_available_variants(self) -> set[str]:
1566
+ """Return variant letters that are both defined and have data for the selected entry."""
1567
+ selection = self._plot_tree.selection()
1568
+ if selection and selection[0].startswith("entry_"):
1569
+ entry = self._tree_entry_map.get(selection[0])
1570
+ if entry:
1571
+ result: set[str] = set()
1572
+ for v in entry.variants:
1573
+ if (
1574
+ (v.result_key, v.sub_config) in self._current_availability
1575
+ or (v.result_key, "*") in self._current_availability
1576
+ ):
1577
+ result.add(v.letter)
1578
+ return result
1579
+ return set()
1580
+
1581
+ def _on_variant_key_up(self, event: tk.Event) -> str:
1582
+ """Handle Up / Shift+Up in the variant panel.
1583
+
1584
+ - Up: jump to prev row with any available data. Show focus variant
1585
+ if available, otherwise find nearest available and activate it.
1586
+ - Shift+Up: jump to prev row that has available data at the
1587
+ current focus column specifically.
1588
+ """
1589
+ shift_held = bool(event.state & 0x1)
1590
+ if shift_held:
1591
+ self._move_to_next_with_focus_col(-1)
1592
+ else:
1593
+ self._move_to_next_available_row(-1)
1594
+ return "break"
1595
+
1596
+ def _on_variant_key_down(self, event: tk.Event) -> str:
1597
+ """Handle Down / Shift+Down in the variant panel.
1598
+
1599
+ - Down: jump to next row with any available data. Show focus variant
1600
+ if available, otherwise find nearest available and activate it.
1601
+ - Shift+Down: jump to next row that has available data at the
1602
+ current focus column specifically.
1603
+ """
1604
+ shift_held = bool(event.state & 0x1)
1605
+ if shift_held:
1606
+ self._move_to_next_with_focus_col(1)
1607
+ else:
1608
+ self._move_to_next_available_row(1)
1609
+ return "break"
1610
+
1611
+ def _get_visible_entries(self) -> list[str]:
1612
+ """Return flat list of entry iids in open branches."""
1613
+ visible: list[str] = []
1614
+ for group_iid in self._plot_tree.get_children():
1615
+ if self._plot_tree.item(group_iid, "open"):
1616
+ for entry_iid in self._plot_tree.get_children(group_iid):
1617
+ visible.append(entry_iid)
1618
+ return visible
1619
+
1620
+ def _move_tree_selection(self, direction: int) -> None:
1621
+ """Move tree selection by *direction* (-1 = up, +1 = down), skipping disabled entries."""
1622
+ selection = self._plot_tree.selection()
1623
+ if not selection:
1624
+ self._restore_or_select_first_entry()
1625
+ return
1626
+
1627
+ current = selection[0]
1628
+ visible = self._get_visible_entries()
1629
+
1630
+ if current not in visible:
1631
+ self._restore_or_select_first_entry()
1632
+ return
1633
+
1634
+ idx = visible.index(current)
1635
+ step = 1 if direction > 0 else -1
1636
+ new_idx = idx + step
1637
+
1638
+ while 0 <= new_idx < len(visible):
1639
+ if not self._is_entry_disabled(visible[new_idx]):
1640
+ self._plot_tree.selection_set(visible[new_idx])
1641
+ self._plot_tree.see(visible[new_idx])
1642
+ self._plot_tree.event_generate("<<TreeviewSelect>>")
1643
+ return
1644
+ new_idx += step
1645
+
1646
+ def _move_to_next_with_focus_col(self, direction: int) -> None:
1647
+ """Jump to next/prev row that has available data at the focus column.
1648
+
1649
+ The focus column stays fixed; only rows where that specific
1650
+ variant letter has data are considered.
1651
+ """
1652
+ if self._focus_col < 0 or self._focus_col >= len(self._all_variant_letters):
1653
+ return
1654
+ focus_letter = self._all_variant_letters[self._focus_col]
1655
+
1656
+ visible = self._get_visible_entries()
1657
+ selection = self._plot_tree.selection()
1658
+ if not selection or selection[0] not in visible:
1659
+ return
1660
+ idx = visible.index(selection[0])
1661
+ new_idx = idx + direction
1662
+
1663
+ while 0 <= new_idx < len(visible):
1664
+ iid = visible[new_idx]
1665
+ entry = self._tree_entry_map.get(iid)
1666
+ if entry:
1667
+ # Check if this entry has an available variant at focus_letter
1668
+ for v in entry.variants:
1669
+ if v.letter == focus_letter and (
1670
+ (v.result_key, v.sub_config) in self._current_availability
1671
+ or (v.result_key, "*") in self._current_availability
1672
+ ):
1673
+ self._plot_tree.selection_set(iid)
1674
+ self._plot_tree.see(iid)
1675
+ self._active_variant = focus_letter
1676
+ self._active_entry_iid = iid
1677
+ self._viewer_settings.last_variant = focus_letter
1678
+ self._viewer_settings.last_entry = entry.number
1679
+ self._file_index = 0
1680
+ self._clear_figure_cache()
1681
+ self._trigger_replot()
1682
+ self._redraw_variant_grid()
1683
+ return
1684
+ new_idx += direction
1685
+
1686
+ def _move_tree_to_next_with_active(self, direction: int) -> None:
1687
+ """Move tree selection to next/prev entry that has the active variant.
1688
+
1689
+ Only stops at non-disabled entries whose variant set includes
1690
+ ``_active_variant``.
1691
+ """
1692
+ visible = self._get_visible_entries()
1693
+ selection = self._plot_tree.selection()
1694
+ if not selection or selection[0] not in visible:
1695
+ return
1696
+
1697
+ idx = visible.index(selection[0])
1698
+ step = 1 if direction > 0 else -1
1699
+ new_idx = idx + step
1700
+
1701
+ while 0 <= new_idx < len(visible):
1702
+ iid = visible[new_idx]
1703
+ if not self._is_entry_disabled(iid):
1704
+ entry = self._tree_entry_map.get(iid)
1705
+ if entry and any(
1706
+ v.letter == self._active_variant
1707
+ and (
1708
+ (v.result_key, v.sub_config) in self._current_availability
1709
+ or (v.result_key, "*") in self._current_availability
1710
+ )
1711
+ for v in entry.variants
1712
+ ):
1713
+ self._plot_tree.selection_set(iid)
1714
+ self._plot_tree.see(iid)
1715
+ self._plot_tree.event_generate("<<TreeviewSelect>>")
1716
+ return
1717
+ new_idx += step
1718
+
1719
+ def _get_current_variant_index(self) -> int:
1720
+ """Return index of the active variant in _all_variant_letters, or 0."""
1721
+ if self._active_variant in self._all_variant_letters:
1722
+ return self._all_variant_letters.index(self._active_variant)
1723
+ return 0
1724
+
1725
+ def _show_variant_panel(self) -> None:
1726
+ """Show the variant canvas."""
1727
+ self._update_variant_canvas_width()
1728
+ self._redraw_variant_grid()
1729
+
1730
+ def _hide_variant_panel(self) -> None:
1731
+ """Hide the variant canvas."""
1732
+ self._variant_canvas.configure(width=0)
1733
+ self._variant_canvas.delete("all")
1734
+
1735
+ # ------------------------------------------------------------------
1736
+ # Focus management
1737
+ # ------------------------------------------------------------------
1738
+
1739
+ def _on_canvas_focus_in(self, event: tk.Event) -> None:
1740
+ """Handle variant canvas receiving focus."""
1741
+ self._focus_col = max(0, self._focus_col) # ensure valid
1742
+ # Hide the plot-tree selection while focus is in the variant
1743
+ # canvas. Scoped to PlotTree.Treeview so other trees keep their
1744
+ # blue selection.
1745
+ style = ttk.Style()
1746
+ style.map(
1747
+ "PlotTree.Treeview",
1748
+ background=[
1749
+ ("selected !focus", self._bg_color),
1750
+ ("selected", self._bg_color),
1751
+ ],
1752
+ foreground=[
1753
+ ("selected !focus", self._fg_color),
1754
+ ("selected", self._fg_color),
1755
+ ],
1756
+ )
1757
+ self._redraw_variant_grid()
1758
+
1759
+ def _on_tree_focus_in(self, event: tk.Event) -> None:
1760
+ """Handle plot tree receiving focus."""
1761
+ self._focus_col = -1 # focus is in tree, not canvas
1762
+ # Restore the plot-tree selection highlight (still scoped to
1763
+ # PlotTree.Treeview).
1764
+ style = ttk.Style()
1765
+ style.map(
1766
+ "PlotTree.Treeview",
1767
+ background=[
1768
+ ("selected !focus", "#2074d5"),
1769
+ ("selected", "#2074d5"),
1770
+ ],
1771
+ foreground=[
1772
+ ("selected !focus", "#ffffff"),
1773
+ ("selected", "#ffffff"),
1774
+ ],
1775
+ )
1776
+ self._redraw_variant_grid()
1777
+
1778
+ def _try_activate_focused(self) -> None:
1779
+ """If the focused cell has an available variant, make it the active plot."""
1780
+ if self._focus_col < 0 or self._focus_col >= len(self._all_variant_letters):
1781
+ return
1782
+ letter = self._all_variant_letters[self._focus_col]
1783
+
1784
+ selection = self._plot_tree.selection()
1785
+ if not selection:
1786
+ return
1787
+ iid = selection[0]
1788
+ entry = self._tree_entry_map.get(iid)
1789
+ if not entry:
1790
+ return
1791
+
1792
+ # Check if this variant is defined AND available
1793
+ variant = next((v for v in entry.variants if v.letter == letter), None)
1794
+ if variant is None:
1795
+ return # undefined -- do nothing
1796
+ is_available = (
1797
+ (variant.result_key, variant.sub_config) in self._current_availability
1798
+ or (variant.result_key, "*") in self._current_availability
1799
+ )
1800
+ if not is_available:
1801
+ return # no data -- do nothing
1802
+
1803
+ # Activate this plot
1804
+ self._active_entry_iid = iid
1805
+ self._active_variant = letter
1806
+ self._viewer_settings.last_variant = letter
1807
+ self._viewer_settings.last_entry = entry.number
1808
+ self._file_index = 0
1809
+ self._clear_figure_cache()
1810
+ self._trigger_replot()
1811
+
1812
+ def _move_tree_selection_from_canvas(
1813
+ self, direction: int, skip_disabled: bool = True,
1814
+ ) -> None:
1815
+ """Move tree selection up/down while keeping focus in canvas.
1816
+
1817
+ When *skip_disabled* is True, disabled (greyed-out) entries are
1818
+ skipped. When False, every visible entry is a valid stop
1819
+ (used by Shift+arrow to allow landing on blank/grey rows).
1820
+ """
1821
+ visible = self._get_visible_entries()
1822
+ selection = self._plot_tree.selection()
1823
+ if not selection or selection[0] not in visible:
1824
+ if visible:
1825
+ self._plot_tree.selection_set(visible[0])
1826
+ return
1827
+ idx = visible.index(selection[0])
1828
+ new_idx = idx + direction
1829
+ while 0 <= new_idx < len(visible):
1830
+ if not skip_disabled or not self._is_entry_disabled(visible[new_idx]):
1831
+ self._plot_tree.selection_set(visible[new_idx])
1832
+ self._plot_tree.see(visible[new_idx])
1833
+ return
1834
+ new_idx += direction
1835
+
1836
+ def _move_to_next_available_row(self, direction: int) -> None:
1837
+ """Move to the next row that has any available data, then activate.
1838
+
1839
+ If the focus variant (_focus_col) is available on the new row,
1840
+ activate it. Otherwise find the nearest available variant on
1841
+ that row and activate that instead. The dashed rectangle stays
1842
+ at _focus_col regardless.
1843
+ """
1844
+ visible = self._get_visible_entries()
1845
+ selection = self._plot_tree.selection()
1846
+ if not selection or selection[0] not in visible:
1847
+ return
1848
+ idx = visible.index(selection[0])
1849
+ new_idx = idx + direction
1850
+
1851
+ while 0 <= new_idx < len(visible):
1852
+ iid = visible[new_idx]
1853
+ entry = self._tree_entry_map.get(iid)
1854
+ if entry:
1855
+ # Check if this row has any available variant
1856
+ available_letters = self._entry_available_letters(entry)
1857
+ if available_letters:
1858
+ self._plot_tree.selection_set(iid)
1859
+ self._plot_tree.see(iid)
1860
+ # Try focus_col variant first
1861
+ focus_letter = (
1862
+ self._all_variant_letters[self._focus_col]
1863
+ if 0 <= self._focus_col < len(self._all_variant_letters)
1864
+ else ""
1865
+ )
1866
+ if focus_letter in available_letters:
1867
+ self._active_variant = focus_letter
1868
+ else:
1869
+ self._active_variant = self._find_nearest_available(
1870
+ available_letters
1871
+ )
1872
+ self._active_entry_iid = iid
1873
+ self._viewer_settings.last_variant = self._active_variant
1874
+ self._viewer_settings.last_entry = entry.number
1875
+ self._file_index = 0
1876
+ self._clear_figure_cache()
1877
+ self._trigger_replot()
1878
+ self._redraw_variant_grid()
1879
+ return
1880
+ new_idx += direction
1881
+
1882
+ def _entry_available_letters(self, entry: PlotEntry) -> set[str]:
1883
+ """Return the set of variant letters with data for an entry."""
1884
+ result: set[str] = set()
1885
+ for v in entry.variants:
1886
+ if (
1887
+ (v.result_key, v.sub_config) in self._current_availability
1888
+ or (v.result_key, "*") in self._current_availability
1889
+ ):
1890
+ result.add(v.letter)
1891
+ return result
1892
+
1893
+ # ------------------------------------------------------------------
1894
+ # Mode switching
1895
+ # ------------------------------------------------------------------
1896
+
1897
+ def _on_mode_changed(self) -> None:
1898
+ """Handle mode radio button change."""
1899
+ mode = self._mode.get()
1900
+ self._viewer_settings.last_mode = mode
1901
+
1902
+ if mode == "comparison":
1903
+ # Show comparison checkboxes, hide scenario listbox.
1904
+ # Phase B: do NOT auto-rebuild based on the live tick state —
1905
+ # rebuilding the combined parquets is exclusively driven by
1906
+ # ``refresh_to_viewer_scenarios`` (i.e. the main window's
1907
+ # "Update view scenarios" button or cold-open reconciliation).
1908
+ # Mode switches inside the viewer are pure view changes.
1909
+ self._scenario_listbox.grid_remove()
1910
+ self._comp_outer_frame.grid()
1911
+ self._populate_comparison_checkboxes()
1912
+ self._populate_plot_tree()
1913
+ self._show_variant_panel()
1914
+ else:
1915
+ # Show scenario listbox, hide comparison checkboxes
1916
+ self._comp_outer_frame.grid_remove()
1917
+ self._scenario_listbox.grid()
1918
+
1919
+ if mode == "single":
1920
+ self._scenario_listbox.configure(selectmode="browse")
1921
+ self._populate_plot_tree()
1922
+ self._show_variant_panel()
1923
+ elif mode == "dispatch":
1924
+ self._scenario_listbox.configure(selectmode="browse")
1925
+ self._hide_variant_panel()
1926
+ # Make sure a scenario is selected so the dispatch tree
1927
+ # and the first figure can actually render. Coming back
1928
+ # from comparison mode, the listbox may retain no
1929
+ # selection (grid_remove doesn't clear it, but _scan
1930
+ # changes may have emptied it).
1931
+ if (
1932
+ not self._scenario_listbox.curselection()
1933
+ and self._scenario_listbox.size() > 0
1934
+ ):
1935
+ self._scenario_listbox.selection_set(0)
1936
+ self._scenario_listbox.see(0)
1937
+ # Populate tree with nodeGroups instead of plot entries
1938
+ self._populate_dispatch_tree()
1939
+ # _populate_dispatch_tree's selection_set fires
1940
+ # <<TreeviewSelect>> but the event arrives after the
1941
+ # tree has also processed a clear (empty → first-item)
1942
+ # transition, which can swallow it. Render explicitly.
1943
+ self._render_first_dispatch_figure()
1944
+ elif mode == "network":
1945
+ self._scenario_listbox.configure(selectmode="browse")
1946
+ # Clear plot tree
1947
+ for item in self._plot_tree.get_children():
1948
+ self._plot_tree.delete(item)
1949
+ self._tree_entry_map.clear()
1950
+ self._hide_variant_panel()
1951
+ self._render_network()
1952
+
1953
+ # ------------------------------------------------------------------
1954
+ # File navigation
1955
+ # ------------------------------------------------------------------
1956
+
1957
+ def _update_file_nav(self) -> None:
1958
+ """Update file navigation label and button states."""
1959
+ self._file_label.configure(
1960
+ text=f"File {self._file_index + 1}/{self._file_count}"
1961
+ )
1962
+ state = "normal" if self._file_count > 1 else "disabled"
1963
+ self._prev_file_btn.configure(state=state)
1964
+ self._next_file_btn.configure(state=state)
1965
+
1966
+ def _on_focus_scenarios_event(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
1967
+ """Focus the scenarios area on 's' press (only in this window).
1968
+
1969
+ In comparison mode the scenario listbox is hidden, so focus the
1970
+ first checkbox in the comparison frame instead.
1971
+ """
1972
+ try:
1973
+ if event.widget.winfo_toplevel() is not self:
1974
+ return None
1975
+ if isinstance(event.widget, (tk.Entry, ttk.Entry, tk.Text)):
1976
+ return None
1977
+ except (tk.TclError, AttributeError):
1978
+ return None
1979
+ if self._mode.get() == "comparison":
1980
+ self._comp_tree.focus_set()
1981
+ children = self._comp_tree.get_children()
1982
+ if children and not self._comp_tree.selection():
1983
+ # Select first row for keyboard UX if nothing selected.
1984
+ self._comp_tree.selection_set(children[0])
1985
+ self._comp_tree.focus(children[0])
1986
+ else:
1987
+ self._scenario_listbox.focus_set()
1988
+ return "break"
1989
+
1990
+ def _on_focus_plots_event(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
1991
+ """Focus the plot tree on 'p' press (only in this window)."""
1992
+ try:
1993
+ if event.widget.winfo_toplevel() is not self:
1994
+ return None
1995
+ if isinstance(event.widget, (tk.Entry, ttk.Entry, tk.Text)):
1996
+ return None
1997
+ except (tk.TclError, AttributeError):
1998
+ return None
1999
+ self._plot_tree.focus_set()
2000
+ return "break"
2001
+
2002
+ def _on_prev_file_event(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
2003
+ """Handle prev file key event — only if from this window."""
2004
+ try:
2005
+ if event.widget.winfo_toplevel() is not self:
2006
+ return None
2007
+ except (tk.TclError, AttributeError):
2008
+ return None
2009
+ # For Left/Right arrows, let the plot tree and scenario listbox
2010
+ # handle them natively (expand/collapse, scrolling)
2011
+ keysym = getattr(event, "keysym", "")
2012
+ if keysym in ("Left", "Right"):
2013
+ w = event.widget
2014
+ if isinstance(w, (ttk.Treeview, tk.Listbox)):
2015
+ return None
2016
+ self._on_prev_file()
2017
+ return "break"
2018
+
2019
+ def _on_next_file_event(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
2020
+ """Handle next file key event — only if from this window."""
2021
+ try:
2022
+ if event.widget.winfo_toplevel() is not self:
2023
+ return None
2024
+ except (tk.TclError, AttributeError):
2025
+ return None
2026
+ # For Left/Right arrows, let the plot tree and scenario listbox
2027
+ # handle them natively (expand/collapse, scrolling)
2028
+ keysym = getattr(event, "keysym", "")
2029
+ if keysym in ("Left", "Right"):
2030
+ w = event.widget
2031
+ if isinstance(w, (ttk.Treeview, tk.Listbox)):
2032
+ return None
2033
+ self._on_next_file()
2034
+ return "break"
2035
+
2036
+ def _on_prev_file(self) -> None:
2037
+ """Navigate to previous file."""
2038
+ if self._file_index > 0:
2039
+ self._file_index -= 1
2040
+ self._update_file_nav()
2041
+ self._trigger_replot()
2042
+
2043
+ def _on_next_file(self) -> None:
2044
+ """Navigate to next file."""
2045
+ if self._file_index < self._file_count - 1:
2046
+ self._file_index += 1
2047
+ self._update_file_nav()
2048
+ self._trigger_replot()
2049
+
2050
+ # ------------------------------------------------------------------
2051
+ # Time range controls
2052
+ # ------------------------------------------------------------------
2053
+
2054
+ def _active_plot_settings(self) -> 'PlotSettings': # type: ignore[name-defined]
2055
+ """Return the PlotSettings for the active mode (single vs comparison).
2056
+
2057
+ ``dispatch`` and ``network`` modes also use the single settings as
2058
+ a sane fallback — they do not surface their own duration controls.
2059
+ """
2060
+ if self._mode.get() == "comparison":
2061
+ return self._settings.comparison_plot_settings
2062
+ return self._settings.single_plot_settings
2063
+
2064
+ def _resolve_initial_duration(self, letter: str, data_length: int) -> int:
2065
+ """Compute the duration to show the FIRST time *letter* is viewed.
2066
+
2067
+ Resolution order (short-circuits on the first match):
2068
+
2069
+ 1. If ``letter`` is already in ``settings.variant_durations`` →
2070
+ saved user intent wins, even on a fresh GUI session.
2071
+ 2. Else if ``letter`` is in ``self._template_default_durations``:
2072
+ - value ``"all"`` → resolve to ``data_length`` (or 168 if
2073
+ ``data_length <= 0``);
2074
+ - integer value → use that integer.
2075
+ The resolved int is then persisted into
2076
+ ``settings.variant_durations[letter]`` and a settings save is
2077
+ triggered so the user's first sighting becomes the new baseline.
2078
+ 3. Else fall back to 168, except for ``'w'`` which preserves the
2079
+ legacy "full timeline" behaviour when no template default exists.
2080
+ """
2081
+ plot_settings = self._active_plot_settings()
2082
+ saved = plot_settings.variant_durations
2083
+ if letter in saved:
2084
+ try:
2085
+ return int(saved[letter])
2086
+ except (TypeError, ValueError):
2087
+ pass
2088
+
2089
+ template = self._template_default_durations
2090
+ if letter in template:
2091
+ tmpl_val = template[letter]
2092
+ if isinstance(tmpl_val, str) and tmpl_val.strip().lower() == "all":
2093
+ resolved = data_length if data_length > 0 else 168
2094
+ else:
2095
+ try:
2096
+ resolved = int(tmpl_val)
2097
+ except (TypeError, ValueError):
2098
+ resolved = 168
2099
+ saved[letter] = int(resolved)
2100
+ self._schedule_settings_save()
2101
+ return int(resolved)
2102
+
2103
+ if letter == 'w':
2104
+ return data_length if data_length > 0 else 168
2105
+ return 168
2106
+
2107
+ def _update_time_range(self, data_length: int) -> None:
2108
+ """Update the Start slider and Duration spinbox for *data_length* rows.
2109
+
2110
+ Saves the current start under the previous variant letter and
2111
+ restores the values for the current variant letter. Durations are
2112
+ resolved against persisted settings (and template defaults on the
2113
+ first sighting); see :meth:`_resolve_initial_duration`.
2114
+
2115
+ The spinbox value MAY be clamped down to ``data_length`` for
2116
+ display, but the persisted user intent in
2117
+ ``settings.variant_durations[letter]`` is *not* overwritten by
2118
+ that clamp — the ``_suppress_duration_save`` guard ensures the
2119
+ trace callback ignores the programmatic write.
2120
+ """
2121
+ self._updating_time_range = True
2122
+ plot_settings = self._active_plot_settings()
2123
+ try:
2124
+ # ── Save outgoing variant's start state ──
2125
+ prev = getattr(self, '_last_slider_variant', None)
2126
+ current = self._active_variant or 'h'
2127
+ if prev is not None and prev != current:
2128
+ self._variant_start_state[prev] = self._start_var.get()
2129
+ # Outgoing duration is already in plot_settings — last
2130
+ # user-driven write came through the trace handler and
2131
+ # was already persisted (or scheduled to be).
2132
+
2133
+ # ── Restore or initialise incoming variant's slider state ──
2134
+ if current != prev:
2135
+ # Determine duration: settings → template → fallback.
2136
+ if current in plot_settings.variant_durations:
2137
+ saved_dur = int(plot_settings.variant_durations[current])
2138
+ else:
2139
+ saved_dur = self._resolve_initial_duration(current, data_length)
2140
+ # Determine start: session-local dict, default 0.
2141
+ saved_start = self._variant_start_state.get(current, 0)
2142
+
2143
+ self._suppress_duration_save = True
2144
+ try:
2145
+ self._duration_var.set(int(saved_dur))
2146
+ finally:
2147
+ self._suppress_duration_save = False
2148
+ self._start_var.set(saved_start)
2149
+ self._last_slider_variant = current
2150
+
2151
+ # The user's chosen duration is preserved as-is regardless
2152
+ # of how short the current scenario's data is — the renderer
2153
+ # simply leaves empty space on the right when data ends
2154
+ # before the chosen duration. This keeps the time axis
2155
+ # stable when the user toggles between scenarios with
2156
+ # different model horizons.
2157
+ self._duration_var.get()
2158
+
2159
+ # Spinbox values include the standard steps (always; we no
2160
+ # longer trim to <= data_length so the user can dial up to
2161
+ # whatever they want).
2162
+ self._duration_spin.configure(
2163
+ values=tuple(self._duration_steps), state="normal",
2164
+ )
2165
+
2166
+ # Start slider ranges over the full data_length so the user
2167
+ # can scroll through the actual data window.
2168
+ max_start = max(0, data_length - 1)
2169
+ self._start_scale.configure(to=max(max_start, 1))
2170
+ if max_start <= 0:
2171
+ self._start_var.set(0)
2172
+ self._start_scale.configure(state="disabled")
2173
+ else:
2174
+ self._start_scale.configure(state="normal")
2175
+ if self._start_var.get() > max_start:
2176
+ self._start_var.set(max_start)
2177
+ finally:
2178
+ self._updating_time_range = False
2179
+
2180
+ def _on_change_colors(self) -> None:
2181
+ """Open the non-modal colors/order picker; preview on each Apply.
2182
+
2183
+ Always edits the PROJECT's ``plot_settings.yaml`` (seeding it from
2184
+ the bundled default if absent), never the bundled package file.
2185
+ The picker is NON-MODAL (usable alongside the viewer), so the
2186
+ recolor must run on each of its Apply / Save / Cancel actions
2187
+ rather than once after a modal close — it is wired in as the
2188
+ picker's ``on_apply`` callback (:meth:`_apply_color_settings`).
2189
+ """
2190
+ from flextool.gui.dialogs.plot_settings_picker import PlotSettingsPicker
2191
+ from flextool.gui.project_utils import seed_plot_settings
2192
+
2193
+ # Edit the project copy; seed from the bundled default if missing.
2194
+ project_file = seed_plot_settings(self._project_path)
2195
+
2196
+ PlotSettingsPicker(
2197
+ self, project_file, on_apply=self._apply_color_settings,
2198
+ )
2199
+
2200
+ def _reapply_template_to_plan(
2201
+ self, plan, df, config, plot_name, break_times,
2202
+ ):
2203
+ """Re-apply the current ``plot_settings.yaml`` to a loaded disk plan.
2204
+
2205
+ Disk plot-plans are baked at solve time, so their colors/order
2206
+ reflect the template AS IT WAS THEN. Without this, a color/order
2207
+ edit saved while viewing one scenario would not appear when the user
2208
+ navigates to another scenario (or back to the edited one), because
2209
+ each scenario loads its own stale disk plan.
2210
+
2211
+ When the plan carries color hints, rebuild its color map + order in
2212
+ place (cheap — no parquet reload). Legacy hintless plans whose
2213
+ config nonetheless asks for template coloring are recomputed from
2214
+ scratch so the edit is honored. Plans that don't colour by template
2215
+ are returned untouched.
2216
+ """
2217
+ from flextool.plot_outputs.plan import (
2218
+ compute_live_plan,
2219
+ rebuild_plan_color_map,
2220
+ )
2221
+ color_path = resolve_plot_settings_path(self._project_path)
2222
+ if plan.shared_color_map and (
2223
+ plan.color_category is not None
2224
+ or plan.color_entity_class is not None
2225
+ ):
2226
+ try:
2227
+ rebuild_plan_color_map(plan, color_path=color_path)
2228
+ return plan
2229
+ except Exception: # noqa: BLE001
2230
+ logger.warning(
2231
+ "In-place recolor of disk plan failed; recomputing",
2232
+ exc_info=True,
2233
+ )
2234
+ if config.color_category is not None or config.color_entity_class is not None:
2235
+ try:
2236
+ return compute_live_plan(
2237
+ df, config, plot_name, break_times, color_path=color_path,
2238
+ )
2239
+ except Exception: # noqa: BLE001
2240
+ logger.warning(
2241
+ "Recompute of disk plan failed; using disk plan as-is",
2242
+ exc_info=True,
2243
+ )
2244
+ return plan
2245
+
2246
+ def _apply_color_settings(self) -> None:
2247
+ """Re-render the displayed plot from the edited ``plot_settings.yaml``.
2248
+
2249
+ The already-loaded parquet/dataframe is reused (no reload): the
2250
+ color-template cache is cleared so the edited file is re-read, then
2251
+ the live plan's color map is rebuilt in place when it carries color
2252
+ hints, else the plan is recomputed from scratch. Invoked on every
2253
+ picker Apply / Save / Cancel.
2254
+ """
2255
+ # Colors changed → the cached template and any colored figures are
2256
+ # stale. Clear the template cache so the edited file is re-read.
2257
+ from flextool.plot_outputs.color_template import (
2258
+ _clear_cache,
2259
+ resolve_plot_settings_path,
2260
+ )
2261
+ from flextool.plot_outputs.plan import rebuild_plan_color_map
2262
+ _clear_cache()
2263
+
2264
+ # Dispatch plots don't use ``_live_plan``: they freeze their column
2265
+ # (stacking) order in a per-nodeGroup cache that reindexes every
2266
+ # render (``_display_*_dispatch``), so a freshly derived template
2267
+ # order would be overridden by the stale cached order — colors would
2268
+ # update on Apply but the ORDER would not, until the viewer is
2269
+ # reopened. Drop the order/ylim caches (kept in lockstep — both are
2270
+ # keyed by nodeGroup and set together) so the next dispatch render
2271
+ # re-derives its order from the edited ``plot_settings.yaml``.
2272
+ self._dispatch_ylims.clear()
2273
+ self._dispatch_columns.clear()
2274
+
2275
+ # True in-place recolor/reorder: when a plan is already cached AND
2276
+ # it carries color hints (``color_category`` / ``color_entity_class``),
2277
+ # rebuild ONLY its shared_color_map (new colors + new file order)
2278
+ # from the edited template via those hints — no parquet reload, no
2279
+ # dimension-rule / layout / processed_df recompute. Prefetched
2280
+ # figures hold the OLD colors so they must be dropped (but the live
2281
+ # plan is preserved).
2282
+ #
2283
+ # A plan WITHOUT color hints cannot be recolored in place: that path
2284
+ # is hit by a legacy DISK plan written before plans carried the hints
2285
+ # (they deserialize as ``None``), whose baked color map AND an
2286
+ # in-place rebuild both silently ignore the edited ``entities`` /
2287
+ # ``categories`` template and revert to palette colors. For those —
2288
+ # and when nothing is rendered yet — force a from-scratch recompute
2289
+ # against the freshly read template (bypassing the stale disk plan).
2290
+ plan = self._live_plan
2291
+ can_rebuild_in_place = (
2292
+ plan is not None
2293
+ and plan.shared_color_map
2294
+ and (
2295
+ plan.color_category is not None
2296
+ or plan.color_entity_class is not None
2297
+ )
2298
+ )
2299
+ if can_rebuild_in_place:
2300
+ try:
2301
+ rebuild_plan_color_map(
2302
+ plan,
2303
+ color_path=resolve_plot_settings_path(self._project_path),
2304
+ )
2305
+ except Exception: # noqa: BLE001
2306
+ logger.warning(
2307
+ "In-place color rebuild failed; recomputing plan",
2308
+ exc_info=True,
2309
+ )
2310
+ self._force_plan_recompute = True
2311
+ self._clear_figure_cache()
2312
+ self._trigger_replot()
2313
+ return
2314
+ self._clear_prefetched_figures()
2315
+ self._trigger_replot()
2316
+ else:
2317
+ # No usable hints (legacy disk plan / no live plan) → recompute
2318
+ # from scratch so the edited template is actually honored.
2319
+ self._force_plan_recompute = True
2320
+ self._clear_figure_cache()
2321
+ self._trigger_replot()
2322
+
2323
+ def _on_time_range_changed(self, *_args) -> None:
2324
+ """Handle Start or Duration change.
2325
+
2326
+ Persists the *unclamped* user intent into
2327
+ ``settings.variant_durations`` and triggers a debounced settings
2328
+ save (500 ms). Programmatic clamps inside ``_update_time_range``
2329
+ bypass this path via ``_suppress_duration_save``.
2330
+ """
2331
+ if self._updating_time_range:
2332
+ return
2333
+ if not self._suppress_duration_save:
2334
+ letter = self._active_variant or self._last_slider_variant
2335
+ if letter:
2336
+ try:
2337
+ intent = int(self._duration_var.get())
2338
+ except tk.TclError:
2339
+ intent = None
2340
+ if intent is not None and intent > 0:
2341
+ plot_settings = self._active_plot_settings()
2342
+ plot_settings.variant_durations[letter] = intent
2343
+ self._schedule_settings_save()
2344
+ self._file_index = 0
2345
+ self._clear_prefetched_figures()
2346
+ self._trigger_replot()
2347
+
2348
+ def _schedule_settings_save(self, delay_ms: int = 500) -> None:
2349
+ """Debounced ``save_project_settings`` — coalesce keystroke bursts.
2350
+
2351
+ Cancels any previously scheduled save and schedules a new one
2352
+ ``delay_ms`` from now. Saves run on the Tk main thread.
2353
+ """
2354
+ if self._duration_save_after_id is not None:
2355
+ try:
2356
+ self.after_cancel(self._duration_save_after_id)
2357
+ except (tk.TclError, ValueError):
2358
+ pass
2359
+ self._duration_save_after_id = None
2360
+ try:
2361
+ self._duration_save_after_id = self.after(
2362
+ delay_ms, self._flush_settings_save,
2363
+ )
2364
+ except tk.TclError:
2365
+ # Window may already be destroyed — fall back to immediate save.
2366
+ self._flush_settings_save()
2367
+
2368
+ def _flush_settings_save(self) -> None:
2369
+ """Persist project settings now and clear the pending after() id."""
2370
+ self._duration_save_after_id = None
2371
+ try:
2372
+ save_project_settings(self._project_path, self._settings)
2373
+ except Exception: # noqa: BLE001
2374
+ logger.warning("Failed to persist variant durations", exc_info=True)
2375
+
2376
+ # ------------------------------------------------------------------
2377
+ # Figure cache management
2378
+ # ------------------------------------------------------------------
2379
+
2380
+ def _clear_figure_cache(self) -> None:
2381
+ """Discard all prefetched figures AND the live plan.
2382
+
2383
+ Called on structural changes (entry/variant/scenario). For slider
2384
+ changes use :meth:`_clear_prefetched_figures` instead so the live
2385
+ plan (which is slider-independent) is preserved.
2386
+ """
2387
+ with self._figure_cache_lock:
2388
+ self._figure_cache.clear()
2389
+ self._live_plan = None
2390
+ self._live_plan_key = ("", "", "")
2391
+
2392
+ def _clear_prefetched_figures(self) -> None:
2393
+ """Discard prefetched figures only, keep the live plan."""
2394
+ with self._figure_cache_lock:
2395
+ self._figure_cache.clear()
2396
+
2397
+ # ------------------------------------------------------------------
2398
+ # Update
2399
+ # ------------------------------------------------------------------
2400
+
2401
+ def _on_update(self) -> None:
2402
+ """Backward-compatible alias for :meth:`refresh_to_viewer_scenarios`.
2403
+
2404
+ The Phase B contract is that the main window's
2405
+ "Update view scenarios" button is the only path that rebuilds the
2406
+ combined comparison parquets, and the main window now calls
2407
+ :meth:`refresh_to_viewer_scenarios` directly with an explicit
2408
+ desired set. This shim remains so any older call sites or
2409
+ diagnostic tools that still reference ``_on_update`` keep working
2410
+ — it derives ``desired`` from the persisted main-window-checked
2411
+ list.
2412
+ """
2413
+ from flextool.gui.scenario_key import (
2414
+ parse_key, resolve_subdir_for_read,
2415
+ )
2416
+ bare_owners = self._settings.bare_output_owners
2417
+ desired: list[str] = []
2418
+ for entry in self._settings.checked_executed_scenarios:
2419
+ try:
2420
+ src_num, scen_name = parse_key(entry)
2421
+ except ValueError:
2422
+ continue
2423
+ desired.append(
2424
+ resolve_subdir_for_read(bare_owners, src_num, scen_name)
2425
+ )
2426
+ self.refresh_to_viewer_scenarios(desired)
2427
+
2428
+ def _read_metadata_scenarios(self) -> list[str]:
2429
+ """Return the scenario list recorded in ``_metadata.json``.
2430
+
2431
+ Empty list when the file is missing, unreadable, or doesn't
2432
+ contain a ``scenarios`` array. This is the canonical record of
2433
+ the *viewer scenarios* set (the scenarios materialised into the
2434
+ combined comparison parquets).
2435
+ """
2436
+ import json as _json
2437
+ meta_path = (
2438
+ self._project_path
2439
+ / "output_parquet_comparison"
2440
+ / "_metadata.json"
2441
+ )
2442
+ if not meta_path.is_file():
2443
+ return []
2444
+ try:
2445
+ with open(meta_path) as f:
2446
+ payload = _json.load(f)
2447
+ except (OSError, _json.JSONDecodeError):
2448
+ return []
2449
+ scenarios = payload.get("scenarios")
2450
+ if not isinstance(scenarios, list):
2451
+ return []
2452
+ return [str(s) for s in scenarios if isinstance(s, str)]
2453
+
2454
+ def refresh_to_viewer_scenarios(self, desired: list[str]) -> None:
2455
+ """Reconcile the on-disk viewer-scenarios set with *desired*.
2456
+
2457
+ Phase B entry point invoked by the main window's
2458
+ "Update view scenarios" button (and the cold-open path inside
2459
+ :meth:`__init__`). Compares *desired* against the scenarios
2460
+ recorded in ``output_parquet_comparison/_metadata.json``:
2461
+
2462
+ * **Differs:** schedule *desired* as the next combine target and
2463
+ kick off :meth:`_regenerate_comparison`. When that finishes,
2464
+ ``_metadata.json`` is rewritten and the comparison tree
2465
+ repopulates so the viewer reflects the new viewer scenarios.
2466
+ * **Matches:** no rebuild needed; just refresh the plot
2467
+ tree + figure so any stale availability is recomputed.
2468
+ """
2469
+ self.config(cursor="watch")
2470
+ try:
2471
+ self.update_idletasks()
2472
+ except tk.TclError:
2473
+ pass
2474
+
2475
+ # Clear caches that depend on scenario contents — both the
2476
+ # rebuild-and-no-rebuild paths benefit from a fresh slate.
2477
+ self._yaml_cache.clear()
2478
+ self._break_times_cache.clear()
2479
+ self._current_availability = set()
2480
+ self._clear_figure_cache()
2481
+ self._dispatch_scenario = ""
2482
+ self._dispatch_mappings = None
2483
+ self._dispatch_results = None
2484
+ self._dispatch_ylims.clear()
2485
+ self._dispatch_columns.clear()
2486
+ self._dispatch_node_iids.clear()
2487
+ if hasattr(self, '_dispatch_metadata_cache'):
2488
+ del self._dispatch_metadata_cache
2489
+ self._plot_canvas._cache.clear()
2490
+ self._parquet_cache_key = ("", "")
2491
+ self._parquet_cache_df = None
2492
+ # Force the shared axis-bounds manifest to reload on the next
2493
+ # render — a batch run may have just rewritten it.
2494
+ self._axis_manifest = None
2495
+ self._axis_manifest_mtime = 0.0
2496
+
2497
+ self._populate_scenarios()
2498
+ if self._mode.get() == "comparison":
2499
+ self._populate_comparison_checkboxes()
2500
+
2501
+ current_meta = set(self._read_metadata_scenarios())
2502
+ desired_set = set(desired)
2503
+ if current_meta != desired_set:
2504
+ # Schedule the rebuild for *exactly* the desired set, then
2505
+ # kick off the combine. ``_on_comparison_ready`` will
2506
+ # repopulate the plot tree once the parquets land.
2507
+ self._scheduled_viewer_scenarios = list(desired)
2508
+ self._settings.scenarios_changed = False
2509
+ self._comp_needs_regen = True
2510
+ self._regenerate_comparison(list(desired))
2511
+ else:
2512
+ # Sets match — no rebuild, just refresh availability + plot.
2513
+ self._comp_needs_regen = False
2514
+ self._on_mode_changed()
2515
+
2516
+ try:
2517
+ self.config(cursor="")
2518
+ except tk.TclError:
2519
+ pass
2520
+
2521
+ # ------------------------------------------------------------------
2522
+ # Tab focus cycling
2523
+ # ------------------------------------------------------------------
2524
+
2525
+ def _focus_plot_tree(self, _event: tk.Event | None = None) -> str:
2526
+ """Move focus to the plot tree."""
2527
+ self._plot_tree.focus_set()
2528
+ # Ensure something is selected
2529
+ if not self._plot_tree.selection():
2530
+ self._restore_or_select_first_entry()
2531
+ return "break"
2532
+
2533
+ def _focus_variant_canvas(self, _event: tk.Event | None = None) -> str:
2534
+ """Move focus to the variant canvas."""
2535
+ if self._all_variant_letters:
2536
+ self._variant_canvas.focus_set()
2537
+ return "break"
2538
+
2539
+ def _focus_scenario_listbox(self, _event: tk.Event | None = None) -> str:
2540
+ """Move focus back to the scenario listbox (or comparison tree in comparison mode)."""
2541
+ if self._mode.get() == "comparison":
2542
+ self._comp_tree.focus_set()
2543
+ children = self._comp_tree.get_children()
2544
+ if children and not self._comp_tree.selection():
2545
+ self._comp_tree.selection_set(children[0])
2546
+ self._comp_tree.focus(children[0])
2547
+ else:
2548
+ self._scenario_listbox.focus_set()
2549
+ return "break"
2550
+
2551
+ # ------------------------------------------------------------------
2552
+ # Plot display
2553
+ # ------------------------------------------------------------------
2554
+
2555
+ def _get_active_variant(self, entry: PlotEntry) -> PlotVariant | None:
2556
+ """Return the PlotVariant matching the active variant letter."""
2557
+ for v in entry.variants:
2558
+ if v.letter == self._active_variant:
2559
+ return v
2560
+ # Fall back to first variant
2561
+ return entry.variants[0] if entry.variants else None
2562
+
2563
+ def _load_single_plot_config(
2564
+ self, result_key: str, sub_config: str,
2565
+ ) -> PlotConfig | None:
2566
+ """Load PlotConfig from the **single-mode** YAML regardless of current mode.
2567
+
2568
+ Used by the merged-config comparison path: when a single-mode
2569
+ config carries ``scenario_rule``, comparison view derives the
2570
+ comparison config from it via
2571
+ :func:`flextool.scenario_comparison.plan_union.derive_comparison_config`.
2572
+ """
2573
+ return self._load_plot_config_from(
2574
+ self._single_config_path, result_key, sub_config,
2575
+ )
2576
+
2577
+ def _load_plot_config_from(
2578
+ self, config_path: Path, result_key: str, sub_config: str,
2579
+ ) -> PlotConfig | None:
2580
+ """Load a PlotConfig from a specific YAML file path."""
2581
+ if config_path not in self._yaml_cache:
2582
+ if not config_path.is_file():
2583
+ return None
2584
+ try:
2585
+ with open(config_path, "r", encoding="utf-8") as f:
2586
+ data = yaml.safe_load(f)
2587
+ except (yaml.YAMLError, OSError) as exc:
2588
+ logger.error("Failed to read plot config %s: %s", config_path, exc)
2589
+ return None
2590
+ if not isinstance(data, dict):
2591
+ return None
2592
+ self._yaml_cache[config_path] = data
2593
+ data = self._yaml_cache[config_path]
2594
+ plots = data.get("plots")
2595
+ if not isinstance(plots, dict):
2596
+ return None
2597
+ plots = flatten_new_format(plots)
2598
+ entry = plots.get(result_key)
2599
+ if not isinstance(entry, dict):
2600
+ return None
2601
+ if _is_single_config(entry):
2602
+ if sub_config != "default":
2603
+ return None
2604
+ raw = entry
2605
+ else:
2606
+ raw = entry.get(sub_config)
2607
+ if not isinstance(raw, dict):
2608
+ return None
2609
+ unknown_keys = [k for k in raw if k not in PLOT_FIELD_NAMES]
2610
+ if unknown_keys:
2611
+ logger.debug(
2612
+ "Plot config '%s': ignoring unknown setting(s): %s",
2613
+ result_key, ", ".join(repr(k) for k in unknown_keys),
2614
+ )
2615
+ filtered = {k: v for k, v in raw.items() if k in PLOT_FIELD_NAMES}
2616
+ if "axis_scale_min_max" in filtered and "axis_bounds" not in filtered:
2617
+ filtered["axis_bounds"] = filtered.pop("axis_scale_min_max")
2618
+ elif "axis_scale_min_max" in filtered:
2619
+ del filtered["axis_scale_min_max"]
2620
+ filtered.pop("variant", None)
2621
+ try:
2622
+ return PlotConfig(**filtered)
2623
+ except TypeError as exc:
2624
+ logger.error("Failed to create PlotConfig for '%s': %s", result_key, exc)
2625
+ return None
2626
+
2627
+ def _load_plot_config(self, result_key: str, sub_config: str) -> PlotConfig | None:
2628
+ """Load PlotConfig for a result_key from the active YAML config file."""
2629
+ config_path = self._get_config_path_for_mode()
2630
+
2631
+ # Use cached YAML if available
2632
+ if config_path not in self._yaml_cache:
2633
+ if not config_path.is_file():
2634
+ return None
2635
+ try:
2636
+ with open(config_path, "r", encoding="utf-8") as f:
2637
+ data = yaml.safe_load(f)
2638
+ except (yaml.YAMLError, OSError) as exc:
2639
+ logger.error("Failed to read plot config %s: %s", config_path, exc)
2640
+ return None
2641
+ if not isinstance(data, dict):
2642
+ return None
2643
+ self._yaml_cache[config_path] = data
2644
+
2645
+ data = self._yaml_cache[config_path]
2646
+ plots = data.get("plots")
2647
+ if not isinstance(plots, dict):
2648
+ return None
2649
+
2650
+ # Flatten new-format entries (entry-name grouping) to flat result_key mapping
2651
+ plots = flatten_new_format(plots)
2652
+
2653
+ entry = plots.get(result_key)
2654
+ if not isinstance(entry, dict):
2655
+ return None
2656
+
2657
+ # Determine which raw config dict to use
2658
+ if _is_single_config(entry):
2659
+ # Direct config: the entry dict IS the plot settings
2660
+ if sub_config != "default":
2661
+ return None
2662
+ raw = entry
2663
+ else:
2664
+ # Named-config dict: look up the sub_config key
2665
+ raw = entry.get(sub_config)
2666
+ if not isinstance(raw, dict):
2667
+ return None
2668
+
2669
+ # Filter unknown keys and handle backward-compat alias (same as orchestrator)
2670
+ unknown_keys = [k for k in raw if k not in PLOT_FIELD_NAMES]
2671
+ if unknown_keys:
2672
+ logger.debug(
2673
+ "Plot config '%s': ignoring unknown setting(s): %s",
2674
+ result_key, ", ".join(repr(k) for k in unknown_keys),
2675
+ )
2676
+ filtered = {k: v for k, v in raw.items() if k in PLOT_FIELD_NAMES}
2677
+ if "axis_scale_min_max" in filtered and "axis_bounds" not in filtered:
2678
+ filtered["axis_bounds"] = filtered.pop("axis_scale_min_max")
2679
+ elif "axis_scale_min_max" in filtered:
2680
+ del filtered["axis_scale_min_max"]
2681
+ filtered.pop("variant", None)
2682
+
2683
+ try:
2684
+ return PlotConfig(**filtered)
2685
+ except TypeError as exc:
2686
+ logger.error("Failed to create PlotConfig for '%s': %s", result_key, exc)
2687
+ return None
2688
+
2689
+ def _get_axis_manifest(self) -> dict | None:
2690
+ """Return the cross-scenario axis-bounds manifest (mtime-cached).
2691
+
2692
+ Returns ``None`` when no manifest is available; never raises.
2693
+ The on-disk manifest is ``stat``ed on every call: if its mtime is
2694
+ newer than the cached copy, the file is re-read. This lets the
2695
+ viewer pick up manifest rewrites from post-run hooks without any
2696
+ explicit invalidation.
2697
+ """
2698
+ manifest_path = (
2699
+ self._project_path
2700
+ / "output_parquet"
2701
+ / "_axis_bounds.json"
2702
+ )
2703
+ try:
2704
+ mtime = manifest_path.stat().st_mtime if manifest_path.is_file() else 0.0
2705
+ except OSError:
2706
+ mtime = 0.0
2707
+
2708
+ if mtime == 0.0:
2709
+ # File missing — drop any previous cached view.
2710
+ if self._axis_manifest is not None:
2711
+ self._axis_manifest = None
2712
+ self._axis_manifest_mtime = 0.0
2713
+ return None
2714
+
2715
+ if mtime <= self._axis_manifest_mtime and self._axis_manifest is not None:
2716
+ return self._axis_manifest
2717
+
2718
+ try:
2719
+ from flextool.plot_outputs.shared_manifest import (
2720
+ load_axis_bounds_manifest,
2721
+ )
2722
+ self._axis_manifest = load_axis_bounds_manifest(
2723
+ self._project_path
2724
+ )
2725
+ except Exception: # noqa: BLE001
2726
+ logger.warning(
2727
+ "Failed to load shared axis manifest", exc_info=True,
2728
+ )
2729
+ self._axis_manifest = None
2730
+ self._axis_manifest_mtime = mtime
2731
+ return self._axis_manifest
2732
+
2733
+ def _get_axis_active_scenarios(self) -> set[str] | None:
2734
+ """Return the scenarios that should contribute to the shared y-axis.
2735
+
2736
+ - In *single* mode the active set is the list of currently-checked
2737
+ executed scenarios (from :meth:`_scan_scenarios`). That list
2738
+ reflects the user's own statement of which scenarios are
2739
+ relevant for comparison, so it's the natural filter for the
2740
+ shared axis — and it updates the moment a checkbox is toggled,
2741
+ without needing any file rewrite.
2742
+ - In *comparison* mode we return the **viewer scenarios** set —
2743
+ the scenarios locked in at the last "Update view scenarios"
2744
+ press (i.e. the union currently materialised into the combined
2745
+ parquets). Using this set rather than the currently-ticked
2746
+ subset in ``_comp_tree`` is what *freezes* the y-axis as the
2747
+ user toggles individual scenarios on/off. See
2748
+ :meth:`_get_comparison_viewer_scenarios`.
2749
+ - In *network* / *dispatch* modes the override is not applicable;
2750
+ the caller skips it entirely so the return value is irrelevant.
2751
+
2752
+ Returns ``None`` in unsupported modes / on internal errors so the
2753
+ caller can early-out rather than forwarding ``None`` to
2754
+ :func:`apply_manifest_to_plan` (which would silently union over
2755
+ every scenario in the manifest).
2756
+ """
2757
+ mode = self._mode.get()
2758
+ if mode == "single":
2759
+ try:
2760
+ scenarios = self._scan_scenarios()
2761
+ except Exception: # noqa: BLE001
2762
+ logger.warning(
2763
+ "Failed to collect active scenarios for axis manifest",
2764
+ exc_info=True,
2765
+ )
2766
+ return None
2767
+ return set(scenarios)
2768
+ if mode == "comparison":
2769
+ try:
2770
+ return set(self._get_comparison_viewer_scenarios())
2771
+ except Exception: # noqa: BLE001
2772
+ logger.warning(
2773
+ "Failed to collect viewer scenarios for axis manifest",
2774
+ exc_info=True,
2775
+ )
2776
+ return None
2777
+ return None
2778
+
2779
+ def _get_comparison_viewer_scenarios(self) -> list[str]:
2780
+ """Return the *viewer scenarios* set for comparison mode.
2781
+
2782
+ These are the scenarios locked in at the last "Update view
2783
+ scenarios" press — i.e. the set whose results are currently
2784
+ materialised into ``output_parquet_comparison/*.parquet``. The
2785
+ canonical record is the ``scenarios`` list in
2786
+ ``output_parquet_comparison/_metadata.json``; we fall back to
2787
+ ``settings.comp_viewer_scenarios`` (which tracks the live tree
2788
+ state) only when the metadata is missing or unreadable, since
2789
+ that's the closest available approximation on a cold open.
2790
+
2791
+ This set is **not** the currently-ticked subset in ``_comp_tree``
2792
+ — that's the *current scenarios*, which the caller toggles freely
2793
+ without changing the axis scope.
2794
+ """
2795
+ import json as _json
2796
+
2797
+ meta_path = (
2798
+ self._project_path
2799
+ / "output_parquet_comparison"
2800
+ / "_metadata.json"
2801
+ )
2802
+ if meta_path.is_file():
2803
+ try:
2804
+ with open(meta_path) as f:
2805
+ payload = _json.load(f)
2806
+ scenarios = payload.get("scenarios")
2807
+ if isinstance(scenarios, list):
2808
+ return [str(s) for s in scenarios if isinstance(s, str)]
2809
+ except (OSError, _json.JSONDecodeError):
2810
+ logger.warning(
2811
+ "Could not read comparison metadata at %s",
2812
+ meta_path, exc_info=True,
2813
+ )
2814
+ # Fall back to the persisted viewer-tree state.
2815
+ return list(self._settings.comp_viewer_scenarios)
2816
+
2817
+ def _apply_axis_manifest(
2818
+ self, plan, result_key: str, sub_config: str,
2819
+ ) -> None:
2820
+ """Override a plan's subplot_y_ranges from the shared manifest.
2821
+
2822
+ No-op when the manifest is missing or has no matching entry.
2823
+ Safe to call on every render — the override is a cheap dict
2824
+ lookup. Mutates the plan in place before handing it to
2825
+ ``build_figure_from_plan`` so the call site doesn't need a new
2826
+ parameter.
2827
+
2828
+ In *single* mode the override is filtered to the set of currently
2829
+ checked executed scenarios: as the user toggles checkboxes the
2830
+ next render naturally picks up a new union.
2831
+
2832
+ In *comparison* mode the override is filtered to the **viewer
2833
+ scenarios** set (the scenarios locked in at the last "Update view
2834
+ scenarios" press — see :meth:`_get_comparison_viewer_scenarios`).
2835
+ That keeps the y-axis frozen while the user toggles individual
2836
+ scenarios on/off in the comparison tree: ticking changes
2837
+ visibility only, never axes.
2838
+
2839
+ If :meth:`_get_axis_active_scenarios` returns ``None`` (network /
2840
+ dispatch mode, or an exception inside the helper) we skip the
2841
+ override entirely rather than forwarding ``None`` to
2842
+ :func:`apply_manifest_to_plan` — the latter treats ``None`` as
2843
+ "union over every scenario in the manifest", which would silently
2844
+ defeat the scoped filter.
2845
+ """
2846
+ if plan is None:
2847
+ return
2848
+ manifest = self._get_axis_manifest()
2849
+ if manifest is None:
2850
+ return
2851
+ active = self._get_axis_active_scenarios()
2852
+ if active is None:
2853
+ # Non-supported mode (or active-set lookup failed): the
2854
+ # caller shouldn't be using the shared-manifest override at
2855
+ # all. See the docstring above for why we can't pass
2856
+ # ``None`` on through.
2857
+ return
2858
+ try:
2859
+ from flextool.plot_outputs.shared_manifest import (
2860
+ apply_manifest_to_plan,
2861
+ )
2862
+ apply_manifest_to_plan(
2863
+ plan, manifest, result_key, sub_config,
2864
+ scenarios=active,
2865
+ )
2866
+ except Exception: # noqa: BLE001
2867
+ # Never let manifest application break the viewer — the
2868
+ # fallback is the plan's own per-scenario y-ranges.
2869
+ logger.warning(
2870
+ "Failed to apply axis manifest for %s/%s",
2871
+ result_key, sub_config, exc_info=True,
2872
+ )
2873
+
2874
+ def _load_parquet(self, scenario: str, result_key: str) -> pd.DataFrame | None:
2875
+ """Load a parquet file for the given scenario and result_key.
2876
+
2877
+ Per-scenario parquets have 'scenario' as the top column MultiIndex
2878
+ level (added at write time). Strip it so the DataFrame matches the
2879
+ dimension rules in the plot config. Caches the last loaded DataFrame.
2880
+ """
2881
+ cache_key = (scenario, result_key)
2882
+ if cache_key == self._parquet_cache_key and self._parquet_cache_df is not None:
2883
+ return self._parquet_cache_df
2884
+ path = self._project_path / "output_parquet" / scenario / f"{result_key}.parquet"
2885
+ if not path.exists():
2886
+ return None
2887
+ df = read_lean_parquet(path)
2888
+ if isinstance(df.columns, pd.MultiIndex) and 'scenario' in df.columns.names:
2889
+ df = df.droplevel('scenario', axis=1)
2890
+ self._parquet_cache_key = cache_key
2891
+ self._parquet_cache_df = df
2892
+ return df
2893
+
2894
+ def _load_break_times(self, scenario: str) -> set[str] | None:
2895
+ """Load timeline break times from parquet, cached per scenario."""
2896
+ if scenario in self._break_times_cache:
2897
+ return self._break_times_cache[scenario]
2898
+
2899
+ path = self._project_path / "output_parquet" / scenario / "timeline_breaks.parquet"
2900
+ if not path.exists():
2901
+ self._break_times_cache[scenario] = None
2902
+ return None
2903
+
2904
+ try:
2905
+ df = read_lean_parquet(path)
2906
+ # Extract break time values as strings
2907
+ if df.empty:
2908
+ result: set[str] | None = None
2909
+ else:
2910
+ # The parquet has a column with break time values
2911
+ result = set(df.iloc[:, 0].astype(str))
2912
+ self._break_times_cache[scenario] = result
2913
+ return result
2914
+ except Exception: # noqa: BLE001
2915
+ logger.warning("Failed to read timeline breaks for %s", scenario, exc_info=True)
2916
+ self._break_times_cache[scenario] = None
2917
+ return None
2918
+
2919
+ def _make_figure_cache_key(
2920
+ self, scenario: str, result_key: str, sub_config: str,
2921
+ file_index: int, start: int, duration: int,
2922
+ ) -> tuple:
2923
+ return (scenario, result_key, sub_config, file_index, start, duration)
2924
+
2925
+ def _display_from_parquet(self, scenario: str, entry: PlotEntry, variant: PlotVariant) -> None:
2926
+ """Load parquet, build PlotConfig, render Figure, display it.
2927
+
2928
+ If a pre-computed PlotPlan exists, use it for instant rendering.
2929
+ If the figure is already cached (from prefetch), display instantly.
2930
+ Otherwise submit building to a background thread and display on
2931
+ completion.
2932
+ """
2933
+ # 1. Load parquet (cached) and config (cached) — synchronous, fast
2934
+ df = self._load_parquet(scenario, variant.result_key)
2935
+ if df is None:
2936
+ self._plot_canvas.show_message(f"No data: {variant.result_key}.parquet")
2937
+ return
2938
+
2939
+ config = self._load_plot_config(variant.result_key, variant.sub_config)
2940
+ if config is None:
2941
+ self._plot_canvas.show_message(f"No config for {variant.result_key}")
2942
+ return
2943
+
2944
+ break_times = self._load_break_times(scenario)
2945
+ plot_name = config.plot_name or variant.full_name
2946
+
2947
+ # 1b. Try live plan (cached in memory) or disk plan for instant rendering.
2948
+ # The plan caches dimension rules, layout, and colors; only the
2949
+ # time slice and matplotlib rendering run on each slider change.
2950
+ try:
2951
+ from flextool.plot_outputs.plan import (
2952
+ load_plot_plan, compute_live_plan,
2953
+ )
2954
+ plan_key = (scenario, variant.result_key, variant.sub_config)
2955
+ # A pending color edit may demand a from-scratch recompute that
2956
+ # bypasses any cached/disk plan (see _force_plan_recompute).
2957
+ force_recompute = self._force_plan_recompute
2958
+ self._force_plan_recompute = False
2959
+ if (
2960
+ not force_recompute
2961
+ and self._live_plan_key == plan_key
2962
+ and self._live_plan is not None
2963
+ ):
2964
+ plan = self._live_plan
2965
+ else:
2966
+ # Try the pre-computed DISK plan first (instant), then
2967
+ # compute on-the-fly. On a forced recompute we skip the
2968
+ # disk plan entirely: a legacy disk plan carries no color
2969
+ # hints, so its baked colors AND an in-place rebuild both
2970
+ # ignore the freshly edited template — only a recompute
2971
+ # against the new template honors the edit.
2972
+ plan = None
2973
+ if not force_recompute:
2974
+ plan_dir = (
2975
+ self._project_path / "output_parquet" / scenario
2976
+ / "plot_plans"
2977
+ )
2978
+ plan = load_plot_plan(
2979
+ plan_dir, variant.result_key, variant.sub_config,
2980
+ )
2981
+ if plan is not None:
2982
+ # Disk plans are baked at solve time, so they carry
2983
+ # whatever colors/order plot_settings.yaml had THEN.
2984
+ # Re-apply the CURRENT template so an edit saved while
2985
+ # viewing one scenario shows on every scenario (and
2986
+ # when navigating back), not just the edited one.
2987
+ plan = self._reapply_template_to_plan(
2988
+ plan, df, config, plot_name, break_times,
2989
+ )
2990
+ if plan is None:
2991
+ plan = compute_live_plan(
2992
+ df, config, plot_name, break_times,
2993
+ color_path=resolve_plot_settings_path(self._project_path),
2994
+ )
2995
+ self._live_plan = plan
2996
+ self._live_plan_key = plan_key
2997
+
2998
+ if plan is not None:
2999
+ # Apply cross-scenario axis override every render (cheap
3000
+ # dict lookup). This handles the case where the manifest
3001
+ # finished loading AFTER the plan was cached as well as
3002
+ # the normal freshly-loaded plan.
3003
+ self._apply_axis_manifest(
3004
+ plan, variant.result_key, variant.sub_config,
3005
+ )
3006
+ # Use the plan's processed_df length for the slider range
3007
+ # (aggregated/weekly plots have a shorter processed_df)
3008
+ self._update_time_range(len(plan.processed_df))
3009
+ start = self._start_var.get()
3010
+ duration = self._duration_var.get()
3011
+ plot_rows = (start, start + duration)
3012
+ self._file_count = plan.total_file_count
3013
+ self._file_index = min(self._file_index, max(0, self._file_count - 1))
3014
+ self._update_file_nav()
3015
+ # build_figure_from_plan can be slow for plots with many
3016
+ # bars / ticks (matplotlib does the heavy lifting). Run it
3017
+ # on the background executor and display when ready so the
3018
+ # UI thread stays responsive.
3019
+ self._render_gen += 1
3020
+ gen = self._render_gen
3021
+ self._schedule_placeholder(gen, f"Rendering {variant.full_name}…")
3022
+ self._executor.submit(
3023
+ self._build_figure_from_plan_async,
3024
+ gen, plan, self._file_index, plot_rows, variant,
3025
+ )
3026
+ return
3027
+ except Exception:
3028
+ logger.warning("Plan path failed for %s", variant.result_key, exc_info=True)
3029
+
3030
+ # Fallback: use raw data length for slider range
3031
+ self._update_time_range(len(df))
3032
+ start = self._start_var.get()
3033
+ duration = self._duration_var.get()
3034
+
3035
+ # 2. Check prefetch cache for instant display
3036
+ cache_key = self._make_figure_cache_key(
3037
+ scenario, variant.result_key, variant.sub_config,
3038
+ self._file_index, start, duration,
3039
+ )
3040
+ with self._figure_cache_lock:
3041
+ cached_fig = self._figure_cache.pop(cache_key, None)
3042
+
3043
+ if cached_fig is not None:
3044
+ self._cancel_placeholder()
3045
+ self._plot_canvas.display_figure(cached_fig)
3046
+ logger.info("Plot %s: CACHED [file %d]", variant.result_key, self._file_index)
3047
+ self._prefetch_adjacent(scenario, variant, df, config, plot_name, break_times, start, duration)
3048
+ return
3049
+
3050
+ # 3. Invalidate stale in-flight builds
3051
+ self._render_gen += 1
3052
+ gen = self._render_gen
3053
+ self._schedule_placeholder(gen, f"Rendering {variant.full_name}…")
3054
+
3055
+ # 4. Submit build to background thread
3056
+ self._executor.submit(
3057
+ self._build_figure_async, gen, df, config, plot_name,
3058
+ plot_rows, break_times, self._file_index,
3059
+ scenario, variant, start, duration,
3060
+ )
3061
+
3062
+ def _build_figure_from_plan_async(
3063
+ self, generation: int, plan, file_index, plot_rows, variant,
3064
+ ) -> None:
3065
+ """Run plan-based figure building on the background executor.
3066
+
3067
+ Mirrors :meth:`_build_figure_async` but for the precomputed-plan
3068
+ path. The plan-path is "instant" for most plots, but bar plots
3069
+ with hundreds of items per file (matplotlib's barh + per-tick
3070
+ formatter loop) can take tens of seconds — long enough for the
3071
+ UI to freeze if we run it on the Tk main thread.
3072
+ """
3073
+ from flextool.plot_outputs.plan import build_figure_from_plan
3074
+ t0 = time.perf_counter()
3075
+ try:
3076
+ fig = build_figure_from_plan(plan, file_index, plot_rows)
3077
+ except Exception as exc:
3078
+ self.after(0, self._on_figure_error, generation, str(exc), variant.result_key)
3079
+ return
3080
+ t1 = time.perf_counter()
3081
+ logger.info(
3082
+ "Plot %s: from plan build=%.0fms [file %d/%d, bg thread]",
3083
+ variant.result_key, (t1 - t0) * 1000,
3084
+ file_index, plan.total_file_count,
3085
+ )
3086
+ self.after(0, self._on_plan_figure_ready, generation, fig, variant)
3087
+
3088
+ def _on_plan_figure_ready(self, generation: int, fig, variant) -> None:
3089
+ """Main-thread display callback for a plan-path build."""
3090
+ if generation != self._render_gen:
3091
+ return # stale — user moved on
3092
+ self._cancel_placeholder()
3093
+ if fig is not None:
3094
+ self._plot_canvas.display_figure(fig)
3095
+ else:
3096
+ self._plot_canvas.show_message(f"No plottable data for {variant.full_name}")
3097
+
3098
+ def _build_figure_async(
3099
+ self, generation: int, df, config, plot_name, plot_rows,
3100
+ break_times, file_index, scenario, variant, start, duration,
3101
+ ) -> None:
3102
+ """Run in background thread: build figure, then schedule display on main thread."""
3103
+ t0 = time.perf_counter()
3104
+ try:
3105
+ figures, total_count = prepare_plot_data(
3106
+ df, config, plot_name, plot_rows, break_times,
3107
+ only_file_index=file_index,
3108
+ color_path=resolve_plot_settings_path(self._project_path),
3109
+ )
3110
+ except Exception as exc:
3111
+ self.after(0, self._on_figure_error, generation, str(exc), variant.result_key)
3112
+ return
3113
+ t1 = time.perf_counter()
3114
+
3115
+ fig = figures[0][1] if figures else None
3116
+ logger.info(
3117
+ "Plot %s: build=%.0fms [file %d/%d, bg thread]",
3118
+ variant.result_key, (t1 - t0) * 1000, file_index, total_count,
3119
+ )
3120
+
3121
+ self.after(0, self._on_figure_ready, generation, fig, total_count,
3122
+ scenario, variant, start, duration)
3123
+
3124
+ def _on_figure_ready(
3125
+ self, generation: int, fig, total_count: int,
3126
+ scenario, variant, start, duration,
3127
+ ) -> None:
3128
+ """Main-thread callback: display figure if still current."""
3129
+ if generation != self._render_gen:
3130
+ return # stale result — figure will be garbage-collected
3131
+
3132
+ self._cancel_placeholder()
3133
+ self._file_count = max(total_count, 1)
3134
+ self._file_index = min(self._file_index, max(0, self._file_count - 1))
3135
+ self._update_file_nav()
3136
+
3137
+ if fig is not None:
3138
+ t0 = time.perf_counter()
3139
+ self._plot_canvas.display_figure(fig)
3140
+ logger.info("Plot %s: display=%.0fms", variant.result_key, (time.perf_counter() - t0) * 1000)
3141
+ # Prefetch adjacent pages
3142
+ df = self._load_parquet(scenario, variant.result_key)
3143
+ config = self._load_plot_config(variant.result_key, variant.sub_config)
3144
+ if df is not None and config is not None:
3145
+ break_times = self._load_break_times(scenario)
3146
+ plot_name = config.plot_name or variant.full_name
3147
+ self._prefetch_adjacent(scenario, variant, df, config, plot_name, break_times, start, duration)
3148
+ else:
3149
+ self._plot_canvas.show_message(f"No plottable data for {variant.full_name}")
3150
+
3151
+ def _on_figure_error(self, generation: int, error_msg: str, result_key: str) -> None:
3152
+ """Main-thread callback: show error if still current."""
3153
+ if generation != self._render_gen:
3154
+ return
3155
+ self._cancel_placeholder()
3156
+ logger.error("prepare_plot_data failed for '%s': %s", result_key, error_msg)
3157
+ self._plot_canvas.show_message(f"Plot error: {error_msg}")
3158
+
3159
+ # ── Deferred "Rendering…" placeholder ────────────────────────────────
3160
+ # Showing the placeholder text immediately on every plot switch wipes the
3161
+ # current figure, which makes flipping back and forth to spot differences
3162
+ # jarring. Instead we keep the previous figure up and only fall back to
3163
+ # text if the new render hasn't arrived within this delay.
3164
+ _PLACEHOLDER_DELAY_MS = 300
3165
+
3166
+ def _schedule_placeholder(self, generation: int, text: str) -> None:
3167
+ """Show *text* on the canvas, but only after _PLACEHOLDER_DELAY_MS.
3168
+
3169
+ Cancels any previously scheduled placeholder first. The fire is gated
3170
+ on *generation* so a stale timer never overwrites a newer render.
3171
+ """
3172
+ self._cancel_placeholder()
3173
+
3174
+ def _fire() -> None:
3175
+ self._placeholder_after_id = None
3176
+ if generation != self._render_gen:
3177
+ return
3178
+ try:
3179
+ self._plot_canvas.show_message(text)
3180
+ except tk.TclError:
3181
+ pass # viewer closed mid-flight
3182
+
3183
+ self._placeholder_after_id = self.after(self._PLACEHOLDER_DELAY_MS, _fire)
3184
+
3185
+ def _cancel_placeholder(self) -> None:
3186
+ """Cancel a pending deferred placeholder, if any."""
3187
+ if self._placeholder_after_id is not None:
3188
+ try:
3189
+ self.after_cancel(self._placeholder_after_id)
3190
+ except (tk.TclError, ValueError):
3191
+ pass
3192
+ self._placeholder_after_id = None
3193
+
3194
+ def _prefetch_adjacent(
3195
+ self, scenario, variant, df, config, plot_name, break_times, start, duration,
3196
+ ) -> None:
3197
+ """Submit background builds for adjacent file pages."""
3198
+ plot_rows = (start, start + duration)
3199
+ for adj_offset in (1, -1):
3200
+ adj_index = self._file_index + adj_offset
3201
+ if adj_index < 0 or adj_index >= self._file_count:
3202
+ continue
3203
+ cache_key = self._make_figure_cache_key(
3204
+ scenario, variant.result_key, variant.sub_config,
3205
+ adj_index, start, duration,
3206
+ )
3207
+ with self._figure_cache_lock:
3208
+ if cache_key in self._figure_cache:
3209
+ continue
3210
+ self._executor.submit(
3211
+ self._prefetch_build, cache_key, df, config, plot_name,
3212
+ plot_rows, break_times, adj_index, variant.result_key,
3213
+ )
3214
+
3215
+ def _prefetch_build(self, cache_key, df, config, plot_name, plot_rows, break_times, file_index, result_key) -> None:
3216
+ """Background thread: build and cache a figure for prefetch."""
3217
+ try:
3218
+ figures, _ = prepare_plot_data(
3219
+ df, config, plot_name, plot_rows, break_times,
3220
+ only_file_index=file_index,
3221
+ )
3222
+ if figures:
3223
+ with self._figure_cache_lock:
3224
+ self._figure_cache[cache_key] = figures[0][1]
3225
+ logger.info("Prefetch %s: file %d ready", result_key, file_index)
3226
+ except Exception:
3227
+ pass # prefetch failure is not critical
3228
+
3229
+ def _trigger_replot(self) -> None:
3230
+ """Called when scenario, entry, or variant changes."""
3231
+ scenarios = self._get_selected_scenarios()
3232
+ if not scenarios:
3233
+ self._plot_canvas.show_message("No scenario selected")
3234
+ return
3235
+
3236
+ mode = self._mode.get()
3237
+
3238
+ # Dispatch mode is handled directly by _on_tree_selected
3239
+ if mode == "dispatch":
3240
+ selection = self._plot_tree.selection()
3241
+ # Prefer the live tree selection, but fall back to the
3242
+ # last-rendered dispatch iid when the tree has no (plotting)
3243
+ # selection — otherwise a live colors/order edit (whose refresh
3244
+ # routes through here) would silently no-op and the dispatch plot
3245
+ # would not update. Guard that the iid still exists in the tree.
3246
+ if selection and _is_dispatch_iid(selection[0]):
3247
+ iid = selection[0]
3248
+ else:
3249
+ iid = self._last_dispatch_iid
3250
+ if iid and self._plot_tree.exists(iid) and _is_dispatch_iid(iid):
3251
+ self._display_dispatch_iid(scenarios[0], iid)
3252
+ return
3253
+
3254
+ selection = self._plot_tree.selection()
3255
+ if not selection or not selection[0].startswith("entry_"):
3256
+ self._plot_canvas.show_message("No plot selected")
3257
+ return
3258
+
3259
+ entry = self._tree_entry_map.get(selection[0])
3260
+ if not entry:
3261
+ return
3262
+
3263
+ variant = self._get_active_variant(entry)
3264
+ if not variant:
3265
+ self._plot_canvas.show_message("No variant selected")
3266
+ return
3267
+
3268
+ if mode == "single":
3269
+ self._display_from_parquet(scenarios[0], entry, variant)
3270
+ elif mode == "comparison":
3271
+ self._display_comparison(entry, variant)
3272
+
3273
+ # ------------------------------------------------------------------
3274
+ # Comparison mode
3275
+ # ------------------------------------------------------------------
3276
+
3277
+ def _display_comparison_from_single(
3278
+ self,
3279
+ entry: PlotEntry,
3280
+ variant: PlotVariant,
3281
+ single_cfg: PlotConfig,
3282
+ ) -> None:
3283
+ """Render comparison via merged-config (prototype) path.
3284
+
3285
+ The single-mode config carries ``scenario_rule`` (and optionally
3286
+ ``comparison_overrides``). We:
3287
+
3288
+ 1. Read each viewer scenario's **raw** result parquet and union
3289
+ them with ``scenario`` as the outermost col-MultiIndex level.
3290
+ 2. Derive a comparison-mode PlotConfig by prepending ``s`` to the
3291
+ column part of ``index_types`` and ``scenario_rule`` to the
3292
+ column part of ``rules``, then merging in
3293
+ ``comparison_overrides``.
3294
+ 3. Run ``compute_live_plan`` against the unioned raw frame with
3295
+ the derived config and render.
3296
+
3297
+ No per-scenario plan parquet is read on this path — the dim-rule
3298
+ pivot logic is applied directly to the raw union, which is the
3299
+ only shape the augmented config knows how to interpret.
3300
+ """
3301
+ from flextool.scenario_comparison.plan_union import (
3302
+ derive_comparison_config, union_raw_data,
3303
+ )
3304
+ from flextool.plot_outputs.plan import (
3305
+ build_figure_from_plan, compute_live_plan,
3306
+ )
3307
+
3308
+ viewer_scenarios = self._get_comparison_viewer_scenarios()
3309
+ checked = set(self._get_comparison_scenarios())
3310
+
3311
+ df = union_raw_data(
3312
+ self._project_path, list(viewer_scenarios), variant.result_key,
3313
+ )
3314
+ if df is None:
3315
+ self._plot_canvas.show_message(
3316
+ f"No raw parquet for {variant.result_key}\n"
3317
+ f"in viewer scenarios."
3318
+ )
3319
+ return
3320
+
3321
+ # Filter to currently-ticked scenarios (visibility toggle).
3322
+ if (
3323
+ checked
3324
+ and isinstance(df.columns, pd.MultiIndex)
3325
+ and "scenario" in df.columns.names
3326
+ ):
3327
+ mask = df.columns.get_level_values("scenario").isin(checked)
3328
+ df = df.loc[:, mask]
3329
+
3330
+ if df.empty:
3331
+ self._plot_canvas.show_message(f"Empty data for {variant.result_key}")
3332
+ return
3333
+
3334
+ try:
3335
+ cmp_cfg = derive_comparison_config(single_cfg)
3336
+ except (ValueError, TypeError) as exc:
3337
+ logger.warning(
3338
+ "derive_comparison_config failed for %s: %s",
3339
+ variant.result_key, exc,
3340
+ )
3341
+ self._plot_canvas.show_message(
3342
+ f"Cannot derive comparison config for {variant.result_key}\n"
3343
+ f"({exc})"
3344
+ )
3345
+ return
3346
+
3347
+ break_times = self._load_comparison_break_times(viewer_scenarios)
3348
+ plot_name = cmp_cfg.plot_name or variant.full_name
3349
+
3350
+ plan_key = (
3351
+ "_comparison_merged", variant.result_key, variant.sub_config,
3352
+ tuple(sorted(checked)),
3353
+ )
3354
+ if self._live_plan_key == plan_key and self._live_plan is not None:
3355
+ plan = self._live_plan
3356
+ else:
3357
+ plan = compute_live_plan(
3358
+ df, cmp_cfg, plot_name, break_times,
3359
+ color_path=resolve_plot_settings_path(self._project_path),
3360
+ )
3361
+ self._live_plan = plan
3362
+ self._live_plan_key = plan_key
3363
+
3364
+ if plan is None:
3365
+ self._plot_canvas.show_message(
3366
+ f"compute_live_plan returned None for {variant.result_key}"
3367
+ )
3368
+ return
3369
+
3370
+ self._apply_axis_manifest(plan, variant.result_key, variant.sub_config)
3371
+ self._update_time_range(len(plan.processed_df))
3372
+ start = self._start_var.get()
3373
+ duration = self._duration_var.get()
3374
+ plot_rows = (start, start + duration)
3375
+ self._file_count = plan.total_file_count
3376
+ self._file_index = min(self._file_index, max(0, self._file_count - 1))
3377
+ self._update_file_nav()
3378
+ fig = build_figure_from_plan(plan, self._file_index, plot_rows)
3379
+ if fig is not None:
3380
+ self._plot_canvas.display_figure(fig)
3381
+ logger.info(
3382
+ "Comparison %s (merged-config): file %d/%d",
3383
+ variant.result_key, self._file_index, plan.total_file_count,
3384
+ )
3385
+ else:
3386
+ self._plot_canvas.show_message(
3387
+ f"build_figure_from_plan returned None for {variant.result_key}"
3388
+ )
3389
+
3390
+ def _display_comparison(self, entry: PlotEntry, variant: PlotVariant) -> None:
3391
+ """Render comparison plot from the merged single-mode config.
3392
+
3393
+ Comparison rendering is driven by the ``scenario_rule`` field on
3394
+ the single-mode config (see :mod:`flextool.scenario_comparison.plan_union`)
3395
+ or, alternatively, by ``map_dimensions_for_plots`` supplied in
3396
+ ``comparison_overrides`` (which makes ``scenario_rule`` irrelevant).
3397
+ Configs that carry neither have no comparison view and surface a
3398
+ clear message instead of trying to render something undefined.
3399
+ """
3400
+ from flextool.scenario_comparison.plan_union import has_comparison_view
3401
+ single_cfg = self._load_single_plot_config(
3402
+ variant.result_key, variant.sub_config,
3403
+ )
3404
+ if single_cfg is None:
3405
+ self._plot_canvas.show_message(f"No config for {variant.result_key}")
3406
+ return
3407
+ if not has_comparison_view(single_cfg):
3408
+ self._plot_canvas.show_message(
3409
+ f"No comparison rendering for {variant.result_key}\n"
3410
+ f"(no `scenario_rule` or `comparison_overrides."
3411
+ f"map_dimensions_for_plots` in default_plots.yaml)."
3412
+ )
3413
+ return
3414
+ self._display_comparison_from_single(entry, variant, single_cfg)
3415
+
3416
+ def _load_comparison_break_times(
3417
+ self, viewer_scenarios: list[str] | None = None,
3418
+ ) -> set[str] | None:
3419
+ """Return the union of timeline-break times across viewer scenarios.
3420
+
3421
+ Phase E: ``_regenerate_comparison`` no longer writes a combined
3422
+ ``output_parquet_comparison/timeline_breaks.parquet``, so we
3423
+ union the per-scenario ``output_parquet/<scen>/timeline_breaks
3424
+ .parquet`` files at view time instead. Falls back to the
3425
+ legacy combined file when no viewer scenarios are passed and
3426
+ the combined file happens to exist (e.g. CLI run).
3427
+
3428
+ Cached under the ``"_comparison"`` slot so repeat callers
3429
+ don't re-read the per-scenario files.
3430
+ """
3431
+ if "_comparison" in self._break_times_cache:
3432
+ return self._break_times_cache["_comparison"]
3433
+
3434
+ scenarios = list(viewer_scenarios or [])
3435
+ if scenarios:
3436
+ union: set[str] = set()
3437
+ any_present = False
3438
+ for scen in scenarios:
3439
+ p = (
3440
+ self._project_path
3441
+ / "output_parquet" / scen / "timeline_breaks.parquet"
3442
+ )
3443
+ if not p.exists():
3444
+ continue
3445
+ any_present = True
3446
+ try:
3447
+ df = read_lean_parquet(p)
3448
+ if not df.empty:
3449
+ union.update(df.iloc[:, 0].astype(str))
3450
+ except Exception: # noqa: BLE001
3451
+ logger.warning(
3452
+ "Failed to read timeline breaks for %s", scen,
3453
+ exc_info=True,
3454
+ )
3455
+ result: set[str] | None = union if any_present else None
3456
+ self._break_times_cache["_comparison"] = result
3457
+ return result
3458
+
3459
+ # Legacy fallback (no viewer scenarios passed and / or CLI-built
3460
+ # combined file is around): read the combined parquet.
3461
+ path = (
3462
+ self._project_path
3463
+ / "output_parquet_comparison"
3464
+ / "timeline_breaks.parquet"
3465
+ )
3466
+ if not path.exists():
3467
+ self._break_times_cache["_comparison"] = None
3468
+ return None
3469
+ try:
3470
+ df = read_lean_parquet(path)
3471
+ if df.empty:
3472
+ result = None
3473
+ else:
3474
+ result = set(df.iloc[:, 0].astype(str))
3475
+ self._break_times_cache["_comparison"] = result
3476
+ return result
3477
+ except Exception: # noqa: BLE001
3478
+ self._break_times_cache["_comparison"] = None
3479
+ return None
3480
+
3481
+ # ------------------------------------------------------------------
3482
+ # Comparison scenarios tree (viewer-mode check state)
3483
+ # ------------------------------------------------------------------
3484
+
3485
+ # Unicode check glyphs — matches the main window's input/executed trees.
3486
+ _COMP_CHECK_ON = "\u25a0" # ■
3487
+ _COMP_CHECK_OFF = "\u25a1" # □
3488
+
3489
+ def _get_comparison_scenarios(self) -> list[str]:
3490
+ """Return list of checked scenario names for comparison."""
3491
+ result: list[str] = []
3492
+ for iid in self._comp_tree.get_children():
3493
+ values = self._comp_tree.item(iid, "values")
3494
+ if values and values[0] == self._COMP_CHECK_ON:
3495
+ result.append(values[1])
3496
+ return result
3497
+
3498
+ def _populate_comparison_checkboxes(self) -> None:
3499
+ """Populate the comparison scenarios tree with current check state.
3500
+
3501
+ Restores check state in this order:
3502
+
3503
+ 1. ``settings.comp_viewer_scenarios`` — the user's last-known ticks
3504
+ inside the viewer (saved on every click / space toggle).
3505
+ 2. Otherwise fall back to ``_metadata.json`` (the last-run
3506
+ comparison's scenarios) so a freshly-opened project still
3507
+ reflects what was actually generated.
3508
+ """
3509
+ # Clear existing rows
3510
+ for iid in self._comp_tree.get_children():
3511
+ self._comp_tree.delete(iid)
3512
+
3513
+ scenarios = self._scan_scenarios()
3514
+ if not scenarios:
3515
+ return
3516
+
3517
+ checked_from_settings = [
3518
+ s for s in self._settings.comp_viewer_scenarios if s in scenarios
3519
+ ]
3520
+ if checked_from_settings:
3521
+ checked_set: set[str] = set(checked_from_settings)
3522
+ else:
3523
+ import json
3524
+ meta_path = self._project_path / "output_parquet_comparison" / "_metadata.json"
3525
+ checked_set = set()
3526
+ if meta_path.exists():
3527
+ try:
3528
+ with open(meta_path) as f:
3529
+ checked_set = set(json.load(f).get("scenarios", []))
3530
+ except (json.JSONDecodeError, OSError):
3531
+ pass
3532
+
3533
+ for name in scenarios:
3534
+ glyph = self._COMP_CHECK_ON if name in checked_set else self._COMP_CHECK_OFF
3535
+ self._comp_tree.insert(
3536
+ "", "end", iid=name, values=(glyph, name),
3537
+ )
3538
+
3539
+ def _save_comparison_check_state(self) -> None:
3540
+ """Persist the current viewer-comparison check state to settings.
3541
+
3542
+ The settings object is shared with the main window (passed into
3543
+ the viewer's constructor), but the main window only persists on
3544
+ its own lifecycle events — we save here so the state survives a
3545
+ viewer crash or mode switch.
3546
+ """
3547
+ self._settings.comp_viewer_scenarios = self._get_comparison_scenarios()
3548
+ try:
3549
+ from flextool.gui.settings_io import save_project_settings
3550
+ save_project_settings(self._project_path, self._settings)
3551
+ except Exception:
3552
+ logger.warning("Could not persist comp_viewer_scenarios", exc_info=True)
3553
+
3554
+ def _set_comp_check(self, name: str, checked: bool) -> None:
3555
+ """Flip the check glyph on one row."""
3556
+ if not self._comp_tree.exists(name):
3557
+ return
3558
+ glyph = self._COMP_CHECK_ON if checked else self._COMP_CHECK_OFF
3559
+ self._comp_tree.set(name, "check", glyph)
3560
+
3561
+ def _on_comp_tree_toggled(self, _changed: list[str]) -> None:
3562
+ """CheckTreeController callback: filter-only replot after a toggle.
3563
+
3564
+ Phase B: persisting the tick set + replotting is delegated to
3565
+ :meth:`_on_comp_check_state_changed` (which saves once). We no
3566
+ longer regenerate the combined parquets from this path — that's
3567
+ the main-window button's job.
3568
+ """
3569
+ self._on_comp_check_state_changed()
3570
+
3571
+ def _on_comp_tree_ctrl_a(self, _event: tk.Event) -> str:
3572
+ """Ctrl-A selects every row (does not alter check state)."""
3573
+ children = self._comp_tree.get_children()
3574
+ if children:
3575
+ self._comp_tree.selection_set(children)
3576
+ self._comp_tree.focus(children[0])
3577
+ return "break"
3578
+
3579
+ def _on_comp_tree_reordered(self, new_order: list[str]) -> None:
3580
+ """Persist a new tree order and refresh comparison figures."""
3581
+ self._settings.executed_scenario_order = list(new_order)
3582
+ self._schedule_settings_save()
3583
+ # Re-render so the figures reflect the new order. The comparison
3584
+ # combine itself doesn't need to re-run (data is unchanged); the
3585
+ # figure-side ordering is driven by ``_get_comparison_scenarios``.
3586
+ self._clear_figure_cache()
3587
+ self._trigger_replot()
3588
+
3589
+ def _on_comp_tree_alt_up(self, _event: tk.Event) -> str:
3590
+ sel = list(self._comp_tree.selection())
3591
+ if not sel:
3592
+ return "break"
3593
+ children = list(self._comp_tree.get_children())
3594
+ try:
3595
+ indices = sorted(children.index(iid) for iid in sel)
3596
+ except ValueError:
3597
+ return "break"
3598
+ if indices[0] == 0:
3599
+ return "break"
3600
+ for idx in indices:
3601
+ self._comp_tree.move(children[idx], "", idx - 1)
3602
+ self._on_comp_tree_reordered(list(self._comp_tree.get_children()))
3603
+ return "break"
3604
+
3605
+ def _on_comp_tree_alt_down(self, _event: tk.Event) -> str:
3606
+ sel = list(self._comp_tree.selection())
3607
+ if not sel:
3608
+ return "break"
3609
+ children = list(self._comp_tree.get_children())
3610
+ try:
3611
+ indices = sorted(
3612
+ (children.index(iid) for iid in sel), reverse=True,
3613
+ )
3614
+ except ValueError:
3615
+ return "break"
3616
+ if indices[0] >= len(children) - 1:
3617
+ return "break"
3618
+ for idx in indices:
3619
+ self._comp_tree.move(children[idx], "", idx + 1)
3620
+ self._on_comp_tree_reordered(list(self._comp_tree.get_children()))
3621
+ return "break"
3622
+
3623
+ def _on_comp_check_state_changed(self) -> None:
3624
+ """Handle a user-driven comparison check-state change.
3625
+
3626
+ Phase B contract: toggling a row in the viewer's Scenarios tree
3627
+ is purely a *current scenarios* filter — it never triggers a
3628
+ rebuild of the combined parquets and never widens or narrows the
3629
+ axis-bounds set (the latter is frozen by Phase A's manifest
3630
+ scoping). The figure simply hides or reveals the toggled
3631
+ scenario's lines/bars/area and the persisted state is saved so
3632
+ the next session reopens with the same ticks.
3633
+ """
3634
+ # Persist the new tick set. ``_save_comparison_check_state``
3635
+ # writes it to ``settings.comp_viewer_scenarios``, which is now
3636
+ # exclusively the *current scenarios* record (the viewer
3637
+ # scenarios live in ``_metadata.json``).
3638
+ self._save_comparison_check_state()
3639
+ # The cached plan is keyed on the checked tuple; clear so the
3640
+ # next replot rebuilds against the filtered DataFrame.
3641
+ self._clear_figure_cache()
3642
+ self._trigger_replot()
3643
+
3644
+ def _regenerate_comparison(self, scenario_names: list[str]) -> None:
3645
+ """Update the comparison-mode metadata and refresh the viewer.
3646
+
3647
+ Phase E (lazy plan-parquet union) collapses this from a multi-GB
3648
+ cross-scenario raw-data combine to a metadata-only operation:
3649
+
3650
+ 1. Write ``output_parquet_comparison/_metadata.json`` with the
3651
+ viewer scenarios.
3652
+ 2. Drop in-memory caches that depend on the viewer-scenarios
3653
+ set (figure cache, axis manifest, dispatch metadata, the
3654
+ unioned plan-parquet cache).
3655
+ 3. Hand off to :meth:`_on_comparison_ready` to repopulate the
3656
+ comparison tree and the plot tree.
3657
+
3658
+ The unit-of-work moves entirely to render time: when the user
3659
+ opens a plot, :meth:`_display_comparison_from_single` reads each
3660
+ viewer scenario's raw parquet, derives a comparison config from
3661
+ the single-mode rules + ``scenario_rule``, and renders.
3662
+
3663
+ ``_comp_request_gen`` is still bumped so any in-flight async
3664
+ work from earlier rebuilds doesn't clobber the current state
3665
+ on completion — kept for forward-compatibility if async work
3666
+ is reintroduced here.
3667
+ """
3668
+ # Bump the generation counter — current state-change supersedes
3669
+ # any earlier in-flight async render that hasn't completed yet.
3670
+ self._comp_request_gen += 1
3671
+ gen = self._comp_request_gen
3672
+
3673
+ self._plot_canvas.show_message("Updating comparison data...")
3674
+
3675
+ try:
3676
+ self._write_comparison_metadata(list(scenario_names))
3677
+ except Exception as exc: # noqa: BLE001
3678
+ logger.warning(
3679
+ "Failed to write comparison metadata: %s", exc, exc_info=True,
3680
+ )
3681
+ self._on_comparison_failed(gen, exc)
3682
+ return
3683
+
3684
+ self._on_comparison_ready(gen)
3685
+
3686
+ def _write_comparison_metadata(self, scenario_names: list[str]) -> None:
3687
+ """Atomically write ``output_parquet_comparison/_metadata.json``.
3688
+
3689
+ The metadata file is the canonical record of the viewer-scenarios
3690
+ set; downstream callers (Phase A axis manifest filtering,
3691
+ Phase D dispatch metadata, and Phase E's union path) read it to
3692
+ decide which per-scenario subdirs to consult.
3693
+ """
3694
+ import json
3695
+ import tempfile
3696
+
3697
+ comp_dir = self._project_path / "output_parquet_comparison"
3698
+ comp_dir.mkdir(parents=True, exist_ok=True)
3699
+ meta_path = comp_dir / "_metadata.json"
3700
+ payload = {"scenarios": list(scenario_names)}
3701
+
3702
+ fd, tmp_name = tempfile.mkstemp(
3703
+ dir=str(comp_dir), prefix="_metadata_", suffix=".tmp",
3704
+ )
3705
+ tmp_path = Path(tmp_name)
3706
+ try:
3707
+ with open(fd, "w", encoding="utf-8") as f:
3708
+ json.dump(payload, f)
3709
+ tmp_path.replace(meta_path)
3710
+ except BaseException:
3711
+ tmp_path.unlink(missing_ok=True)
3712
+ raise
3713
+
3714
+ def _on_comparison_ready(self, gen: int) -> None:
3715
+ """Called on main thread when comparison parquets are ready.
3716
+
3717
+ Drops the result if a newer state-change has been queued since
3718
+ this combine was submitted; otherwise refreshes caches, refreshes
3719
+ the comparison tree (to reflect the new viewer scenarios), and
3720
+ triggers a replot.
3721
+ """
3722
+ if gen != self._comp_request_gen:
3723
+ # A newer request has superseded this one; ignore the stale
3724
+ # result so the plot keeps the latest selection in flight.
3725
+ return
3726
+ self._comp_needs_regen = False
3727
+ self._scheduled_viewer_scenarios = None
3728
+ self._break_times_cache.clear()
3729
+ self._clear_figure_cache()
3730
+ self._current_availability = set()
3731
+ # Phase E: drop the unioned plan-parquet cache; the viewer
3732
+ # scenarios set just changed so the cached union no longer
3733
+ # matches.
3734
+ self._parquet_cache_key = ("", "")
3735
+ self._parquet_cache_df = None
3736
+ # Phase A axis manifest is keyed on disk; ``_get_axis_manifest``
3737
+ # picks up file rewrites by mtime, but invalidating the cached
3738
+ # view here forces a fresh read on the next render.
3739
+ self._axis_manifest = None
3740
+ self._axis_manifest_mtime = 0.0
3741
+ if hasattr(self, '_dispatch_metadata_cache'):
3742
+ del self._dispatch_metadata_cache
3743
+ # The viewer scenarios set just changed on disk — re-render the
3744
+ # comparison tree so its rows match ``_metadata.json`` again.
3745
+ if self._mode.get() == "comparison":
3746
+ self._populate_comparison_checkboxes()
3747
+ self._populate_plot_tree()
3748
+ # Try to display current selection
3749
+ self._trigger_replot()
3750
+
3751
+ def _on_comparison_failed(self, gen: int, exc: BaseException) -> None:
3752
+ """Called on main thread when a comparison combine raised.
3753
+
3754
+ Suppresses the error message if a newer request has superseded
3755
+ this one — the in-flight render shouldn't be clobbered by a
3756
+ stale failure.
3757
+ """
3758
+ if gen != self._comp_request_gen:
3759
+ return
3760
+ self._scheduled_viewer_scenarios = None
3761
+ self._plot_canvas.show_message(f"Comparison update failed:\n{exc}")
3762
+
3763
+ # ------------------------------------------------------------------
3764
+ # Dispatch mode
3765
+ # ------------------------------------------------------------------
3766
+
3767
+ def _populate_dispatch_tree(self) -> None:
3768
+ """Populate the tree with a hierarchical dispatch view.
3769
+
3770
+ Each dispatchable nodeGroup is a parent (``dispatch_<ng>`` iid,
3771
+ renders the aggregate group dispatch). Its member nodes (from
3772
+ ``group_node.parquet``, restricted to nodes that have dispatch
3773
+ data) are children (``dispatchnode_<node>`` iid, renders an
3774
+ individual node dispatch). A node in several displayed groups
3775
+ appears under each. A catch-all parent "Ungrouped nodes" holds
3776
+ every available dispatch node that belongs to no displayed
3777
+ nodeGroup.
3778
+ """
3779
+ # Clear existing tree and entry map
3780
+ for item in self._plot_tree.get_children():
3781
+ self._plot_tree.delete(item)
3782
+ self._tree_entry_map.clear()
3783
+ self._dispatch_node_iids.clear()
3784
+
3785
+ scenarios = self._get_selected_scenarios()
3786
+ if not scenarios:
3787
+ return
3788
+
3789
+ scenario = scenarios[0]
3790
+ parquet_dir = self._project_path / "output_parquet" / scenario
3791
+ if not parquet_dir.is_dir():
3792
+ return
3793
+
3794
+ # Load dispatch groups (groups flagged for dispatch table output).
3795
+ # The model's nodeGroupDispatch set is the source of truth; the
3796
+ # similarly-named nodeGroupIndicators is for summary indicators
3797
+ # and a project may flag either independently.
3798
+ dispatch_groups_path = parquet_dir / "nodeGroupDispatch.parquet"
3799
+ if not dispatch_groups_path.exists():
3800
+ return
3801
+
3802
+ df = read_lean_parquet(dispatch_groups_path)
3803
+ if df.empty:
3804
+ return
3805
+
3806
+ group_col = 'group' if 'group' in df.columns else 'nodeGroupDispatch'
3807
+ flagged_groups = set(df[group_col].unique())
3808
+
3809
+ # group_node.parquet gives nodeGroup→node membership. Keep only
3810
+ # groups that actually have node members.
3811
+ gn_df: pd.DataFrame | None = None
3812
+ group_node_path = parquet_dir / "group_node.parquet"
3813
+ if group_node_path.exists():
3814
+ loaded = read_lean_parquet(group_node_path)
3815
+ if not loaded.empty and 'group' in loaded.columns:
3816
+ gn_df = loaded
3817
+ groups_with_nodes = set(loaded['group'].unique())
3818
+ flagged_groups &= groups_with_nodes
3819
+
3820
+ node_groups = sorted(flagged_groups)
3821
+
3822
+ # Discover all nodes with dispatch data (the same universe
3823
+ # ``prepare_node_dispatch_data`` operates on) so "Ungrouped nodes"
3824
+ # can be computed. Loading the dispatch results here is cheap and
3825
+ # idempotent (cached on ``self._dispatch_scenario``).
3826
+ available_nodes: list[str] = []
3827
+ if self._load_dispatch_data(scenario) and self._dispatch_results is not None:
3828
+ available_nodes = available_dispatch_nodes(self._dispatch_results)
3829
+
3830
+ tree = build_dispatch_tree(
3831
+ gn_df, node_groups, available_nodes,
3832
+ ungrouped_label=_DISPATCH_UNGROUPED_LABEL,
3833
+ )
3834
+
3835
+ first_iid: str | None = None
3836
+ for parent_idx, (parent_label, child_nodes) in enumerate(tree):
3837
+ parent_iid = f"{_DISPATCH_GROUP_PREFIX}{parent_label}"
3838
+ self._plot_tree.insert(
3839
+ "", "end", iid=parent_iid, text=parent_label, open=False,
3840
+ )
3841
+ if first_iid is None:
3842
+ first_iid = parent_iid
3843
+ for child_idx, node in enumerate(child_nodes):
3844
+ # Treeview iids are global, but a node can appear under
3845
+ # several groups — so qualify the child iid by its position
3846
+ # to keep it unique, and map it back to the node name.
3847
+ child_iid = (
3848
+ f"{_DISPATCH_NODE_PREFIX}{parent_idx}_{child_idx}_{node}"
3849
+ )
3850
+ self._dispatch_node_iids[child_iid] = node
3851
+ self._plot_tree.insert(
3852
+ parent_iid, "end", iid=child_iid, text=node,
3853
+ )
3854
+
3855
+ # Select first item (a nodeGroup parent — or the "Ungrouped nodes"
3856
+ # parent when no nodeGroups are flagged).
3857
+ if first_iid is not None:
3858
+ self._plot_tree.selection_set(first_iid)
3859
+ self._plot_tree.see(first_iid)
3860
+
3861
+ def _load_dispatch_data(self, scenario: str) -> bool:
3862
+ """Load dispatch data for a scenario. Returns True if successful."""
3863
+ if self._dispatch_scenario == scenario and self._dispatch_mappings is not None:
3864
+ return True # Already loaded
3865
+
3866
+ parquet_dir = self._project_path / "output_parquet" / scenario
3867
+ if not parquet_dir.is_dir():
3868
+ return False
3869
+
3870
+ # Load dispatch mappings
3871
+ raw_mappings = load_dispatch_mappings(parquet_dir)
3872
+
3873
+ # Build DispatchMappings with scenario in index. Always tag by the
3874
+ # folder identity, overwriting any model-scenario tag baked in at
3875
+ # write time, so the folder name is the single dispatch identity
3876
+ # (matches combine_parquet_files, which re-tags the results the same
3877
+ # way). See specs/dispatch_scenario_identity_retag.md.
3878
+ mapping_fields: dict[str, pd.DataFrame | None] = {}
3879
+ for key, df in raw_mappings.items():
3880
+ if df is not None and not df.empty:
3881
+ df_copy = df.copy()
3882
+ df_copy['scenario'] = scenario
3883
+ df_copy = df_copy.set_index('scenario')
3884
+ mapping_fields[key] = df_copy
3885
+ else:
3886
+ mapping_fields[key] = df
3887
+ self._dispatch_mappings = DispatchMappings(**mapping_fields)
3888
+ # Data and mappings are now both tagged by the folder identity, so
3889
+ # the cached slice key is just the folder name.
3890
+ self._dispatch_data_tag = resolve_data_scenario_tag(
3891
+ self._dispatch_mappings, scenario,
3892
+ )
3893
+
3894
+ # Load TimeSeriesResults
3895
+ scenario_folders = build_scenario_folders_from_dir(
3896
+ self._project_path / "output_parquet", [scenario]
3897
+ )
3898
+ files_by_name = collect_parquet_files(scenario_folders, output_subdir="")
3899
+ combined = combine_parquet_files(files_by_name, num_scenarios=1)
3900
+ self._dispatch_results = TimeSeriesResults.from_dict(combined)
3901
+
3902
+ self._dispatch_scenario = scenario
3903
+ return True
3904
+
3905
+ def _load_dispatch_metadata(self) -> dict | None:
3906
+ """Load cross-scenario dispatch metadata (ylims, columns) if available.
3907
+
3908
+ Resolution order (Phase D):
3909
+
3910
+ 1. If ``output_parquet_comparison/_dispatch_metadata.json`` exists
3911
+ AND ``output_parquet_comparison/_metadata.json`` records the
3912
+ same viewer-scenarios set as
3913
+ :meth:`_get_comparison_viewer_scenarios`, use the combined
3914
+ file (authoritative for the locked-in viewer set).
3915
+ 2. Else compute via
3916
+ :func:`union_dispatch_metadata` over the per-scenario
3917
+ ``output_parquet/<scenario>/_dispatch_metadata.json`` files.
3918
+ Missing per-scenario files contribute nothing (silently
3919
+ skipped, like Phase C's availability union).
3920
+ 3. Else ``None``.
3921
+ """
3922
+ if hasattr(self, '_dispatch_metadata_cache'):
3923
+ return self._dispatch_metadata_cache
3924
+
3925
+ import json
3926
+ from flextool.scenario_comparison.dispatch_plots import (
3927
+ union_dispatch_metadata,
3928
+ )
3929
+
3930
+ viewer_scenarios = self._get_comparison_viewer_scenarios()
3931
+ viewer_set = {str(s) for s in viewer_scenarios}
3932
+
3933
+ combined_path = (
3934
+ self._project_path
3935
+ / "output_parquet_comparison"
3936
+ / "_dispatch_metadata.json"
3937
+ )
3938
+ if combined_path.exists():
3939
+ metadata_scenarios = set(self._read_metadata_scenarios())
3940
+ if metadata_scenarios == viewer_set and viewer_set:
3941
+ try:
3942
+ with open(combined_path, "r", encoding="utf-8") as f:
3943
+ self._dispatch_metadata_cache = json.load(f)
3944
+ return self._dispatch_metadata_cache
3945
+ except (json.JSONDecodeError, OSError):
3946
+ pass # fall through to union
3947
+
3948
+ if viewer_scenarios:
3949
+ try:
3950
+ meta = union_dispatch_metadata(
3951
+ self._project_path, list(viewer_scenarios),
3952
+ )
3953
+ if meta.get("nodeGroups"):
3954
+ self._dispatch_metadata_cache = meta
3955
+ return self._dispatch_metadata_cache
3956
+ except Exception: # noqa: BLE001
3957
+ logger.warning(
3958
+ "Failed to union per-scenario dispatch metadata",
3959
+ exc_info=True,
3960
+ )
3961
+
3962
+ self._dispatch_metadata_cache = None
3963
+ return None
3964
+
3965
+ def _render_first_dispatch_figure(self) -> None:
3966
+ """Render the first dispatch nodeGroup for the selected scenario.
3967
+
3968
+ Called after ``_populate_dispatch_tree`` to guarantee a figure
3969
+ appears on mode entry, since the virtual ``<<TreeviewSelect>>``
3970
+ event from programmatic selection isn't always delivered before
3971
+ the layout settles.
3972
+ """
3973
+ scenarios = self._get_selected_scenarios()
3974
+ if not scenarios:
3975
+ self._plot_canvas.show_message("Select a scenario to view dispatch")
3976
+ return
3977
+ selection = self._plot_tree.selection()
3978
+ if not selection or not _is_dispatch_iid(selection[0]):
3979
+ # No node groups in the tree — nothing to render
3980
+ self._plot_canvas.show_message(
3981
+ "No dispatch node groups available for this scenario"
3982
+ )
3983
+ return
3984
+ self._display_dispatch_iid(scenarios[0], selection[0])
3985
+
3986
+ def _display_dispatch_iid(self, scenario: str, iid: str) -> None:
3987
+ """Route a dispatch-tree iid to the right renderer.
3988
+
3989
+ ``dispatchnode_<node>`` → individual node dispatch;
3990
+ ``dispatch_<ng>`` → aggregate nodeGroup dispatch. The node prefix
3991
+ is checked first because it also starts with ``dispatch_``. The
3992
+ "Ungrouped nodes" parent is a pure container with no plot of its
3993
+ own, so selecting it shows a hint.
3994
+ """
3995
+ if iid.startswith(_DISPATCH_NODE_PREFIX):
3996
+ node = self._dispatch_node_iids.get(iid)
3997
+ if node is not None:
3998
+ self._last_dispatch_iid = iid
3999
+ self._display_node_dispatch(scenario, node)
4000
+ return
4001
+ if iid.startswith(_DISPATCH_GROUP_PREFIX):
4002
+ label = iid[len(_DISPATCH_GROUP_PREFIX):]
4003
+ if label == _DISPATCH_UNGROUPED_LABEL:
4004
+ self._plot_canvas.show_message(
4005
+ "Select a node under “Ungrouped nodes” to view "
4006
+ "its dispatch"
4007
+ )
4008
+ return
4009
+ self._last_dispatch_iid = iid
4010
+ self._display_dispatch(scenario, label)
4011
+
4012
+ def _resolve_dispatch_template(self):
4013
+ """Load the project plot-settings template + entity stacking order.
4014
+
4015
+ Shared by the nodeGroup and node dispatch renderers so both honor
4016
+ the same ``plot_settings.yaml`` colors/order (Stage 4.1).
4017
+ """
4018
+ from flextool.plot_outputs.color_template import (
4019
+ load_color_template,
4020
+ resolve_dispatch_colors_and_order,
4021
+ template_entity_names,
4022
+ )
4023
+
4024
+ template = load_color_template(
4025
+ resolve_plot_settings_path(self._project_path)
4026
+ )
4027
+ _, config_order = resolve_dispatch_colors_and_order(
4028
+ template, template_entity_names(template),
4029
+ )
4030
+ return template, config_order
4031
+
4032
+ def _maybe_invalidate_dispatch_order_on_settings_change(self) -> None:
4033
+ """Drop the frozen dispatch order/ylim caches when the settings changed.
4034
+
4035
+ The per-nodeGroup ``_dispatch_columns`` accumulator freezes the stack
4036
+ order on a nodeGroup's first render and every later render reindexes to
4037
+ it — so an edited ``plot_settings.yaml`` order would be ignored on a
4038
+ plain navigate (it only refreshed when the picker's Apply, or a rescan,
4039
+ happened to clear the cache). Stamp the accumulator with the settings
4040
+ file's mtime and, whenever it changes (a dialog save or a live edit),
4041
+ clear the frozen order + ylims so the next render re-derives them from
4042
+ the current ``config_order``. Cheap ``stat`` per render; no file read.
4043
+ """
4044
+ try:
4045
+ mtime = resolve_plot_settings_path(
4046
+ self._project_path
4047
+ ).stat().st_mtime_ns
4048
+ except OSError:
4049
+ return
4050
+ if mtime != self._dispatch_order_settings_mtime:
4051
+ self._dispatch_columns.clear()
4052
+ self._dispatch_ylims.clear()
4053
+ self._dispatch_order_settings_mtime = mtime
4054
+
4055
+ def _display_node_dispatch(self, scenario: str, node: str) -> None:
4056
+ """Render and display an individual node dispatch plot."""
4057
+ self._maybe_invalidate_dispatch_order_on_settings_change()
4058
+ from flextool.plot_outputs.color_template import (
4059
+ resolve_dispatch_colors_and_order,
4060
+ )
4061
+ from flextool.scenario_comparison.dispatch_plots import _compute_ylim
4062
+
4063
+ if not self._load_dispatch_data(scenario):
4064
+ self._plot_canvas.show_message(
4065
+ f"Could not load dispatch data for {scenario}"
4066
+ )
4067
+ return
4068
+
4069
+ results = self._dispatch_results
4070
+
4071
+ # Node dispatch threads the same plot_settings.yaml colors/order the
4072
+ # group path uses, so the live node plot matches the PNG export.
4073
+ template, config_order = self._resolve_dispatch_template()
4074
+
4075
+ # Slice by the in-data scenario tag (may differ from folder name).
4076
+ # Thread config_order so the per-node stack follows the picker's
4077
+ # order (not just the std-dev fallback), like the nodeGroup path.
4078
+ # special_order carries the dispatch specials' file order so the
4079
+ # engine stacks the positive/negative specials within each sign group
4080
+ # in the order the picker shows.
4081
+ special_order = list(
4082
+ template.get('categories', {}).get('dispatch', {}).keys()
4083
+ )
4084
+ df_node, inflow = prepare_node_dispatch_data(
4085
+ results, self._dispatch_data_tag, node, config_order=config_order,
4086
+ special_order=special_order,
4087
+ )
4088
+
4089
+ if (df_node is None or df_node.empty) and (
4090
+ inflow is None or inflow.empty
4091
+ ):
4092
+ self._plot_canvas.show_message(f"No dispatch data for {node}")
4093
+ return
4094
+ if df_node is None or df_node.empty:
4095
+ idx = inflow.index if inflow is not None else pd.RangeIndex(1)
4096
+ df_node = pd.DataFrame(index=idx)
4097
+
4098
+ self._update_time_range(len(df_node))
4099
+
4100
+ start = self._start_var.get()
4101
+ duration = self._duration_var.get()
4102
+ timeline = (start, start + duration)
4103
+
4104
+ # Accumulate ylims + column order across scenarios for this node.
4105
+ node_key = f"{_DISPATCH_NODE_PREFIX}{node}"
4106
+ ymin, ymax = _compute_ylim(df_node, timeline, inflow)
4107
+ if node_key in self._dispatch_ylims:
4108
+ old_min, old_max = self._dispatch_ylims[node_key]
4109
+ self._dispatch_ylims[node_key] = (
4110
+ min(old_min, ymin), max(old_max, ymax),
4111
+ )
4112
+ for col in df_node.columns:
4113
+ if col not in self._dispatch_columns[node_key]:
4114
+ self._dispatch_columns[node_key].append(col)
4115
+ else:
4116
+ self._dispatch_ylims[node_key] = (ymin, ymax)
4117
+ self._dispatch_columns[node_key] = list(df_node.columns)
4118
+
4119
+ acc_min, acc_max = self._dispatch_ylims[node_key]
4120
+ margin = (acc_max - acc_min) * 0.05
4121
+ ylim = (acc_min - margin, acc_max + margin)
4122
+
4123
+ for col in self._dispatch_columns[node_key]:
4124
+ if col not in df_node.columns:
4125
+ df_node[col] = 0.0
4126
+ df_node = df_node[
4127
+ [c for c in self._dispatch_columns[node_key] if c in df_node.columns]
4128
+ ]
4129
+
4130
+ break_times = self._load_break_times(scenario)
4131
+
4132
+ # Resolve per-column colors from the template (entity / node-level
4133
+ # columns + special tokens); unresolved columns fall back to the
4134
+ # palette inside ``_build_dispatch_figure``.
4135
+ colors, _ = resolve_dispatch_colors_and_order(
4136
+ template, [str(c) for c in df_node.columns],
4137
+ )
4138
+
4139
+ fig = _build_dispatch_figure(
4140
+ df_node, inflow,
4141
+ title=f"{node} — {scenario}",
4142
+ colors=colors,
4143
+ timeline=timeline,
4144
+ ylim=ylim,
4145
+ break_times=break_times,
4146
+ )
4147
+
4148
+ if fig is None:
4149
+ self._plot_canvas.show_message(f"No plottable data for {node}")
4150
+ return
4151
+
4152
+ self._file_count = 1
4153
+ self._file_index = 0
4154
+ self._update_file_nav()
4155
+
4156
+ self._plot_canvas.display_figure(fig)
4157
+
4158
+ def _display_dispatch(self, scenario: str, node_group: str) -> None:
4159
+ """Render and display a dispatch plot for a nodeGroup."""
4160
+ from flextool.scenario_comparison.dispatch_plots import _compute_ylim
4161
+
4162
+ self._maybe_invalidate_dispatch_order_on_settings_change()
4163
+ if not self._load_dispatch_data(scenario):
4164
+ self._plot_canvas.show_message(f"Could not load dispatch data for {scenario}")
4165
+ return
4166
+
4167
+ results = self._dispatch_results
4168
+ mappings = self._dispatch_mappings
4169
+
4170
+ # Dispatch colors + stacking order come from the project's
4171
+ # ``plot_settings.yaml`` (``entities`` + ``categories.dispatch``),
4172
+ # identical to the PNG path, so the live viewer matches the export.
4173
+ from flextool.plot_outputs.color_template import (
4174
+ load_color_template,
4175
+ resolve_dispatch_colors_and_order,
4176
+ template_entity_names,
4177
+ )
4178
+
4179
+ template = load_color_template(
4180
+ resolve_plot_settings_path(self._project_path)
4181
+ )
4182
+ _, config_order = resolve_dispatch_colors_and_order(
4183
+ template, template_entity_names(template),
4184
+ )
4185
+ # Dispatch specials' file order (picker top-to-bottom) so the engine
4186
+ # stacks the positive/negative specials within each sign group to
4187
+ # match the picker.
4188
+ special_order = list(
4189
+ template.get('categories', {}).get('dispatch', {}).keys()
4190
+ )
4191
+
4192
+ # Prepare dispatch data — slice by the in-data scenario tag, which
4193
+ # may differ from the folder name (GUI run-index suffix).
4194
+ df_dispatch, inflow = prepare_dispatch_data(
4195
+ results, mappings, self._dispatch_data_tag, node_group,
4196
+ config_order=config_order, special_order=special_order,
4197
+ )
4198
+
4199
+ if df_dispatch is None or df_dispatch.empty:
4200
+ self._plot_canvas.show_message(f"No dispatch data for {node_group}")
4201
+ return
4202
+
4203
+ self._update_time_range(len(df_dispatch))
4204
+
4205
+ # Get timeline from start/duration controls
4206
+ start = self._start_var.get()
4207
+ duration = self._duration_var.get()
4208
+ timeline = (start, start + duration)
4209
+
4210
+ # Accumulate ylims and column order across scenarios for this nodeGroup
4211
+ ymin, ymax = _compute_ylim(df_dispatch, timeline, inflow)
4212
+ if node_group in self._dispatch_ylims:
4213
+ old_min, old_max = self._dispatch_ylims[node_group]
4214
+ self._dispatch_ylims[node_group] = (min(old_min, ymin), max(old_max, ymax))
4215
+ # Add new columns preserving existing order
4216
+ for col in df_dispatch.columns:
4217
+ if col not in self._dispatch_columns[node_group]:
4218
+ self._dispatch_columns[node_group].append(col)
4219
+ else:
4220
+ self._dispatch_ylims[node_group] = (ymin, ymax)
4221
+ self._dispatch_columns[node_group] = list(df_dispatch.columns)
4222
+
4223
+ # Apply accumulated ylim with margin
4224
+ acc_min, acc_max = self._dispatch_ylims[node_group]
4225
+ margin = (acc_max - acc_min) * 0.05
4226
+ ylim = (acc_min - margin, acc_max + margin)
4227
+
4228
+ # Ensure consistent column order across scenarios
4229
+ for col in self._dispatch_columns[node_group]:
4230
+ if col not in df_dispatch.columns:
4231
+ df_dispatch[col] = 0.0
4232
+ df_dispatch = df_dispatch[[c for c in self._dispatch_columns[node_group] if c in df_dispatch.columns]]
4233
+
4234
+ # Scale the y-axis to THIS scenario's own dispatch, mirroring the
4235
+ # per-node path (_display_node_dispatch). We deliberately do NOT merge
4236
+ # the cross-scenario comparison-metadata ylim here: unioning ylims
4237
+ # across scenarios of very different magnitude (e.g. a represented-
4238
+ # period solve vs an 8760 h trade-only snapshot ~1000x larger) forces
4239
+ # the smaller scenario onto the larger one's axis and squashes its
4240
+ # flows into a flat line at the bottom. Per-scenario scaling keeps
4241
+ # every nodeGroup dispatch plot readable.
4242
+
4243
+ # Load break times
4244
+ break_times = self._load_break_times(scenario)
4245
+
4246
+ # Resolve per-column colors from the template (special tokens +
4247
+ # entity / composite / node-level columns); unresolved columns fall
4248
+ # back to the palette inside ``_build_dispatch_figure``.
4249
+ colors, _ = resolve_dispatch_colors_and_order(
4250
+ template, [str(c) for c in df_dispatch.columns]
4251
+ )
4252
+
4253
+ # Build figure
4254
+ fig = _build_dispatch_figure(
4255
+ df_dispatch, inflow,
4256
+ title=f"{node_group} \u2014 {scenario}",
4257
+ colors=colors,
4258
+ timeline=timeline,
4259
+ ylim=ylim,
4260
+ break_times=break_times,
4261
+ )
4262
+
4263
+ if fig is None:
4264
+ self._plot_canvas.show_message(f"No plottable data for {node_group}")
4265
+ return
4266
+
4267
+ # No file navigation for dispatch (single figure per nodeGroup)
4268
+ self._file_count = 1
4269
+ self._file_index = 0
4270
+ self._update_file_nav()
4271
+
4272
+ self._plot_canvas.display_figure(fig)
4273
+
4274
+ # ------------------------------------------------------------------
4275
+ # Network rendering
4276
+ # ------------------------------------------------------------------
4277
+
4278
+ def _render_network(self) -> None:
4279
+ """Render the network graph for the selected scenario."""
4280
+ scenarios = self._get_selected_scenarios()
4281
+ if not scenarios:
4282
+ self._plot_canvas.show_message("Select a scenario to display the network graph")
4283
+ return
4284
+
4285
+ scenario = scenarios[0]
4286
+ db_path = self._scenario_db_map.get(scenario)
4287
+ if not db_path:
4288
+ self._plot_canvas.show_message(
4289
+ f"No database found for scenario '{scenario}'"
4290
+ )
4291
+ return
4292
+
4293
+ db_url = f"sqlite:///{db_path}"
4294
+ fig = build_network_figure(db_url)
4295
+ if fig is None:
4296
+ self._plot_canvas.show_message(
4297
+ "No latitude/longitude data found for nodes in this database.\n"
4298
+ "Geographic coordinates (lat, lon) must be defined as node parameters."
4299
+ )
4300
+ return
4301
+
4302
+ self._plot_canvas.display_figure(fig)
4303
+
4304
+ # ------------------------------------------------------------------
4305
+ # Window close
4306
+ # ------------------------------------------------------------------
4307
+
4308
+ def _restore_sash_position(self) -> None:
4309
+ """Restore saved sash positions after the window is laid out.
4310
+
4311
+ Clamps each saved pixel value to the current pane so a position
4312
+ captured at a different DPI / screen size cannot collapse one
4313
+ side to zero.
4314
+ """
4315
+ from flextool.gui.ui_metrics import clamp_sash, rescale_pixels
4316
+ try:
4317
+ self.update_idletasks()
4318
+ paned_total = self._paned.winfo_width()
4319
+ target = clamp_sash(
4320
+ rescale_pixels(
4321
+ self._viewer_settings.left_pane_width,
4322
+ self._viewer_settings.layout_cw,
4323
+ self._char_width,
4324
+ ),
4325
+ paned_total,
4326
+ min_px=self._char_width * 20,
4327
+ )
4328
+ if target > 0:
4329
+ self._paned.sashpos(0, target)
4330
+ except (tk.TclError, IndexError):
4331
+ pass
4332
+ try:
4333
+ left_paned_total = self._left_paned.winfo_height()
4334
+ target = clamp_sash(
4335
+ rescale_pixels(
4336
+ self._viewer_settings.scenario_pane_height,
4337
+ self._viewer_settings.layout_cw,
4338
+ self._char_width,
4339
+ ),
4340
+ left_paned_total,
4341
+ min_px=self._line_height * 4,
4342
+ )
4343
+ if target > 0:
4344
+ self._left_paned.sashpos(0, target)
4345
+ except (tk.TclError, IndexError):
4346
+ pass
4347
+
4348
+ def _on_close(self) -> None:
4349
+ """Handle window close — persist settings and clean up resources."""
4350
+ self._hide_tooltip()
4351
+ self._render_gen += 1 # invalidate in-flight builds
4352
+ self._executor.shutdown(wait=False)
4353
+ self._clear_figure_cache()
4354
+
4355
+ # Cancel any pending debounced save — the ``save_project_settings``
4356
+ # call below already covers the latest variant_durations state.
4357
+ if self._duration_save_after_id is not None:
4358
+ try:
4359
+ self.after_cancel(self._duration_save_after_id)
4360
+ except (tk.TclError, ValueError):
4361
+ pass
4362
+ self._duration_save_after_id = None
4363
+
4364
+ # Unbind global key bindings added with bind_all
4365
+ for seq in ("<Prior>", "<Next>", "<Left>", "<Right>"):
4366
+ self.unbind_all(seq)
4367
+
4368
+ # Save window geometry (keyed by monitor configuration) and sashes
4369
+ from flextool.gui.ui_metrics import monitor_signature
4370
+ self._viewer_settings.layout_cw = self._char_width
4371
+ self._viewer_settings.window_geometry[monitor_signature(self)] = self.geometry()
4372
+ try:
4373
+ self._viewer_settings.left_pane_width = self._paned.sashpos(0)
4374
+ except (tk.TclError, IndexError):
4375
+ pass
4376
+ try:
4377
+ self._viewer_settings.scenario_pane_height = self._left_paned.sashpos(0)
4378
+ except (tk.TclError, IndexError):
4379
+ pass
4380
+
4381
+ # Save comparison scenarios if in comparison mode
4382
+ if self._mode.get() == "comparison":
4383
+ self._settings.comp_plots_scenarios = self._get_comparison_scenarios()
4384
+
4385
+ # Persist all settings
4386
+ try:
4387
+ save_project_settings(self._project_path, self._settings)
4388
+ except Exception: # noqa: BLE001
4389
+ logger.warning("Failed to save viewer settings on close", exc_info=True)
4390
+
4391
+ # Clean up matplotlib resources
4392
+ self._plot_canvas.cleanup()
4393
+
4394
+ self.destroy()