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.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- 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()
|