flextool 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. flextool/__init__.py +41 -0
  2. flextool/_mem_sampler.py +193 -0
  3. flextool/_resources.py +43 -0
  4. flextool/calibrate/__init__.py +51 -0
  5. flextool/calibrate/__main__.py +11 -0
  6. flextool/calibrate/_cli.py +316 -0
  7. flextool/calibrate/_db_alt.py +166 -0
  8. flextool/calibrate/_final_outputs.py +110 -0
  9. flextool/calibrate/_guard.py +151 -0
  10. flextool/calibrate/_loop.py +558 -0
  11. flextool/calibrate/_readers.py +223 -0
  12. flextool/calibrate/_report.py +263 -0
  13. flextool/calibrate/_sizing.py +699 -0
  14. flextool/calibrate/_solve.py +134 -0
  15. flextool/calibrate/_solve_status.py +495 -0
  16. flextool/cli/__init__.py +9 -0
  17. flextool/cli/_console.py +51 -0
  18. flextool/cli/_timing.py +147 -0
  19. flextool/cli/cmd_execute_flextool_workflow.py +187 -0
  20. flextool/cli/cmd_export_to_tabular.py +56 -0
  21. flextool/cli/cmd_import_sensitivities.py +75 -0
  22. flextool/cli/cmd_migrate_database.py +13 -0
  23. flextool/cli/cmd_open_results_db.py +269 -0
  24. flextool/cli/cmd_read_matpower.py +66 -0
  25. flextool/cli/cmd_read_old_flextool.py +63 -0
  26. flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
  27. flextool/cli/cmd_read_tabular_input.py +81 -0
  28. flextool/cli/cmd_run_flextool.py +1095 -0
  29. flextool/cli/cmd_scenario_results.py +284 -0
  30. flextool/cli/cmd_solve_mps.py +169 -0
  31. flextool/cli/cmd_update_flextool.py +17 -0
  32. flextool/cli/cmd_write_outputs.py +125 -0
  33. flextool/common_utils/__init__.py +1 -0
  34. flextool/common_utils/plot_mem_shape.py +77 -0
  35. flextool/common_utils/precision.py +451 -0
  36. flextool/decomposition/__init__.py +0 -0
  37. flextool/decomposition/region_decomposition.py +128 -0
  38. flextool/decomposition/region_filter.py +1261 -0
  39. flextool/engine_polars/__init__.py +110 -0
  40. flextool/engine_polars/_axis_enums.py +742 -0
  41. flextool/engine_polars/_benders.py +3462 -0
  42. flextool/engine_polars/_block_layout.py +1479 -0
  43. flextool/engine_polars/_blocks.py +1515 -0
  44. flextool/engine_polars/_commodity_ladder.py +660 -0
  45. flextool/engine_polars/_cumulative_invest.py +1165 -0
  46. flextool/engine_polars/_db_loader.py +153 -0
  47. flextool/engine_polars/_db_reader.py +127 -0
  48. flextool/engine_polars/_dc_power_flow.py +445 -0
  49. flextool/engine_polars/_delay.py +442 -0
  50. flextool/engine_polars/_derived_arithmetic.py +432 -0
  51. flextool/engine_polars/_derived_block.py +990 -0
  52. flextool/engine_polars/_derived_branch.py +769 -0
  53. flextool/engine_polars/_derived_existing.py +1353 -0
  54. flextool/engine_polars/_derived_npv.py +1297 -0
  55. flextool/engine_polars/_derived_params.py +9850 -0
  56. flextool/engine_polars/_derived_profile.py +881 -0
  57. flextool/engine_polars/_derived_walks.py +276 -0
  58. flextool/engine_polars/_determinism.py +70 -0
  59. flextool/engine_polars/_direct_params.py +2186 -0
  60. flextool/engine_polars/_dump_csvs.py +1009 -0
  61. flextool/engine_polars/_emit_arc_unions.py +1631 -0
  62. flextool/engine_polars/_emit_calc_params.py +729 -0
  63. flextool/engine_polars/_emit_chain_params.py +709 -0
  64. flextool/engine_polars/_emit_co2_accumulators.py +400 -0
  65. flextool/engine_polars/_emit_dispatchers.py +690 -0
  66. flextool/engine_polars/_emit_energy_margin.py +125 -0
  67. flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
  68. flextool/engine_polars/_emit_entity_annual.py +428 -0
  69. flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
  70. flextool/engine_polars/_emit_leaf_sets.py +550 -0
  71. flextool/engine_polars/_emit_lp_scaling.py +665 -0
  72. flextool/engine_polars/_emit_mid_sets.py +859 -0
  73. flextool/engine_polars/_emit_pdt_params.py +759 -0
  74. flextool/engine_polars/_emit_per_solve.py +774 -0
  75. flextool/engine_polars/_emit_period_calc.py +504 -0
  76. flextool/engine_polars/_emit_period_params.py +2398 -0
  77. flextool/engine_polars/_emit_provider_io.py +141 -0
  78. flextool/engine_polars/_emit_reserve.py +574 -0
  79. flextool/engine_polars/_emit_solve_time.py +311 -0
  80. flextool/engine_polars/_emit_solve_writers.py +1249 -0
  81. flextool/engine_polars/_flex_data_accumulator.py +388 -0
  82. flextool/engine_polars/_flex_data_provider.py +478 -0
  83. flextool/engine_polars/_group_slack.py +1253 -0
  84. flextool/engine_polars/_inmemory_reader.py +140 -0
  85. flextool/engine_polars/_input_source.py +336 -0
  86. flextool/engine_polars/_invest_seeds.py +191 -0
  87. flextool/engine_polars/_native_input_writer.py +100 -0
  88. flextool/engine_polars/_native_run_model.py +1348 -0
  89. flextool/engine_polars/_orchestration.py +4314 -0
  90. flextool/engine_polars/_output_writer.py +439 -0
  91. flextool/engine_polars/_param_shapes.py +1595 -0
  92. flextool/engine_polars/_parquet_bundle.py +723 -0
  93. flextool/engine_polars/_pdt_join.py +167 -0
  94. flextool/engine_polars/_pdt_lookup.py +547 -0
  95. flextool/engine_polars/_per_solve_sets.py +335 -0
  96. flextool/engine_polars/_projection_params.py +2056 -0
  97. flextool/engine_polars/_provider_keys.py +173 -0
  98. flextool/engine_polars/_provider_translators.py +225 -0
  99. flextool/engine_polars/_recursive_solve.py +703 -0
  100. flextool/engine_polars/_region_filter.py +2508 -0
  101. flextool/engine_polars/_reserve.py +649 -0
  102. flextool/engine_polars/_solve_acceptance.py +331 -0
  103. flextool/engine_polars/_solve_config.py +1001 -0
  104. flextool/engine_polars/_solve_context.py +885 -0
  105. flextool/engine_polars/_solve_handoff.py +164 -0
  106. flextool/engine_polars/_solve_state.py +232 -0
  107. flextool/engine_polars/_solver_base.py +36 -0
  108. flextool/engine_polars/_solver_dispatch.py +511 -0
  109. flextool/engine_polars/_spinedb_reader.py +1165 -0
  110. flextool/engine_polars/_stochastic.py +593 -0
  111. flextool/engine_polars/_subprocess_solve.py +1838 -0
  112. flextool/engine_polars/_timeline.py +1416 -0
  113. flextool/engine_polars/_vectorize.py +438 -0
  114. flextool/engine_polars/_warm.py +858 -0
  115. flextool/engine_polars/autoscale/__init__.py +107 -0
  116. flextool/engine_polars/autoscale/_config.py +218 -0
  117. flextool/engine_polars/autoscale/_layer2.py +1253 -0
  118. flextool/engine_polars/autoscale/_layer2_types.py +584 -0
  119. flextool/engine_polars/autoscale/_quantity_types.py +621 -0
  120. flextool/engine_polars/autoscale/_report.py +336 -0
  121. flextool/engine_polars/chain.py +259 -0
  122. flextool/engine_polars/input.py +6638 -0
  123. flextool/engine_polars/model.py +4754 -0
  124. flextool/env_check.py +388 -0
  125. flextool/export_to_tabular/__init__.py +5 -0
  126. flextool/export_to_tabular/db_reader.py +224 -0
  127. flextool/export_to_tabular/excel_writer.py +3559 -0
  128. flextool/export_to_tabular/export_settings.yaml +377 -0
  129. flextool/export_to_tabular/export_to_excel.py +227 -0
  130. flextool/export_to_tabular/formatting.py +543 -0
  131. flextool/export_to_tabular/sheet_config.py +876 -0
  132. flextool/gui/__init__.py +0 -0
  133. flextool/gui/__main__.py +118 -0
  134. flextool/gui/calibrate_commands.py +184 -0
  135. flextool/gui/calibrate_jobs.py +424 -0
  136. flextool/gui/check_tree.py +142 -0
  137. flextool/gui/cli_format.py +83 -0
  138. flextool/gui/config_parser.py +68 -0
  139. flextool/gui/data_models.py +362 -0
  140. flextool/gui/db_editor_integration.py +202 -0
  141. flextool/gui/db_version_check.py +269 -0
  142. flextool/gui/dialogs/__init__.py +0 -0
  143. flextool/gui/dialogs/add_dialog.py +1098 -0
  144. flextool/gui/dialogs/calibrate_dialog.py +1259 -0
  145. flextool/gui/dialogs/file_picker.py +473 -0
  146. flextool/gui/dialogs/group_picker.py +299 -0
  147. flextool/gui/dialogs/migration_consent_dialog.py +106 -0
  148. flextool/gui/dialogs/migration_progress_dialog.py +237 -0
  149. flextool/gui/dialogs/plot_dialog.py +459 -0
  150. flextool/gui/dialogs/plot_settings_picker.py +2184 -0
  151. flextool/gui/dialogs/project_dialog.py +426 -0
  152. flextool/gui/dialogs/update_dialog.py +212 -0
  153. flextool/gui/downsampling.py +88 -0
  154. flextool/gui/error_handling.py +50 -0
  155. flextool/gui/execution_manager.py +1715 -0
  156. flextool/gui/execution_window.py +1377 -0
  157. flextool/gui/hover_tooltip.py +111 -0
  158. flextool/gui/input_sources.py +730 -0
  159. flextool/gui/main_window.py +6181 -0
  160. flextool/gui/network_graph.py +215 -0
  161. flextool/gui/output_actions.py +393 -0
  162. flextool/gui/output_log_window.py +159 -0
  163. flextool/gui/platform_utils.py +421 -0
  164. flextool/gui/plot_cache.py +88 -0
  165. flextool/gui/plot_canvas.py +543 -0
  166. flextool/gui/plot_config_reader.py +272 -0
  167. flextool/gui/project_utils.py +100 -0
  168. flextool/gui/result_viewer.py +4394 -0
  169. flextool/gui/scenario_key.py +162 -0
  170. flextool/gui/scenario_lists.py +516 -0
  171. flextool/gui/settings_io.py +360 -0
  172. flextool/gui/solve_reader.py +103 -0
  173. flextool/gui/tree_reorder.py +88 -0
  174. flextool/gui/ui_metrics.py +420 -0
  175. flextool/input_derivation/__init__.py +281 -0
  176. flextool/input_derivation/_commodity_ladder.py +375 -0
  177. flextool/input_derivation/_commodity_ladder_sets.py +70 -0
  178. flextool/input_derivation/_dc_power_flow.py +377 -0
  179. flextool/input_derivation/_method_constants.py +77 -0
  180. flextool/input_derivation/_process_method.py +258 -0
  181. flextool/input_derivation/_specs.py +1026 -0
  182. flextool/input_derivation/_validators.py +321 -0
  183. flextool/lean_parquet.py +159 -0
  184. flextool/model_builder/__init__.py +5 -0
  185. flextool/model_builder/build_model.py +589 -0
  186. flextool/model_builder/encoding.py +67 -0
  187. flextool/model_builder/names.py +34 -0
  188. flextool/model_builder/profiles.py +129 -0
  189. flextool/plot_outputs/__init__.py +14 -0
  190. flextool/plot_outputs/axis_helpers.py +355 -0
  191. flextool/plot_outputs/color_template.py +888 -0
  192. flextool/plot_outputs/config.py +171 -0
  193. flextool/plot_outputs/format_helpers.py +345 -0
  194. flextool/plot_outputs/legend_helpers.py +143 -0
  195. flextool/plot_outputs/orchestrator.py +1141 -0
  196. flextool/plot_outputs/perf.py +37 -0
  197. flextool/plot_outputs/plan.py +1787 -0
  198. flextool/plot_outputs/plot_bars.py +1510 -0
  199. flextool/plot_outputs/plot_bars_detail.py +753 -0
  200. flextool/plot_outputs/plot_lines.py +951 -0
  201. flextool/plot_outputs/shared_manifest.py +564 -0
  202. flextool/plot_outputs/subplot_helpers.py +137 -0
  203. flextool/process_inputs/__init__.py +188 -0
  204. flextool/process_inputs/import_old_excel_input.json +4159 -0
  205. flextool/process_inputs/read_matpower.py +451 -0
  206. flextool/process_inputs/read_old_flextool.py +1288 -0
  207. flextool/process_inputs/read_self_describing_excel.py +1423 -0
  208. flextool/process_inputs/read_tabular_with_specification.py +1114 -0
  209. flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
  210. flextool/process_inputs/write_self_describing_to_db.py +977 -0
  211. flextool/process_inputs/write_to_input_db.py +269 -0
  212. flextool/process_outputs/__init__.py +7 -0
  213. flextool/process_outputs/_annualize.py +55 -0
  214. flextool/process_outputs/_inmemory_helpers.py +292 -0
  215. flextool/process_outputs/_output_meta.py +672 -0
  216. flextool/process_outputs/calc_capacity_flows.py +107 -0
  217. flextool/process_outputs/calc_connections.py +136 -0
  218. flextool/process_outputs/calc_costs.py +260 -0
  219. flextool/process_outputs/calc_group_flows.py +192 -0
  220. flextool/process_outputs/calc_slacks.py +103 -0
  221. flextool/process_outputs/calc_storage_vre.py +160 -0
  222. flextool/process_outputs/drop_levels.py +208 -0
  223. flextool/process_outputs/handoff_writers.py +1315 -0
  224. flextool/process_outputs/out_ancillary.py +544 -0
  225. flextool/process_outputs/out_capacity.py +179 -0
  226. flextool/process_outputs/out_costs.py +334 -0
  227. flextool/process_outputs/out_flowgroup.py +189 -0
  228. flextool/process_outputs/out_flows.py +301 -0
  229. flextool/process_outputs/out_group.py +475 -0
  230. flextool/process_outputs/out_node.py +190 -0
  231. flextool/process_outputs/persist_realized_slice.py +601 -0
  232. flextool/process_outputs/process_results.py +24 -0
  233. flextool/process_outputs/read_highs_solution.py +2256 -0
  234. flextool/process_outputs/read_parameters.py +1799 -0
  235. flextool/process_outputs/read_sets.py +1095 -0
  236. flextool/process_outputs/read_variables.py +553 -0
  237. flextool/process_outputs/solve_order.py +81 -0
  238. flextool/process_outputs/spinedb_replay.py +412 -0
  239. flextool/process_outputs/union_realized_slice.py +224 -0
  240. flextool/process_outputs/write_outputs.py +1286 -0
  241. flextool/process_outputs/write_spinedb.py +1267 -0
  242. flextool/representative_periods/__init__.py +5 -0
  243. flextool/representative_periods/clustering.py +165 -0
  244. flextool/representative_periods/force_include.py +563 -0
  245. flextool/representative_periods/netload.py +365 -0
  246. flextool/representative_periods/netload_inputs.py +345 -0
  247. flextool/representative_periods/netload_iterate.py +722 -0
  248. flextool/representative_periods/preprocess.py +948 -0
  249. flextool/representative_periods/scenario_stack.py +195 -0
  250. flextool/representative_periods/weights.py +124 -0
  251. flextool/scenario_comparison/__init__.py +13 -0
  252. flextool/scenario_comparison/config_builder.py +158 -0
  253. flextool/scenario_comparison/constants.py +20 -0
  254. flextool/scenario_comparison/data_models.py +222 -0
  255. flextool/scenario_comparison/db_reader.py +399 -0
  256. flextool/scenario_comparison/dispatch_data.py +1002 -0
  257. flextool/scenario_comparison/dispatch_mappings.py +205 -0
  258. flextool/scenario_comparison/dispatch_plots.py +691 -0
  259. flextool/scenario_comparison/input_entity_colors.py +319 -0
  260. flextool/scenario_comparison/orchestrator.py +453 -0
  261. flextool/scenario_comparison/plan_union.py +244 -0
  262. flextool/scenario_comparison/plot_settings_seed.py +205 -0
  263. flextool/schemas/AXIS_CONTRACT.md +71 -0
  264. flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
  265. flextool/schemas/canonical_databases/howto_connections.json +5606 -0
  266. flextool/schemas/canonical_databases/howto_demand.json +5518 -0
  267. flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
  268. flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
  269. flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
  270. flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
  271. flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
  272. flextool/schemas/canonical_databases/templates_examples.json +13532 -0
  273. flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
  274. flextool/schemas/comparison_settings_template.json +197 -0
  275. flextool/schemas/default_plot_settings.yaml +260 -0
  276. flextool/schemas/default_plots.yaml +2293 -0
  277. flextool/schemas/flextool_axis_contract.json +303 -0
  278. flextool/schemas/flextool_axis_contract.schema.json +247 -0
  279. flextool/schemas/old_flextool_import_template.json +4443 -0
  280. flextool/schemas/output_info_template.json +48 -0
  281. flextool/schemas/output_settings_template.json +256 -0
  282. flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
  283. flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
  284. flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
  285. flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
  286. flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
  287. flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
  288. flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
  289. flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
  290. flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
  291. flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
  292. flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
  293. flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
  294. flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
  295. flextool/schemas/spinedb_results_schema.json +581 -0
  296. flextool/schemas/spinedb_schema.json +4636 -0
  297. flextool/solver_config/copt.opt.template +18 -0
  298. flextool/solver_config/cplex.opt.template +25 -0
  299. flextool/solver_config/gurobi.opt.template +18 -0
  300. flextool/solver_config/highs.opt.template +18 -0
  301. flextool/solver_config/xpress.opt.template +26 -0
  302. flextool/spinedb_backend/__init__.py +26 -0
  303. flextool/spinedb_backend/_axis_enums.py +1119 -0
  304. flextool/spinedb_backend/_backend.py +1139 -0
  305. flextool/update_flextool/__init__.py +12 -0
  306. flextool/update_flextool/canonical_databases.py +251 -0
  307. flextool/update_flextool/db_migration.py +7108 -0
  308. flextool/update_flextool/ensure_settings_db.py +138 -0
  309. flextool/update_flextool/export_database.py +103 -0
  310. flextool/update_flextool/extend_tests_fixture.py +772 -0
  311. flextool/update_flextool/generate_canonical.py +274 -0
  312. flextool/update_flextool/initialize_database.py +42 -0
  313. flextool/update_flextool/install_info.py +225 -0
  314. flextool/update_flextool/self_update.py +464 -0
  315. flextool/update_flextool/sync_master_json_template.py +125 -0
  316. flextool/update_flextool/test_fixtures.py +187 -0
  317. flextool-4.0.0.dist-info/METADATA +217 -0
  318. flextool-4.0.0.dist-info/RECORD +322 -0
  319. flextool-4.0.0.dist-info/WHEEL +5 -0
  320. flextool-4.0.0.dist-info/entry_points.txt +17 -0
  321. flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
  322. flextool-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,543 @@
1
+ """PlotCanvas — embeds a matplotlib FigureCanvasTkAgg with navigation toolbar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import tkinter as tk
7
+ from pathlib import Path
8
+ from tkinter import ttk
9
+
10
+ import numpy as np
11
+
12
+ from flextool.gui.downsampling import downsample_for_display
13
+ from flextool.gui.plot_cache import PlotCache
14
+
15
+ try:
16
+ import matplotlib
17
+ if matplotlib.get_backend().lower() != "tkagg":
18
+ matplotlib.use("TkAgg")
19
+ except Exception:
20
+ pass
21
+
22
+ import matplotlib.image as mpimage
23
+ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
24
+ from matplotlib.figure import Figure
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Background color used to fill unused canvas area
29
+ _BG = "#f0f0f0"
30
+
31
+
32
+ class PlotCanvas(ttk.Frame):
33
+ """Embeds a matplotlib FigureCanvasTkAgg with navigation toolbar.
34
+
35
+ Matplotlib's own ``<Configure>`` handler is permanently disconnected.
36
+ Instead, this class manages figure sizing itself via a ``<Configure>``
37
+ binding on the *PlotCanvas frame*, which only fires on real window
38
+ resizes — not on internal button/style changes. This eliminates
39
+ jitter from the resize → draw → resize feedback loop.
40
+ """
41
+
42
+ def __init__(self, master: tk.Widget, **kwargs: object) -> None:
43
+ super().__init__(master, **kwargs)
44
+
45
+ self.columnconfigure(0, weight=1)
46
+ self.rowconfigure(0, weight=1)
47
+
48
+ self._raw_line_data: dict[tuple[int, int], tuple[np.ndarray, np.ndarray]] = {}
49
+ self._n_out: int = 3000
50
+ self._cache = PlotCache()
51
+ self._last_widget_size: tuple[int, int] = (0, 0)
52
+ self._resize_pending: str | None = None
53
+ self._natural_size_inches: tuple[float, float] = (6.0, 4.0)
54
+
55
+ # Render figures at the same DPI Tk uses for its own widget fonts,
56
+ # so a 10-pt figure label matches a 10-pt UI label (see
57
+ # _detect_system_dpi for the full rationale).
58
+ self._system_dpi: float = self._detect_system_dpi()
59
+
60
+ # ── Scrollable container ──────────────────────────────────
61
+ # A tk.Canvas with scrollbars wraps the matplotlib widget so
62
+ # that figures larger than the window can be scrolled.
63
+ self._scroll_canvas = tk.Canvas(self, background=_BG, highlightthickness=0)
64
+ self._vscroll = ttk.Scrollbar(self, orient="vertical",
65
+ command=self._scroll_canvas.yview)
66
+ self._hscroll = ttk.Scrollbar(self, orient="horizontal",
67
+ command=self._scroll_canvas.xview)
68
+ self._scroll_canvas.configure(
69
+ yscrollcommand=self._vscroll.set,
70
+ xscrollcommand=self._hscroll.set,
71
+ )
72
+ self._scroll_canvas.grid(row=0, column=0, sticky="nsew")
73
+ self._vscroll.grid(row=0, column=1, sticky="ns")
74
+ self._hscroll.grid(row=1, column=0, sticky="ew")
75
+ self.columnconfigure(0, weight=1)
76
+ self.columnconfigure(1, weight=0)
77
+ self.rowconfigure(0, weight=1)
78
+ self.rowconfigure(1, weight=0)
79
+
80
+ # Create a blank figure that fills the canvas with a solid bg
81
+ self._figure = Figure(facecolor=_BG)
82
+ self._canvas = FigureCanvasTkAgg(self._figure, master=self._scroll_canvas)
83
+ self._canvas_widget = self._canvas.get_tk_widget()
84
+ self._canvas_widget.configure(background=_BG)
85
+ # Place the matplotlib widget as a window on the scroll canvas
86
+ self._inner_window = self._scroll_canvas.create_window(
87
+ 0, 0, window=self._canvas_widget, anchor="nw",
88
+ )
89
+
90
+ # ── Permanently disconnect matplotlib's resize handling ──
91
+ # This prevents the <Configure> → resize() → draw_idle() →
92
+ # <Configure> feedback loop that causes jitter.
93
+ self._canvas_widget.unbind("<Configure>")
94
+ self._canvas_widget.unbind("<Map>")
95
+
96
+ # Instead, handle resizes ourselves on the PlotCanvas frame.
97
+ # This only fires on real geometry changes (window resize,
98
+ # paned-window sash), not on internal button style changes.
99
+ self.bind("<Configure>", self._on_frame_configure)
100
+
101
+ # Monkey-patch the canvas widget's configure to silently ignore
102
+ # width/height changes from matplotlib internals (draw/blit).
103
+ self._orig_tk_configure = self._canvas_widget.configure
104
+
105
+ def _no_resize_configure(**kw):
106
+ kw.pop("width", None)
107
+ kw.pop("height", None)
108
+ if kw:
109
+ self._orig_tk_configure(**kw)
110
+
111
+ self._canvas_widget.configure = _no_resize_configure # type: ignore[assignment]
112
+
113
+ # Mouse-wheel scrolling
114
+ self._scroll_canvas.bind("<Enter>", self._bind_mousewheel)
115
+ self._scroll_canvas.bind("<Leave>", self._unbind_mousewheel)
116
+
117
+ # NavigationToolbar2Tk calls pack() in its __init__, so it needs
118
+ # a dedicated container frame managed by grid in the outer layout.
119
+ toolbar_frame = ttk.Frame(self)
120
+ toolbar_frame.grid(row=2, column=0, columnspan=2, sticky="ew")
121
+ self._toolbar = NavigationToolbar2Tk(self._canvas, toolbar_frame)
122
+ self._toolbar.update()
123
+
124
+ # ── Hover tooltip for middle-truncated bar tick labels ──────
125
+ # plot_bars.py middle-ellipsises long category labels and stashes the
126
+ # full text on each axes (_flextool_bar_labels_full); reveal it when
127
+ # the cursor is over a truncated tick label.
128
+ self._tooltip_win: tk.Toplevel | None = None
129
+ self._tooltip_label: tk.Label | None = None
130
+ # Cached [(bbox, full_text)] for truncated tick labels, rebuilt lazily
131
+ # after each draw (None = needs rebuild).
132
+ self._label_hitmap: list[tuple[object, str]] | None = None
133
+ self._canvas.mpl_connect("motion_notify_event", self._on_motion_tooltip)
134
+ self._canvas.mpl_connect(
135
+ "figure_leave_event", lambda _e: self._hide_label_tooltip()
136
+ )
137
+
138
+ def _bind_mousewheel(self, event: tk.Event) -> None:
139
+ self._scroll_canvas.bind_all("<MouseWheel>", self._on_mousewheel)
140
+ self._scroll_canvas.bind_all("<Button-4>", self._on_mousewheel)
141
+ self._scroll_canvas.bind_all("<Button-5>", self._on_mousewheel)
142
+
143
+ def _unbind_mousewheel(self, event: tk.Event) -> None:
144
+ self._scroll_canvas.unbind_all("<MouseWheel>")
145
+ self._scroll_canvas.unbind_all("<Button-4>")
146
+ self._scroll_canvas.unbind_all("<Button-5>")
147
+
148
+ def _detect_system_dpi(self) -> float:
149
+ """Return the DPI to render figures at, matched to Tk's font DPI.
150
+
151
+ The figure bitmap is blitted 1:1 into the Tk canvas, so for figure
152
+ text to match the surrounding widget text both must use the same
153
+ pixels-per-point. Tk renders fonts at ``tk scaling`` px/pt, i.e.
154
+ ``winfo_fpixels('1i')`` px/inch — so we render the figure at that
155
+ same DPI.
156
+
157
+ The previous code pinned the figure to the raw OS system DPI
158
+ (``GetDpiForSystem`` on Windows, e.g. 144 at 150% scaling). But
159
+ ``apply_dpi_scaling`` deliberately keeps ``tk scaling`` lower than
160
+ the geometric system value, so the figure ended up rendered at a
161
+ *higher* DPI than the rest of the UI and its text/axes looked
162
+ ~1.5x oversized next to the Tk widgets. Tying both to
163
+ ``winfo_fpixels`` removes that decoupling at any scaling factor.
164
+
165
+ macOS is left on matplotlib's default: Tk uses a 72-DPI logical
166
+ space there and handles Retina at the backing-store level, so
167
+ ``winfo_fpixels`` would under-report.
168
+ """
169
+ import sys
170
+ if sys.platform == "darwin":
171
+ return matplotlib.rcParams.get("figure.dpi", 100)
172
+ try:
173
+ dpi = float(self.tk.call("winfo", "fpixels", self._w, "1i"))
174
+ if dpi > 0:
175
+ return dpi
176
+ except (tk.TclError, AttributeError):
177
+ pass
178
+ return matplotlib.rcParams.get("figure.dpi", 100)
179
+
180
+ # X11 pixmaps are limited to 32767 px per side (signed 16-bit). At
181
+ # high DPI on tall figures (many subplots × long timeline) we can
182
+ # easily blow past that, causing a BadValue on X_CreatePixmap.
183
+ # Stay safely under the limit.
184
+ _MAX_PIXMAP_PX = 32000
185
+
186
+ def _apply_system_dpi(self, fig: Figure) -> None:
187
+ """Set the figure DPI to the system DPI, capped to keep the
188
+ rendered pixmap under X11's 32767-px-per-side limit.
189
+
190
+ Rescales rendering so point-sized fonts appear at correct
191
+ physical size on high-DPI screens. For oversized figures
192
+ (typically many-subplot single-scenario plots with a long
193
+ timeline) the DPI is reduced just enough to avoid the X
194
+ server's BadValue on CreatePixmap.
195
+ """
196
+ if not self._system_dpi:
197
+ return
198
+ target_dpi = float(self._system_dpi)
199
+ try:
200
+ w_in, h_in = fig.get_size_inches()
201
+ except Exception:
202
+ w_in = h_in = 0.0
203
+ # Floor first for legibility, then cap by the pixmap limit — the
204
+ # cap must win, since exceeding it crashes the X server with
205
+ # BadValue on X_CreatePixmap, while sub-50-DPI is merely fuzzy.
206
+ target_dpi = max(50.0, target_dpi)
207
+ if w_in and w_in * target_dpi > self._MAX_PIXMAP_PX:
208
+ target_dpi = self._MAX_PIXMAP_PX / w_in
209
+ if h_in and h_in * target_dpi > self._MAX_PIXMAP_PX:
210
+ target_dpi = self._MAX_PIXMAP_PX / h_in
211
+ if fig.get_dpi() != target_dpi:
212
+ fig.set_dpi(target_dpi)
213
+
214
+ def _on_mousewheel(self, event: tk.Event) -> None:
215
+ # Linux uses Button-4/5, Windows/Mac uses MouseWheel.
216
+ # Skip scrolling when the content already fits the viewport —
217
+ # otherwise the figure visibly drifts under the wheel even
218
+ # though no scrollbar is shown.
219
+ try:
220
+ top, bottom = self._scroll_canvas.yview()
221
+ except tk.TclError:
222
+ return
223
+ if top <= 0.0 and bottom >= 1.0:
224
+ return
225
+ if event.num == 4:
226
+ self._scroll_canvas.yview_scroll(-3, "units")
227
+ elif event.num == 5:
228
+ self._scroll_canvas.yview_scroll(3, "units")
229
+ elif event.delta:
230
+ self._scroll_canvas.yview_scroll(-event.delta // 120, "units")
231
+
232
+ # ------------------------------------------------------------------
233
+ # Resize handling (replaces matplotlib's <Configure> handler)
234
+ # ------------------------------------------------------------------
235
+
236
+ def _on_frame_configure(self, event: tk.Event) -> None:
237
+ """Handle real geometry changes with debouncing.
238
+
239
+ Schedules a single redraw after 50ms of no further resize events,
240
+ so dragging a window edge doesn't trigger dozens of redraws.
241
+ """
242
+ if self._resize_pending is not None:
243
+ self.after_cancel(self._resize_pending)
244
+ self._resize_pending = self.after(50, self._do_resize)
245
+
246
+ def _do_resize(self) -> None:
247
+ """Re-render figure at natural size and update scrollbars."""
248
+ self._resize_pending = None
249
+ w = self._scroll_canvas.winfo_width()
250
+ h = self._scroll_canvas.winfo_height()
251
+ if w < 2 or h < 2:
252
+ return
253
+ self._apply_system_dpi(self._figure)
254
+ self._size_and_draw()
255
+ # Invalidate PNG cache if the size changed significantly
256
+ old_w, old_h = self._last_widget_size
257
+ if abs(w - old_w) > 4 or abs(h - old_h) > 4:
258
+ self._cache.clear()
259
+ self._last_widget_size = (w, h)
260
+
261
+ def _size_and_draw(self) -> None:
262
+ """Set the figure to its natural pixel size, update scroll region, draw."""
263
+ self._label_hitmap = None # tick-label geometry changes on redraw
264
+ self._hide_label_tooltip()
265
+ dpi = self._figure.get_dpi() or 100
266
+ nat_w, nat_h = self._natural_size_inches
267
+ self._figure.set_size_inches(nat_w, nat_h, forward=False)
268
+ fig_w = int(nat_w * dpi)
269
+ fig_h = int(nat_h * dpi)
270
+
271
+ try:
272
+ # Resize the matplotlib widget and its internal PhotoImage
273
+ self._orig_tk_configure(width=fig_w, height=fig_h)
274
+ self._canvas._tkcanvas.delete(self._canvas._tkcanvas_image_region)
275
+ self._canvas._tkphoto.configure(width=fig_w, height=fig_h)
276
+ self._canvas._tkcanvas_image_region = (
277
+ self._canvas._tkcanvas.create_image(
278
+ 0, 0, anchor="nw", image=self._canvas._tkphoto,
279
+ )
280
+ )
281
+ self._scroll_canvas.configure(scrollregion=(0, 0, fig_w, fig_h))
282
+ except (AttributeError, tk.TclError):
283
+ pass
284
+
285
+ # Reset the toolbar's nav stack for the new figure
286
+ self._toolbar.update()
287
+ self._cancel_pending_draws()
288
+ self._canvas.draw()
289
+ self._cancel_pending_draws()
290
+
291
+ # Show/hide scrollbars based on whether content exceeds viewport
292
+ w = self._scroll_canvas.winfo_width()
293
+ h = self._scroll_canvas.winfo_height()
294
+ self._vscroll.grid() if fig_h > h else self._vscroll.grid_remove()
295
+ self._hscroll.grid() if fig_w > w else self._hscroll.grid_remove()
296
+
297
+ # Scroll to top-left
298
+ self._scroll_canvas.xview_moveto(0)
299
+ self._scroll_canvas.yview_moveto(0)
300
+
301
+ # ------------------------------------------------------------------
302
+ # Figure display
303
+ # ------------------------------------------------------------------
304
+
305
+ def display_figure(self, fig: Figure) -> None:
306
+ """Display a matplotlib Figure at its natural size.
307
+
308
+ Scrollbars appear when the figure exceeds the visible area.
309
+ Only one ``draw()`` call is made — no jitter, no redundant renders.
310
+ """
311
+ if fig is self._figure:
312
+ self._canvas.draw()
313
+ return
314
+
315
+ fig.set_facecolor(_BG)
316
+ # Remember the figure's designed size for resize handling
317
+ self._natural_size_inches = tuple(fig.get_size_inches())
318
+ # Render at system DPI so fonts match the OS scaling
319
+ self._apply_system_dpi(fig)
320
+
321
+ self._figure = fig
322
+ self._canvas.figure = fig
323
+ fig.set_canvas(self._canvas)
324
+
325
+ self._size_and_draw()
326
+
327
+ def _cancel_pending_draws(self) -> None:
328
+ """Cancel any ``after_idle(draw)`` scheduled by ``draw_idle()``."""
329
+ idle_id = getattr(self._canvas, "_idle_draw_id", None)
330
+ if idle_id is not None:
331
+ self._canvas_widget.after_cancel(idle_id)
332
+ self._canvas._idle_draw_id = None
333
+
334
+ def display_png(self, png_path: Path) -> None:
335
+ """Load and display a PNG file at its natural resolution.
336
+
337
+ Uses the cache — switching back to a previously viewed PNG is
338
+ instant. If the image is larger than the canvas it is scaled
339
+ down (keeping aspect ratio); otherwise shown at 1:1 pixels.
340
+ """
341
+ cache_key = ("png", str(png_path))
342
+
343
+ cached = self._cache.get(cache_key)
344
+ if cached is not None:
345
+ self.display_figure(cached)
346
+ return
347
+
348
+ try:
349
+ img = mpimage.imread(str(png_path))
350
+ except Exception:
351
+ logger.exception("Failed to load PNG: %s", png_path)
352
+ self.show_message(f"Failed to load image:\n{png_path.name}")
353
+ return
354
+
355
+ img_h, img_w = img.shape[:2]
356
+
357
+ w_px = self._scroll_canvas.winfo_width()
358
+ h_px = self._scroll_canvas.winfo_height()
359
+ widget_w = max(w_px, 100)
360
+ widget_h = max(h_px, 100)
361
+ self._last_widget_size = (widget_w, widget_h)
362
+
363
+ # Display at natural size — scrollbars appear if needed
364
+ disp_w = img_w
365
+ disp_h = img_h
366
+
367
+ # Place the image at top-left inside a figure sized to the image.
368
+ # Use system DPI so the image is rendered at the correct scale.
369
+ dpi = self._system_dpi or 100
370
+ fig = Figure(figsize=(disp_w / dpi, disp_h / dpi), dpi=dpi)
371
+ ax = fig.add_axes([0, 0, 1, 1])
372
+ ax.imshow(img, interpolation="nearest")
373
+ ax.set_axis_off()
374
+
375
+ self._cache.put(cache_key, fig)
376
+ self.display_figure(fig)
377
+
378
+ def show_message(self, text: str) -> None:
379
+ """Display a text message (e.g., 'No data available')."""
380
+ fig = Figure()
381
+ ax = fig.add_subplot(111)
382
+ ax.text(
383
+ 0.5, 0.5, text,
384
+ transform=ax.transAxes,
385
+ ha="center", va="center",
386
+ fontsize=14, color="grey",
387
+ )
388
+ ax.set_axis_off()
389
+ self.display_figure(fig)
390
+
391
+ # ------------------------------------------------------------------
392
+ # Downsampling for time series
393
+ # ------------------------------------------------------------------
394
+
395
+ def display_timeseries_figure(
396
+ self, fig: Figure, n_out: int = 3000
397
+ ) -> None:
398
+ """Display a time-series Figure with downsampling support."""
399
+ self._raw_line_data = {}
400
+ self._n_out = n_out
401
+
402
+ for ax_idx, ax in enumerate(fig.axes):
403
+ for line_idx, line in enumerate(ax.get_lines()):
404
+ x_full = np.asarray(line.get_xdata(), dtype=np.float64)
405
+ y_full = np.asarray(line.get_ydata(), dtype=np.float64)
406
+ self._raw_line_data[(ax_idx, line_idx)] = (x_full, y_full)
407
+
408
+ x_ds, y_ds = downsample_for_display(x_full, y_full, n_out)
409
+ line.set_xdata(x_ds)
410
+ line.set_ydata(y_ds)
411
+
412
+ ax.callbacks.connect("xlim_changed", self._on_xlim_changed)
413
+
414
+ self.display_figure(fig)
415
+
416
+ def _on_xlim_changed(self, ax) -> None: # type: ignore[override]
417
+ """Re-downsample visible data when the user zooms or pans."""
418
+ if not self._raw_line_data:
419
+ return
420
+
421
+ try:
422
+ ax_idx = list(self._figure.axes).index(ax)
423
+ except ValueError:
424
+ return
425
+
426
+ lo, hi = ax.get_xlim()
427
+
428
+ for line_idx, line in enumerate(ax.get_lines()):
429
+ key = (ax_idx, line_idx)
430
+ if key not in self._raw_line_data:
431
+ continue
432
+ x_full, y_full = self._raw_line_data[key]
433
+
434
+ mask = (x_full >= lo) & (x_full <= hi)
435
+ x_vis = x_full[mask]
436
+ y_vis = y_full[mask]
437
+
438
+ if len(x_vis) == 0:
439
+ continue
440
+
441
+ x_ds, y_ds = downsample_for_display(x_vis, y_vis, self._n_out)
442
+ line.set_xdata(x_ds)
443
+ line.set_ydata(y_ds)
444
+
445
+ self._canvas.draw_idle()
446
+
447
+ # ------------------------------------------------------------------
448
+ # Full-name hover tooltip for middle-truncated bar tick labels
449
+ # ------------------------------------------------------------------
450
+
451
+ def _build_label_hitmap(self) -> list[tuple[object, str]]:
452
+ """Collect display-space bboxes of *truncated* tick labels + full text.
453
+
454
+ Only labels whose drawn text differs from the stashed full text are
455
+ included, so plots without truncation produce an empty hitmap (and
456
+ never show a tooltip).
457
+ """
458
+ hitmap: list[tuple[object, str]] = []
459
+ try:
460
+ renderer = self._canvas.get_renderer()
461
+ except Exception:
462
+ return hitmap
463
+ for ax in self._figure.axes:
464
+ full = getattr(ax, "_flextool_bar_labels_full", None)
465
+ if not full:
466
+ continue
467
+ orient = getattr(ax, "_flextool_bar_orientation", "horizontal")
468
+ ticklabels = (ax.get_yticklabels() if orient == "horizontal"
469
+ else ax.get_xticklabels())
470
+ for i, artist in enumerate(ticklabels):
471
+ if i >= len(full):
472
+ break
473
+ if artist.get_text() == full[i]:
474
+ continue # not truncated → nothing to reveal
475
+ try:
476
+ bbox = artist.get_window_extent(renderer)
477
+ except Exception:
478
+ continue
479
+ hitmap.append((bbox, full[i]))
480
+ return hitmap
481
+
482
+ def _on_motion_tooltip(self, event) -> None:
483
+ """Show the full label while hovering a truncated tick label."""
484
+ if event is None or event.x is None or event.y is None:
485
+ self._hide_label_tooltip()
486
+ return
487
+ if self._label_hitmap is None:
488
+ self._label_hitmap = self._build_label_hitmap()
489
+ for bbox, full_text in self._label_hitmap:
490
+ try:
491
+ hit = bool(bbox.contains(event.x, event.y))
492
+ except Exception:
493
+ hit = False
494
+ if hit:
495
+ self._show_label_tooltip(full_text)
496
+ return
497
+ self._hide_label_tooltip()
498
+
499
+ def _show_label_tooltip(self, text: str) -> None:
500
+ if self._tooltip_win is None:
501
+ self._tooltip_win = tk.Toplevel(self)
502
+ self._tooltip_win.wm_overrideredirect(True)
503
+ try:
504
+ self._tooltip_win.attributes("-topmost", True)
505
+ except tk.TclError:
506
+ pass
507
+ self._tooltip_label = tk.Label(
508
+ self._tooltip_win, justify="left", background="#ffffe0",
509
+ relief="solid", borderwidth=1, font="TkTooltipFont",
510
+ )
511
+ self._tooltip_label.pack()
512
+ if self._tooltip_label.cget("text") != text:
513
+ self._tooltip_label.configure(text=text)
514
+ x = self.winfo_pointerx() + 14
515
+ y = self.winfo_pointery() + 12
516
+ self._tooltip_win.wm_geometry(f"+{x}+{y}")
517
+ self._tooltip_win.deiconify()
518
+
519
+ def _hide_label_tooltip(self) -> None:
520
+ if self._tooltip_win is not None:
521
+ try:
522
+ self._tooltip_win.withdraw()
523
+ except tk.TclError:
524
+ pass
525
+
526
+ def cleanup(self) -> None:
527
+ """Release matplotlib resources held by this canvas."""
528
+ self._raw_line_data.clear()
529
+ self._cache.clear()
530
+ self._label_hitmap = None
531
+ if self._tooltip_win is not None:
532
+ try:
533
+ self._tooltip_win.destroy()
534
+ except tk.TclError:
535
+ pass
536
+ self._tooltip_win = None
537
+ self._tooltip_label = None
538
+
539
+ def clear(self) -> None:
540
+ """Clear the display."""
541
+ self._raw_line_data = {}
542
+ self._figure.clear()
543
+ self._canvas.draw_idle()