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,159 @@
1
+ """Non-modal log window for output action subprocesses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import tkinter as tk
7
+ import tkinter.font as tkfont
8
+ from tkinter import ttk
9
+ from typing import Callable
10
+
11
+
12
+ class OutputLogWindow(tk.Toplevel):
13
+ """Non-modal window that displays the command and live stdout of a subprocess.
14
+
15
+ The window stays open after the process finishes so the user can
16
+ review the output. Two buttons are provided:
17
+
18
+ * **Close** -- closes the window without stopping the process.
19
+ * **Stop and close** -- kills the process and closes the window.
20
+ """
21
+
22
+ def __init__(
23
+ self, parent: tk.Misc, title: str, on_abort: Callable[[], None] | None = None,
24
+ ) -> None:
25
+ super().__init__(parent)
26
+ self.title(title)
27
+ self.transient(parent)
28
+
29
+ self._process: subprocess.Popen | None = None
30
+ self._on_abort = on_abort
31
+ self._aborted = False
32
+
33
+ # ── Font metrics ─────────────────────────────────────────
34
+ mono_font = tkfont.nametofont("TkFixedFont")
35
+ default_font = tkfont.nametofont("TkDefaultFont")
36
+ cw = default_font.measure("0")
37
+ lh = default_font.metrics("linespace")
38
+
39
+ # ── Window sizing ────────────────────────────────────────
40
+ screen_w = self.winfo_screenwidth()
41
+ screen_h = self.winfo_screenheight()
42
+ taskbar_margin = lh * 4
43
+ win_h = screen_h - taskbar_margin
44
+ win_w = 1800
45
+ x = max(0, screen_w - win_w)
46
+ self.geometry(f"{win_w}x{win_h}+{x}+0")
47
+ self.minsize(cw * 50, lh * 10)
48
+
49
+ self.columnconfigure(0, weight=1)
50
+ self.rowconfigure(0, weight=1)
51
+
52
+ # ── Text widget ─────────────────────────────────────────
53
+ text_frame = ttk.Frame(self)
54
+ text_frame.grid(row=0, column=0, sticky="nsew", padx=8, pady=(8, 4))
55
+ text_frame.columnconfigure(0, weight=1)
56
+ text_frame.rowconfigure(0, weight=1)
57
+
58
+ self._text = tk.Text(text_frame, wrap="word", font=mono_font)
59
+ self._text.grid(row=0, column=0, sticky="nsew")
60
+ self._text.bind("<Key>", self._on_key_press)
61
+
62
+ vscroll = ttk.Scrollbar(text_frame, orient="vertical", command=self._text.yview)
63
+ self._text.configure(yscrollcommand=vscroll.set)
64
+ vscroll.grid(row=0, column=1, sticky="ns")
65
+
66
+ # ── Buttons ──────────────────────────────────────────────
67
+ btn_frame = ttk.Frame(self, padding=(8, 4, 8, 8))
68
+ btn_frame.grid(row=1, column=0, sticky="ew")
69
+
70
+ if on_abort is not None:
71
+ self._stop_close_btn = ttk.Button(
72
+ btn_frame, text="Abort", command=self._on_abort_clicked,
73
+ )
74
+ else:
75
+ self._stop_close_btn = ttk.Button(
76
+ btn_frame, text="Stop and close", command=self._on_stop_and_close,
77
+ )
78
+ self._stop_close_btn.pack(side="left", padx=(0, 10))
79
+
80
+ self._close_btn = ttk.Button(
81
+ btn_frame, text="Close", command=self._on_close,
82
+ )
83
+ self._close_btn.pack(side="right")
84
+
85
+ if on_abort is not None:
86
+ self.protocol("WM_DELETE_WINDOW", self._on_abort_clicked)
87
+ else:
88
+ self.protocol("WM_DELETE_WINDOW", self._on_close)
89
+
90
+ # ── Public API ───────────────────────────────────────────────
91
+
92
+ def set_process(self, proc: subprocess.Popen) -> None:
93
+ """Register the subprocess so Stop can kill it."""
94
+ self._process = proc
95
+
96
+ def append_line(self, line: str) -> None:
97
+ """Append a line of text. Must be called from the main thread."""
98
+ if not self.winfo_exists():
99
+ return
100
+ at_bottom = self._text.yview()[1] >= 0.99
101
+ self._text.insert("end", line + "\n")
102
+ if at_bottom:
103
+ self._text.see("end")
104
+
105
+ def mark_finished(self, success: bool) -> None:
106
+ """Mark the process as finished — update button states."""
107
+ if not self.winfo_exists():
108
+ return
109
+ self._process = None
110
+ self._stop_close_btn.configure(state="disabled")
111
+
112
+ @property
113
+ def is_aborted(self) -> bool:
114
+ """Whether the user clicked Abort."""
115
+ return self._aborted
116
+
117
+ def minimize(self) -> None:
118
+ """Minimize the window to the taskbar.
119
+
120
+ Transient windows cannot be iconified, so we first remove the
121
+ transient binding, then iconify normally.
122
+ """
123
+ self.transient('') # remove transient parent → gets own taskbar entry
124
+ self.iconify()
125
+
126
+ # ── Event handlers ───────────────────────────────────────────
127
+
128
+ def _on_key_press(self, event: tk.Event) -> str | None: # type: ignore[type-arg]
129
+ """Allow Ctrl+C/A but block other input."""
130
+ if event.state & 0x4 and event.keysym in ("c", "C", "a", "A"):
131
+ return None
132
+ return "break"
133
+
134
+ def _on_close(self) -> None:
135
+ """Close the window without stopping the process."""
136
+ self.destroy()
137
+
138
+ def _on_abort_clicked(self) -> None:
139
+ """Kill the process, signal abort, and close the window."""
140
+ if self._process is not None:
141
+ try:
142
+ self._process.kill()
143
+ except OSError:
144
+ pass
145
+ self._process = None
146
+ self._aborted = True
147
+ if self._on_abort is not None:
148
+ self._on_abort()
149
+ self.destroy()
150
+
151
+ def _on_stop_and_close(self) -> None:
152
+ """Kill the process and close the window."""
153
+ if self._process is not None:
154
+ try:
155
+ self._process.kill()
156
+ except OSError:
157
+ pass
158
+ self._process = None
159
+ self.destroy()
@@ -0,0 +1,421 @@
1
+ """Cross-platform helper functions for opening files and launching external tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ import tkinter as tk
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _clean_env() -> dict[str, str]:
20
+ """Return a copy of os.environ without Python virtualenv variables.
21
+
22
+ LibreOffice and other desktop apps bundle their own Python and crash
23
+ (std::bad_alloc) if they inherit PYTHONPATH/VIRTUAL_ENV from the
24
+ parent process. This only affects the child process — the current
25
+ Python process keeps its full environment.
26
+ """
27
+ env = {
28
+ k: v for k, v in os.environ.items()
29
+ if k not in ("PYTHONPATH", "VIRTUAL_ENV", "VIRTUAL_ENV_DISABLE_PROMPT",
30
+ "PYTHONHOME", "CONDA_PREFIX", "CONDA_DEFAULT_ENV")
31
+ }
32
+ if "VIRTUAL_ENV" in os.environ:
33
+ venv_bin = os.path.join(os.environ["VIRTUAL_ENV"], "bin")
34
+ env["PATH"] = os.pathsep.join(
35
+ p for p in os.environ.get("PATH", "").split(os.pathsep)
36
+ if p != venv_bin
37
+ )
38
+ return env
39
+
40
+
41
+ def open_file_in_default_app(filepath: Path) -> None:
42
+ """Open a file using the OS default application.
43
+
44
+ Windows: os.startfile
45
+ macOS: subprocess.Popen(['open', str(filepath)])
46
+ Linux: subprocess.Popen(['xdg-open', str(filepath)])
47
+
48
+ Raises:
49
+ OSError: If the file does not exist or the platform command fails.
50
+ """
51
+ filepath = Path(filepath)
52
+ if not filepath.exists():
53
+ raise OSError(f"File does not exist: {filepath}")
54
+
55
+ if sys.platform == "win32":
56
+ os.startfile(str(filepath)) # type: ignore[attr-defined]
57
+ elif sys.platform == "darwin":
58
+ subprocess.Popen(["open", str(filepath)])
59
+ else:
60
+ subprocess.Popen(
61
+ ["xdg-open", str(filepath)],
62
+ start_new_session=True,
63
+ env=_clean_env(),
64
+ stdout=subprocess.DEVNULL,
65
+ stderr=subprocess.DEVNULL,
66
+ )
67
+
68
+
69
+ def open_folder(dirpath: Path) -> None:
70
+ """Open a folder in the OS file manager.
71
+
72
+ Windows: os.startfile
73
+ macOS: subprocess.Popen(['open', str(dirpath)])
74
+ Linux: subprocess.Popen(['xdg-open', str(dirpath)])
75
+
76
+ Raises:
77
+ OSError: If the directory does not exist.
78
+ """
79
+ dirpath = Path(dirpath)
80
+ if not dirpath.is_dir():
81
+ raise OSError(f"Directory does not exist: {dirpath}")
82
+
83
+ if sys.platform == "win32":
84
+ os.startfile(str(dirpath)) # type: ignore[attr-defined]
85
+ elif sys.platform == "darwin":
86
+ subprocess.Popen(["open", str(dirpath)])
87
+ else:
88
+ subprocess.Popen(
89
+ ["xdg-open", str(dirpath)],
90
+ start_new_session=True,
91
+ env=_clean_env(),
92
+ stdout=subprocess.DEVNULL,
93
+ stderr=subprocess.DEVNULL,
94
+ )
95
+
96
+
97
+ def open_spine_db_editor(db_url: str) -> subprocess.Popen | None:
98
+ """Launch spine-db-editor as a subprocess.
99
+
100
+ Command: ``spine-db-editor <db_url>``
101
+
102
+ Returns:
103
+ The :class:`subprocess.Popen` object, or ``None`` if
104
+ ``spine-db-editor`` is not found on the system PATH.
105
+ """
106
+ exe = shutil.which("spine-db-editor")
107
+ if exe is None:
108
+ logger.warning("spine-db-editor not found on PATH")
109
+ return None
110
+
111
+ try:
112
+ proc = subprocess.Popen([exe, db_url])
113
+ return proc
114
+ except OSError:
115
+ logger.warning("Failed to launch spine-db-editor", exc_info=True)
116
+ return None
117
+
118
+
119
+ def set_process_dpi_awareness() -> None:
120
+ """Mark the process DPI-aware on Windows, before the first Tk window.
121
+
122
+ MUST be called **before** the Tk root (HWND) exists — Windows only
123
+ honours ``SetProcessDpiAwareness`` while the process owns no windows.
124
+ Called afterwards it is a silent no-op, leaving the process DPI-
125
+ *unaware*: the OS then hands Tk a virtualized, bitmap-upscaled (blurry)
126
+ screen and lies about its size. Setting awareness here makes Tk render
127
+ at native pixels and report the true DPI, which ``apply_dpi_scaling``
128
+ then converts into the matching ``tk scaling``. No-op off Windows
129
+ (macOS handles Retina internally; Linux scales via ``tk scaling``).
130
+ """
131
+ if sys.platform != "win32":
132
+ return
133
+ try:
134
+ import ctypes
135
+ # 1 = PROCESS_SYSTEM_DPI_AWARE.
136
+ ctypes.windll.shcore.SetProcessDpiAwareness(1) # type: ignore[attr-defined]
137
+ except Exception:
138
+ pass
139
+
140
+
141
+ def apply_dpi_scaling(root: tk.Tk) -> float:
142
+ """Detect the OS display scale factor and apply it to tkinter.
143
+
144
+ Must be called **before** any widgets or themes are created so that
145
+ default fonts (and everything derived from them) pick up the correct
146
+ size.
147
+
148
+ Resolution order:
149
+
150
+ 1. ``FLEXTOOL_DPI`` environment variable — explicit user override
151
+ (e.g. ``FLEXTOOL_DPI=144``) for setups where auto-detection fails.
152
+ 2. On Windows, ``ctypes`` queries the system DPI directly (the process
153
+ must already be DPI-aware via :func:`set_process_dpi_awareness`,
154
+ called before the Tk root existed) and sets ``tk scaling`` to
155
+ ``dpi / 72`` so fonts match the OS scale.
156
+ 3. On Linux/X11, ``Xft.dpi`` (set by GNOME/KDE/Xfce) is the most
157
+ reliable hint, followed by ``GDK_SCALE`` and finally an
158
+ ``xrandr``-derived DPI. The xrandr fallback uses the monitor
159
+ under the cursor at startup (which is where the WM will open
160
+ the window on every desktop I've tested), and falls back to
161
+ the primary monitor when the cursor's monitor reports a clearly
162
+ bogus physical size (e.g. virtual / mirrored displays often
163
+ report 0mm or copy a real monitor's dimensions verbatim).
164
+ 4. On macOS, Tk handles Retina internally — nothing to do.
165
+
166
+ Once-at-startup is intentional. ``tk scaling`` doesn't re-flow
167
+ widgets that are already laid out, so changing it mid-session
168
+ would only partially update the UI.
169
+
170
+ A best-effort per-monitor handler in :class:`MainWindow` reapplies
171
+ ``setup_fonts`` when ``winfo_fpixels('1i')`` swings by more than 10 %,
172
+ so text rescales on monitor changes; widget positions still need a
173
+ restart to fully re-flow.
174
+
175
+ Returns:
176
+ The ratio by which tk scaling was increased (1.0 means no change).
177
+ Use this to rescale theme fonts that use hardcoded pixel sizes.
178
+ """
179
+ if sys.platform == "darwin":
180
+ return 1.0
181
+
182
+ dpi: float | None = None
183
+
184
+ # Explicit override always wins.
185
+ flextool_dpi = os.environ.get("FLEXTOOL_DPI")
186
+ if flextool_dpi:
187
+ try:
188
+ dpi = float(flextool_dpi)
189
+ except ValueError:
190
+ pass
191
+
192
+ if dpi is None and sys.platform == "win32":
193
+ # Awareness is set earlier (set_process_dpi_awareness, before the Tk
194
+ # root existed); here we only read the now-truthful system DPI.
195
+ try:
196
+ import ctypes
197
+ dpi = ctypes.windll.user32.GetDpiForSystem() # type: ignore[attr-defined]
198
+ except Exception:
199
+ pass
200
+ elif dpi is None:
201
+ # Linux / X11: try xrdb for Xft.dpi (set by GNOME, KDE, Xfce)
202
+ try:
203
+ out = subprocess.check_output(
204
+ ["xrdb", "-query"], text=True, timeout=2, stderr=subprocess.DEVNULL,
205
+ )
206
+ for line in out.splitlines():
207
+ if line.startswith("Xft.dpi:"):
208
+ dpi = float(line.split(":", 1)[1].strip())
209
+ break
210
+ except Exception:
211
+ pass
212
+ # Fallback: GDK_SCALE environment variable (integer scale factor)
213
+ if dpi is None:
214
+ gdk_scale = os.environ.get("GDK_SCALE")
215
+ if gdk_scale:
216
+ try:
217
+ dpi = 96.0 * float(gdk_scale)
218
+ except ValueError:
219
+ pass
220
+ # Last resort: ask xrandr which monitor the cursor is on and
221
+ # use that monitor's DPI. Falls through to primary if the
222
+ # cursor's monitor is suspect (mirrored / virtual / headless).
223
+ if dpi is None:
224
+ try:
225
+ cursor_x, cursor_y = root.winfo_pointerxy()
226
+ except Exception:
227
+ cursor_x, cursor_y = (None, None)
228
+ dpi = _xrandr_dpi_for_window(cursor_x, cursor_y)
229
+
230
+ if dpi is not None and dpi > 0:
231
+ current_scaling = float(root.tk.call("tk", "scaling"))
232
+ if sys.platform == "win32":
233
+ # Pin ``tk scaling`` to the documented pixels-per-point for the
234
+ # real DPI. When the process is DPI-aware Tk usually pre-sets
235
+ # this to the same value already (so the call is a harmless
236
+ # no-op); when it doesn't (or ``FLEXTOOL_DPI`` overrides), this
237
+ # is what makes point-sized fonts track the OS scale instead of
238
+ # rendering tiny. Either way the result is deterministic, not
239
+ # dependent on Tk's startup guess.
240
+ #
241
+ # The factor we RETURN is NOT ``new/current``: on a DPI-aware
242
+ # box ``current`` already equals ``new`` (Tk read the true DPI),
243
+ # so that ratio collapses to ~1.0 and ``scale_theme_fonts`` would
244
+ # never touch sv_ttk's pixel-sized fonts — they'd stay frozen at
245
+ # their 96-DPI baseline while every point font doubled (tiny body
246
+ # text + huge headings on hi-DPI). Return the *absolute* factor
247
+ # that lifts those 96-DPI-baseline pixel fonts to the current DPI
248
+ # (``dpi/96``), so the pixel and point worlds scale together.
249
+ new_scaling = dpi / 72.0
250
+ root.tk.call("tk", "scaling", new_scaling)
251
+ return new_scaling / (96.0 / 72.0)
252
+ # Linux/X11: desktop apps treat 96 DPI as "scale = 1.0", and a
253
+ # 0.85 fudge keeps sv_ttk's heavier metrics in line with
254
+ # surrounding Qt/GTK content. Only raise scaling, never shrink it
255
+ # below tk's default on systems that already got it right.
256
+ new_scaling = (dpi / 96.0) * 0.85
257
+ if new_scaling > current_scaling:
258
+ root.tk.call("tk", "scaling", new_scaling)
259
+ return new_scaling / current_scaling
260
+ return 1.0
261
+
262
+
263
+ def log_dpi_diagnostics(root: tk.Tk) -> None:
264
+ """Log ground-truth DPI / font-scaling values for support diagnosis.
265
+
266
+ Call this once at startup AFTER the theme and fonts are configured (so
267
+ the theme's own fonts exist). Emitted at WARNING so the single line lands
268
+ in ``flextool_gui.log`` regardless of the configured level.
269
+
270
+ The point is to stop guessing: it reveals the real display DPI, whether
271
+ the process is genuinely DPI-aware, the effective ``tk scaling``, and —
272
+ crucially — that the sv_ttk theme paints widgets with PIXEL-sized fonts
273
+ (negative ``size``) that ``tk scaling`` cannot affect. That distinguishes
274
+ a 100%-scale no-op from a silent DPI-awareness failure in one run.
275
+ """
276
+ import tkinter.font as tkfont
277
+ parts: list[str] = []
278
+
279
+ if sys.platform == "win32":
280
+ try:
281
+ import ctypes
282
+ try:
283
+ aw = ctypes.c_int(-1)
284
+ ctypes.windll.shcore.GetProcessDpiAwareness( # type: ignore[attr-defined]
285
+ None, ctypes.byref(aw),
286
+ )
287
+ awareness = aw.value # 0=unaware 1=system 2=per-monitor
288
+ except Exception:
289
+ awareness = -1
290
+ try:
291
+ sysdpi: object = ctypes.windll.user32.GetDpiForSystem() # type: ignore[attr-defined]
292
+ except Exception:
293
+ sysdpi = "err"
294
+ parts.append(f"awareness={awareness}(0=unaware,1=system,2=permon)")
295
+ parts.append(f"GetDpiForSystem={sysdpi}")
296
+ except Exception:
297
+ parts.append("win_dpi=err")
298
+
299
+ for label, getter in (
300
+ ("tk_scaling", lambda: f"{float(root.tk.call('tk', 'scaling')):.4f}"),
301
+ ("fpixels_1i", lambda: f"{float(root.winfo_fpixels('1i')):.1f}"),
302
+ ("screen", lambda: f"{root.winfo_screenwidth()}x{root.winfo_screenheight()}"),
303
+ ):
304
+ try:
305
+ parts.append(f"{label}={getter()}")
306
+ except Exception:
307
+ pass
308
+
309
+ for name in ("TkDefaultFont", "TkHeadingFont", "TkFixedFont"):
310
+ try:
311
+ sz = tkfont.nametofont(name).cget("size")
312
+ parts.append(f"{name}={sz}{'pt' if sz > 0 else 'px'}")
313
+ except Exception:
314
+ pass
315
+ try:
316
+ sv = tkfont.Font(root=root, name="SunValleyBodyFont", exists=True)
317
+ parts.append(f"SunValleyBodyFont={sv.cget('size')}px(scaling-immune)")
318
+ except Exception:
319
+ parts.append("SunValleyBodyFont=absent")
320
+
321
+ parts.append(f"FLEXTOOL_DPI={os.environ.get('FLEXTOOL_DPI', '<auto>')}")
322
+ logger.warning("DPI-DIAG: %s", " ".join(parts))
323
+
324
+
325
+ def _xrandr_monitors() -> list[dict]:
326
+ """Parse all connected monitors from xrandr.
327
+
328
+ Returns a list of dicts with keys: name, primary, x, y, w, h,
329
+ mm_w, mm_h, dpi. Empty list if xrandr is unavailable.
330
+ """
331
+ import re
332
+ try:
333
+ out = subprocess.check_output(
334
+ ["xrandr"], text=True, timeout=2, stderr=subprocess.DEVNULL,
335
+ )
336
+ except Exception:
337
+ return []
338
+ monitors: list[dict] = []
339
+ # Match e.g. "DP-0 connected primary 3840x2160+1920+0 ... 700mm x 390mm"
340
+ pattern = re.compile(
341
+ r"^(\S+)\s+connected\s+(primary\s+)?(\d+)x(\d+)\+(\d+)\+(\d+)"
342
+ r".*?(\d+)mm\s+x\s+(\d+)mm",
343
+ )
344
+ for line in out.splitlines():
345
+ m = pattern.match(line)
346
+ if not m:
347
+ continue
348
+ name = m.group(1)
349
+ primary = bool(m.group(2))
350
+ w, h, x, y = int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6))
351
+ mm_w, mm_h = int(m.group(7)), int(m.group(8))
352
+ dpi = (w / mm_w) * 25.4 if mm_w > 0 else 0.0
353
+ monitors.append({
354
+ "name": name, "primary": primary,
355
+ "x": x, "y": y, "w": w, "h": h,
356
+ "mm_w": mm_w, "mm_h": mm_h, "dpi": dpi,
357
+ })
358
+ return monitors
359
+
360
+
361
+ def _xrandr_dpi_for_window(x: int | None, y: int | None) -> float | None:
362
+ """DPI for the monitor at (x, y); falls back to primary when sensible.
363
+
364
+ A monitor is "suspect" when its physical-size readings are 0
365
+ (virtual / mirrored / headless) or when its derived DPI is < 80
366
+ (impossibly low — typically a virtual buffer claiming a real
367
+ monitor's mm dimensions but at a fraction of the resolution).
368
+ Suspect monitors are skipped in favour of the primary or the
369
+ highest-DPI real monitor.
370
+ """
371
+ monitors = _xrandr_monitors()
372
+ if not monitors:
373
+ return None
374
+
375
+ def is_real(m: dict) -> bool:
376
+ return m["mm_w"] > 0 and m["dpi"] >= 80.0
377
+
378
+ chosen = None
379
+ if x is not None and y is not None:
380
+ for m in monitors:
381
+ if (m["x"] <= x < m["x"] + m["w"]
382
+ and m["y"] <= y < m["y"] + m["h"]):
383
+ chosen = m
384
+ break
385
+ if chosen is not None and is_real(chosen):
386
+ return chosen["dpi"]
387
+
388
+ primary = next((m for m in monitors if m["primary"]), None)
389
+ if primary is not None and is_real(primary):
390
+ return primary["dpi"]
391
+
392
+ real = [m for m in monitors if is_real(m)]
393
+ if real:
394
+ # Pick the highest-DPI real monitor as the safest default —
395
+ # users on hi-DPI displays would rather have slightly oversized
396
+ # widgets than unreadable ones.
397
+ return max(real, key=lambda m: m["dpi"])["dpi"]
398
+
399
+ return None
400
+
401
+
402
+ def scale_theme_fonts(root: tk.Tk, factor: float) -> None:
403
+ """Rescale hardcoded theme fonts (e.g. sv_ttk's SunValley* fonts).
404
+
405
+ sv_ttk defines its own fonts with absolute pixel sizes that do not
406
+ respond to ``tk scaling``. This function finds all ``SunValley*``
407
+ fonts and multiplies their size by *factor*.
408
+
409
+ Must be called **after** ``sv_ttk.set_theme()`` (which creates the
410
+ fonts).
411
+ """
412
+ if factor <= 1.0:
413
+ return
414
+ import tkinter.font as tkfont
415
+
416
+ for name in tkfont.names(root):
417
+ if name.startswith("SunValley"):
418
+ font = tkfont.Font(root=root, name=name, exists=True)
419
+ current_size = font.cget("size")
420
+ # Negative size = pixels, positive = points; scale either way
421
+ font.configure(size=round(current_size * factor))
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict
4
+
5
+ import matplotlib.pyplot as plt
6
+ from matplotlib.figure import Figure
7
+
8
+
9
+ def _figure_size_bytes(fig: Figure) -> int:
10
+ """Estimate memory usage of a matplotlib Figure in bytes."""
11
+ try:
12
+ renderer = fig.canvas.get_renderer()
13
+ # Rendered bitmap size: width * height * 4 (RGBA)
14
+ w, h = int(renderer.width), int(renderer.height)
15
+ return w * h * 4
16
+ except Exception:
17
+ pass
18
+ # Fallback: estimate from figure size in inches * dpi
19
+ dpi = fig.get_dpi() or 100
20
+ w_in, h_in = fig.get_size_inches()
21
+ return int(w_in * dpi) * int(h_in * dpi) * 4
22
+
23
+
24
+ class PlotCache:
25
+ """Memory-bounded LRU cache for matplotlib Figure objects.
26
+
27
+ *max_bytes* sets the memory limit (default 0.5 GB). Figures are
28
+ evicted oldest-first when the total estimated memory exceeds the
29
+ limit.
30
+ """
31
+
32
+ def __init__(self, max_bytes: int = 512 * 1024 * 1024):
33
+ self._max_bytes = max_bytes
34
+ self._cache: OrderedDict[tuple, Figure] = OrderedDict()
35
+ self._sizes: dict[tuple, int] = {}
36
+ self._total_bytes: int = 0
37
+
38
+ @property
39
+ def max_gb(self) -> float:
40
+ """Current limit in GB."""
41
+ return self._max_bytes / (1024 ** 3)
42
+
43
+ @max_gb.setter
44
+ def max_gb(self, value: float) -> None:
45
+ """Set limit in GB and evict if necessary."""
46
+ self._max_bytes = int(value * 1024 ** 3)
47
+ self._evict_to_limit()
48
+
49
+ def get(self, key: tuple) -> Figure | None:
50
+ if key in self._cache:
51
+ self._cache.move_to_end(key)
52
+ return self._cache[key]
53
+ return None
54
+
55
+ def put(self, key: tuple, fig: Figure) -> None:
56
+ fig_bytes = _figure_size_bytes(fig)
57
+
58
+ if key in self._cache:
59
+ # Update existing entry
60
+ old_bytes = self._sizes.get(key, 0)
61
+ self._total_bytes -= old_bytes
62
+ self._cache.move_to_end(key)
63
+ self._cache[key] = fig
64
+ self._sizes[key] = fig_bytes
65
+ self._total_bytes += fig_bytes
66
+ else:
67
+ self._cache[key] = fig
68
+ self._sizes[key] = fig_bytes
69
+ self._total_bytes += fig_bytes
70
+
71
+ self._evict_to_limit()
72
+
73
+ def _evict_to_limit(self) -> None:
74
+ """Evict oldest entries until total size is within the limit."""
75
+ while self._total_bytes > self._max_bytes and self._cache:
76
+ evicted_key, evicted_fig = self._cache.popitem(last=False)
77
+ evicted_bytes = self._sizes.pop(evicted_key, 0)
78
+ self._total_bytes -= evicted_bytes
79
+ evicted_fig.clf()
80
+ plt.close(evicted_fig)
81
+
82
+ def clear(self) -> None:
83
+ for fig in self._cache.values():
84
+ fig.clf()
85
+ plt.close(fig)
86
+ self._cache.clear()
87
+ self._sizes.clear()
88
+ self._total_bytes = 0