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,299 @@
1
+ """Modal dialog for picking parameter_group names to include in an export.
2
+
3
+ Used by :class:`AddDialog` when the user adds an empty FlexTool input
4
+ Excel: the dialog shows every parameter_group from the source DB as a
5
+ checkbox row, with the *required* groups highlighted at the top and a
6
+ hover tooltip explaining why they matter.
7
+
8
+ Theming
9
+ -------
10
+ The dialog reads the active ttk theme's background/foreground colours
11
+ (via :class:`ttk.Style`) and applies them to the plain ``tk`` widgets it
12
+ uses for the row list, so it stays readable under both light and dark
13
+ themes — the rest of the FlexTool GUI uses the same pattern.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import tkinter as tk
18
+ from tkinter import messagebox, ttk
19
+ from typing import Sequence
20
+
21
+ from flextool.gui.hover_tooltip import attach_tooltip
22
+
23
+
24
+ _REQUIRED_TOOLTIP = (
25
+ "Required for a functioning FlexTool model.\n"
26
+ "You can still uncheck this — a working model can be assembled by "
27
+ "combining data from multiple input sources in Spine Toolbox."
28
+ )
29
+
30
+ # Highlight tints chosen to read well on top of light and dark theme
31
+ # surfaces. Both keep enough luminance for the forced-black text on
32
+ # required rows to stay legible.
33
+ _HIGHLIGHT_LIGHT = "#fff4c2"
34
+ _HIGHLIGHT_DARK = "#e8d488"
35
+
36
+
37
+ class GroupPickerDialog(tk.Toplevel):
38
+ """Modal picker for parameter_group selection.
39
+
40
+ After the dialog closes, ``self.result`` is one of:
41
+
42
+ * ``None`` — user cancelled, do not export.
43
+ * ``"all"`` — every group was checked; caller should pass
44
+ ``include_groups=None`` to ``export_to_excel`` (preserves the
45
+ original unfiltered semantics, including any ungrouped params).
46
+ * ``list[str]`` — the explicitly selected group names.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ parent: tk.Misc,
52
+ groups: Sequence[dict],
53
+ required: Sequence[str],
54
+ ) -> None:
55
+ super().__init__(parent)
56
+ self.title("Choose parameter groups")
57
+ self.transient(parent)
58
+ self.grab_set()
59
+ self.resizable(False, True)
60
+
61
+ self.result: list[str] | str | None = None
62
+
63
+ # Pull theme colours so plain tk widgets match dark / light mode.
64
+ style = ttk.Style(self)
65
+ self._theme_bg = (
66
+ style.lookup("TFrame", "background")
67
+ or self.cget("background")
68
+ )
69
+ self._theme_fg = style.lookup("TLabel", "foreground") or "black"
70
+ self._highlight_bg = (
71
+ _HIGHLIGHT_DARK
72
+ if _is_dark_color(self, self._theme_bg)
73
+ else _HIGHLIGHT_LIGHT
74
+ )
75
+
76
+ # Build display order: required first (in YAML order), then
77
+ # remaining groups sorted by priority then name.
78
+ by_name = {g["name"]: g for g in groups}
79
+ required_present = [name for name in required if name in by_name]
80
+ required_set = set(required_present)
81
+ remaining = [g for g in groups if g["name"] not in required_set]
82
+ remaining.sort(key=lambda g: (g.get("priority") or 999, g["name"]))
83
+ ordered: list[dict] = [by_name[n] for n in required_present] + remaining
84
+
85
+ self._vars: dict[str, tk.BooleanVar] = {
86
+ g["name"]: tk.BooleanVar(value=True) for g in ordered
87
+ }
88
+ self._all_names: list[str] = [g["name"] for g in ordered]
89
+
90
+ self._build_ui(ordered, required_set)
91
+
92
+ self.protocol("WM_DELETE_WINDOW", self._on_cancel)
93
+ self._center_on_parent(parent)
94
+ parent.wait_window(self)
95
+
96
+ # ------------------------------------------------------------------
97
+ # UI construction
98
+ # ------------------------------------------------------------------
99
+
100
+ def _build_ui(
101
+ self, ordered: list[dict], required_set: set[str],
102
+ ) -> None:
103
+ # Outer container — ttk.Frame inherits theme automatically.
104
+ outer = ttk.Frame(self, padding=10)
105
+ outer.pack(fill="both", expand=True)
106
+
107
+ intro = ttk.Label(
108
+ outer,
109
+ text=(
110
+ "Select which parameter groups to include in the new Excel "
111
+ "file.\nHighlighted rows are required for a functioning "
112
+ "FlexTool model."
113
+ ),
114
+ justify="left",
115
+ )
116
+ intro.pack(anchor="w", pady=(0, 8))
117
+
118
+ # Bulk action row
119
+ bulk = ttk.Frame(outer)
120
+ bulk.pack(fill="x", pady=(0, 6))
121
+ ttk.Button(
122
+ bulk, text="Select all", command=self._on_select_all,
123
+ ).pack(side="left", padx=(0, 4))
124
+ ttk.Button(
125
+ bulk, text="Required only",
126
+ command=lambda: self._on_required_only(required_set),
127
+ ).pack(side="left", padx=(0, 4))
128
+ ttk.Button(
129
+ bulk, text="Clear all", command=self._on_clear_all,
130
+ ).pack(side="left")
131
+
132
+ # Scrollable group list — uses tk.Frame/Canvas so we can colour
133
+ # the highlight rows. Background pulled from the ttk theme.
134
+ list_frame = tk.Frame(
135
+ outer, bg=self._theme_bg, highlightthickness=1,
136
+ highlightbackground="#888888",
137
+ )
138
+ list_frame.pack(fill="both", expand=True)
139
+
140
+ # Canvas size derived from font metrics so the dialog scales with
141
+ # the user's font size instead of being pinned at 360×570 px.
142
+ from flextool.gui.ui_metrics import get_metrics
143
+ _m = get_metrics(self)
144
+ canvas = tk.Canvas(
145
+ list_frame, bg=self._theme_bg, highlightthickness=0,
146
+ width=_m.cw * 36, height=_m.lh * 30,
147
+ )
148
+ canvas.pack(side="left", fill="both", expand=True)
149
+
150
+ sb = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
151
+ sb.pack(side="right", fill="y")
152
+ canvas.configure(yscrollcommand=sb.set)
153
+
154
+ rows_frame = tk.Frame(canvas, bg=self._theme_bg)
155
+ canvas.create_window((0, 0), window=rows_frame, anchor="nw")
156
+
157
+ def _on_rows_configure(_event: tk.Event) -> None: # type: ignore[type-arg]
158
+ canvas.configure(scrollregion=canvas.bbox("all"))
159
+
160
+ rows_frame.bind("<Configure>", _on_rows_configure)
161
+
162
+ # Mouse wheel scroll
163
+ def _on_mousewheel(event: tk.Event) -> None: # type: ignore[type-arg]
164
+ canvas.yview_scroll(-int(event.delta / 120), "units")
165
+
166
+ canvas.bind("<Enter>", lambda _e: canvas.bind_all("<MouseWheel>", _on_mousewheel))
167
+ canvas.bind("<Leave>", lambda _e: canvas.unbind_all("<MouseWheel>"))
168
+
169
+ # Render rows
170
+ for g in ordered:
171
+ self._make_row(rows_frame, g, g["name"] in required_set)
172
+
173
+ # OK / Cancel
174
+ btn_row = ttk.Frame(outer)
175
+ btn_row.pack(fill="x", pady=(10, 0))
176
+ ttk.Button(btn_row, text="Cancel", command=self._on_cancel).pack(side="right")
177
+ ttk.Button(btn_row, text="OK", command=self._on_ok).pack(side="right", padx=(0, 6))
178
+
179
+ def _make_row(
180
+ self, parent: tk.Widget, group: dict, is_required: bool,
181
+ ) -> None:
182
+ bg = self._highlight_bg if is_required else self._theme_bg
183
+ # On highlight rows we also override the foreground so text stays
184
+ # readable on the yellow tint regardless of the parent theme.
185
+ fg = "#000000" if is_required else self._theme_fg
186
+
187
+ row = tk.Frame(parent, bg=bg)
188
+ row.pack(fill="x")
189
+
190
+ # Color swatch
191
+ swatch_color = _normalise_color(group.get("color"))
192
+ swatch_frame = tk.Frame(row, bg=bg, width=22, height=22)
193
+ swatch_frame.pack_propagate(False)
194
+ swatch_frame.pack(side="left", padx=(8, 6), pady=2)
195
+ swatch = tk.Frame(
196
+ swatch_frame, bg=swatch_color or "#cccccc",
197
+ highlightthickness=1, highlightbackground="#888888",
198
+ )
199
+ swatch.place(x=4, y=4, width=14, height=14)
200
+
201
+ cb = tk.Checkbutton(
202
+ row,
203
+ text=group["name"],
204
+ variable=self._vars[group["name"]],
205
+ bg=bg, fg=fg,
206
+ activebackground=bg, activeforeground=fg,
207
+ selectcolor=bg, # checkbox indicator interior
208
+ highlightthickness=0,
209
+ anchor="w",
210
+ )
211
+ cb.pack(side="left", fill="x", expand=True, padx=(0, 8))
212
+
213
+ if is_required:
214
+ attach_tooltip(row, _REQUIRED_TOOLTIP)
215
+ attach_tooltip(cb, _REQUIRED_TOOLTIP)
216
+ attach_tooltip(swatch, _REQUIRED_TOOLTIP)
217
+
218
+ # ------------------------------------------------------------------
219
+ # Button handlers
220
+ # ------------------------------------------------------------------
221
+
222
+ def _on_select_all(self) -> None:
223
+ for v in self._vars.values():
224
+ v.set(True)
225
+
226
+ def _on_clear_all(self) -> None:
227
+ for v in self._vars.values():
228
+ v.set(False)
229
+
230
+ def _on_required_only(self, required_set: set[str]) -> None:
231
+ for name, v in self._vars.items():
232
+ v.set(name in required_set)
233
+
234
+ def _on_ok(self) -> None:
235
+ selected = [n for n in self._all_names if self._vars[n].get()]
236
+ if not selected:
237
+ messagebox.showwarning(
238
+ "No groups selected",
239
+ "Select at least one group, or press Cancel.",
240
+ parent=self,
241
+ )
242
+ return
243
+ if len(selected) == len(self._all_names):
244
+ self.result = "all"
245
+ else:
246
+ self.result = selected
247
+ self.destroy()
248
+
249
+ def _on_cancel(self) -> None:
250
+ self.result = None
251
+ self.destroy()
252
+
253
+ # ------------------------------------------------------------------
254
+ # Layout helpers
255
+ # ------------------------------------------------------------------
256
+
257
+ def _center_on_parent(self, parent: tk.Misc) -> None:
258
+ self.update_idletasks()
259
+ try:
260
+ pw = parent.winfo_width()
261
+ ph = parent.winfo_height()
262
+ px = parent.winfo_rootx()
263
+ py = parent.winfo_rooty()
264
+ except tk.TclError:
265
+ return
266
+ dw = self.winfo_width()
267
+ dh = self.winfo_height()
268
+ self.geometry(f"+{px + (pw - dw) // 2}+{py + (ph - dh) // 2}")
269
+
270
+
271
+ def _normalise_color(value: str | None) -> str | None:
272
+ """Coerce a hex colour string to '#rrggbb' form for Tk, else None."""
273
+ if not value:
274
+ return None
275
+ s = str(value).strip()
276
+ if not s:
277
+ return None
278
+ if s.startswith("#"):
279
+ return s
280
+ if len(s) in (6, 8) and all(c in "0123456789abcdefABCDEF" for c in s):
281
+ return f"#{s[:6]}"
282
+ return None
283
+
284
+
285
+ def _is_dark_color(widget: tk.Widget, color: str) -> bool:
286
+ """Return True when *color* is a dark surface (so we should use a
287
+ dark-theme highlight tint).
288
+
289
+ Uses Tk's built-in colour parser so it accepts hex, rgb tuples,
290
+ and X11 / system colour names. Falls back to "light" on parse
291
+ failure.
292
+ """
293
+ try:
294
+ r, g, b = widget.winfo_rgb(color)
295
+ except tk.TclError:
296
+ return False
297
+ # winfo_rgb returns 16-bit channels (0..65535).
298
+ luminance = 0.299 * r + 0.587 * g + 0.114 * b
299
+ return luminance < 32768
@@ -0,0 +1,106 @@
1
+ """Consent dialog for migrating externally-referenced FlexTool databases."""
2
+ from __future__ import annotations
3
+
4
+ import tkinter as tk
5
+ from pathlib import Path
6
+ from tkinter import ttk
7
+ from typing import Literal
8
+
9
+
10
+ def ask_external_migration_consent(
11
+ parent: tk.Misc,
12
+ external_files: list[tuple[str, Path, int, int]],
13
+ ) -> Literal["in_place", "copy_to_project", "cancel"]:
14
+ """Ask the user how to migrate external (non-project) input source files.
15
+
16
+ ``external_files`` is a list of ``(source_name, abs_path,
17
+ current_version, target_version)`` tuples; all entries share the
18
+ same target version.
19
+
20
+ Returns:
21
+ ``"in_place"`` to migrate the external files where they live,
22
+ ``"copy_to_project"`` to copy them into the project folder and
23
+ migrate the copies, or ``"cancel"`` to abort.
24
+ """
25
+ target_version = external_files[0][3]
26
+
27
+ dlg = tk.Toplevel(parent)
28
+ dlg.title("External database migration")
29
+ dlg.transient(parent)
30
+ dlg.grab_set()
31
+ dlg.resizable(False, False)
32
+
33
+ result: Literal["in_place", "copy_to_project", "cancel"] = "cancel"
34
+
35
+ msg = (
36
+ "The following input source file(s) are stored OUTSIDE the "
37
+ f"project folder and need to be migrated to FlexTool DB "
38
+ f"version {target_version}:"
39
+ )
40
+ lbl = ttk.Label(dlg, text=msg, wraplength=480, justify="left")
41
+ lbl.pack(padx=16, pady=(16, 8), anchor="w")
42
+
43
+ list_frame = ttk.Frame(dlg, borderwidth=1, relief="sunken")
44
+ list_frame.pack(padx=16, pady=(0, 8), fill="x")
45
+
46
+ height = min(6, len(external_files))
47
+ listbox = tk.Listbox(list_frame, height=height, activestyle="none")
48
+ h_scroll = ttk.Scrollbar(
49
+ list_frame, orient="horizontal", command=listbox.xview,
50
+ )
51
+ listbox.configure(xscrollcommand=h_scroll.set)
52
+
53
+ for source_name, abs_path, current_version, _t in external_files:
54
+ listbox.insert(
55
+ "end",
56
+ f"{source_name} — {abs_path} "
57
+ f"(v{current_version} → v{target_version})",
58
+ )
59
+
60
+ listbox.pack(side="top", fill="x")
61
+ h_scroll.pack(side="bottom", fill="x")
62
+
63
+ note = ttk.Label(
64
+ dlg,
65
+ text="Migration modifies the file in place. Choose how to proceed:",
66
+ wraplength=480,
67
+ justify="left",
68
+ )
69
+ note.pack(padx=16, pady=(0, 8), anchor="w")
70
+
71
+ btn_frame = ttk.Frame(dlg)
72
+ btn_frame.pack(padx=16, pady=(4, 16))
73
+
74
+ def _choose(choice: Literal["in_place", "copy_to_project", "cancel"]) -> None:
75
+ nonlocal result
76
+ result = choice
77
+ dlg.destroy()
78
+
79
+ ttk.Button(
80
+ btn_frame, text="Migrate in place",
81
+ command=lambda: _choose("in_place"),
82
+ ).pack(side="left", padx=4)
83
+ ttk.Button(
84
+ btn_frame, text="Copy all to project and migrate",
85
+ command=lambda: _choose("copy_to_project"),
86
+ ).pack(side="left", padx=4)
87
+ ttk.Button(
88
+ btn_frame, text="Cancel",
89
+ command=lambda: _choose("cancel"),
90
+ ).pack(side="left", padx=4)
91
+
92
+ dlg.protocol("WM_DELETE_WINDOW", lambda: _choose("cancel"))
93
+ dlg.bind("<Escape>", lambda _e: _choose("cancel"))
94
+
95
+ # Center on parent
96
+ dlg.update_idletasks()
97
+ pw = parent.winfo_width()
98
+ ph = parent.winfo_height()
99
+ px = parent.winfo_rootx()
100
+ py = parent.winfo_rooty()
101
+ dw = dlg.winfo_width()
102
+ dh = dlg.winfo_height()
103
+ dlg.geometry(f"+{px + (pw - dw) // 2}+{py + (ph - dh) // 2}")
104
+
105
+ parent.wait_window(dlg)
106
+ return result
@@ -0,0 +1,237 @@
1
+ """Modal progress dialog for the automatic FlexTool DB migration."""
2
+ from __future__ import annotations
3
+
4
+ import queue
5
+ import tkinter as tk
6
+ from tkinter import ttk
7
+
8
+ # Mirrors flextool.gui.main_window._SPINNER_FRAMES; duplicated to keep this
9
+ # dialog free of dependencies on main_window.
10
+ _SPINNER_FRAMES = ["◐", "◓", "◑", "◒"] # rotating circle (Geometric Shapes; render on Windows Tk, unlike the old U+29D6/7 hourglasses)
11
+
12
+
13
+ class MigrationProgressDialog(tk.Toplevel):
14
+ """Modal progress dialog for the automatic DB migration.
15
+
16
+ The caller runs the migration in a worker thread. This dialog drives
17
+ the spinner from the Tk main thread via ``after`` and exposes:
18
+
19
+ - ``update_status(text: str)`` — thread-safe; schedules a label
20
+ update on the main thread.
21
+ - ``mark_finished()`` — thread-safe; schedules dialog close.
22
+ - ``cancel_requested`` — read-only ``bool`` property; True after
23
+ the user clicked Cancel (worker polls this via ``cancel_check``).
24
+
25
+ The dialog is shown by constructing it and then calling
26
+ ``parent.wait_window(self)``. It does not spawn the worker thread
27
+ itself — the caller is responsible for that.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ parent: tk.Misc,
33
+ title: str = "Migrating database…",
34
+ initial_status: str = "Preparing…",
35
+ ) -> None:
36
+ super().__init__(parent)
37
+ self.title(title)
38
+ self.transient(parent)
39
+ self.resizable(False, False)
40
+
41
+ self._cancel_requested: bool = False
42
+ self._finished: bool = False
43
+ # The ``after`` ids must be read/written from the main thread only.
44
+ self._spinner_after_id: str | None = None
45
+ self._pump_after_id: str | None = None
46
+ self._spinner_idx: int = 0
47
+ # Worker threads enqueue updates here; a main-thread pump applies them.
48
+ # tkinter is not thread-safe (even ``after`` fails off the main thread
49
+ # on macOS), so the public methods must not touch Tk.
50
+ self._ui_queue: queue.Queue = queue.Queue()
51
+
52
+ self._status_var = tk.StringVar(value=initial_status)
53
+ status_lbl = ttk.Label(
54
+ self,
55
+ textvariable=self._status_var,
56
+ wraplength=440,
57
+ justify="left",
58
+ )
59
+ status_lbl.pack(padx=16, pady=(16, 8), anchor="w", fill="x")
60
+
61
+ self._spinner_var = tk.StringVar(value=_SPINNER_FRAMES[0])
62
+ spinner_lbl = ttk.Label(
63
+ self,
64
+ textvariable=self._spinner_var,
65
+ font=("TkDefaultFont", 16),
66
+ justify="left",
67
+ )
68
+ spinner_lbl.pack(padx=16, pady=(0, 8), anchor="w")
69
+
70
+ note_lbl = ttk.Label(
71
+ self,
72
+ text=(
73
+ "The interface is locked until the migration finishes. "
74
+ "Detailed progress is shown in the Execution window. "
75
+ "Click Cancel to stop after the current step — a cancelled "
76
+ "database is restored to its original state."
77
+ ),
78
+ wraplength=440,
79
+ justify="left",
80
+ )
81
+ note_lbl.pack(padx=16, pady=(0, 8), anchor="w", fill="x")
82
+
83
+ btn_frame = ttk.Frame(self)
84
+ btn_frame.pack(padx=16, pady=(4, 16), fill="x")
85
+
86
+ self._cancel_btn = ttk.Button(
87
+ btn_frame, text="Cancel", command=self._on_cancel,
88
+ )
89
+ self._cancel_btn.pack(side="right")
90
+
91
+ self.protocol("WM_DELETE_WINDOW", self._on_cancel)
92
+ self.bind("<Escape>", lambda _e: self._on_cancel())
93
+
94
+ # Center on parent
95
+ self.update_idletasks()
96
+ pw = parent.winfo_width()
97
+ ph = parent.winfo_height()
98
+ px = parent.winfo_rootx()
99
+ py = parent.winfo_rooty()
100
+ dw = self.winfo_width()
101
+ dh = self.winfo_height()
102
+ self.geometry(f"+{px + (pw - dw) // 2}+{py + (ph - dh) // 2}")
103
+
104
+ # Kick off the spinner animation and the worker-update pump.
105
+ self._spinner_after_id = self.after(200, self._tick_spinner)
106
+ self._pump_after_id = self.after(50, self._pump_queue)
107
+
108
+ # Grab input once the window is actually mapped. ``grab_set`` raises
109
+ # "grab failed: window not viewable" if called before Tk has mapped
110
+ # the Toplevel — which happens when this dialog is constructed from
111
+ # inside another Tk callback (e.g. the project-combo selection).
112
+ self._install_grab()
113
+
114
+ def _install_grab(self) -> None:
115
+ """Grab input once the window is viewable, retrying on the main thread."""
116
+ if self._finished or not self.winfo_exists():
117
+ return
118
+ if self.winfo_viewable():
119
+ try:
120
+ self.grab_set()
121
+ except tk.TclError:
122
+ pass
123
+ return
124
+ self.after(50, self._install_grab)
125
+
126
+ # ── Public API (thread-safe wrappers) ───────────────────────────
127
+
128
+ @property
129
+ def cancel_requested(self) -> bool:
130
+ """Whether the user has requested cancellation."""
131
+ return self._cancel_requested
132
+
133
+ def update_status(self, text: str) -> None:
134
+ """Thread-safe: enqueue a status-label update (no Tk call here)."""
135
+ self._ui_queue.put(("status", text))
136
+
137
+ def mark_finished(self) -> None:
138
+ """Thread-safe: enqueue dialog close (no Tk call here)."""
139
+ self._ui_queue.put(("finish", None))
140
+
141
+ # ── Main-thread helpers ─────────────────────────────────────────
142
+
143
+ def _pump_queue(self) -> None:
144
+ """Apply queued worker updates on the main thread, then reschedule.
145
+
146
+ Drains the whole queue each tick, applying only the most recent status
147
+ (intermediate ones are stale) and closing if a finish was requested.
148
+ """
149
+ if self._finished:
150
+ return
151
+ latest_status: str | None = None
152
+ finish = False
153
+ try:
154
+ while True:
155
+ kind, payload = self._ui_queue.get_nowait()
156
+ if kind == "status":
157
+ latest_status = payload
158
+ elif kind == "finish":
159
+ finish = True
160
+ except queue.Empty:
161
+ pass
162
+
163
+ if latest_status is not None:
164
+ self._set_status(latest_status)
165
+ if finish:
166
+ self._finish()
167
+ return
168
+ self._pump_after_id = self.after(50, self._pump_queue)
169
+
170
+ def _set_status(self, text: str) -> None:
171
+ if self._finished:
172
+ return
173
+ try:
174
+ self._status_var.set(text)
175
+ except tk.TclError:
176
+ pass
177
+
178
+ def _finish(self) -> None:
179
+ if self._finished:
180
+ return
181
+ self._finished = True
182
+ if self._spinner_after_id is not None:
183
+ try:
184
+ self.after_cancel(self._spinner_after_id)
185
+ except tk.TclError:
186
+ pass
187
+ self._spinner_after_id = None
188
+ if self._pump_after_id is not None:
189
+ try:
190
+ self.after_cancel(self._pump_after_id)
191
+ except tk.TclError:
192
+ pass
193
+ self._pump_after_id = None
194
+ try:
195
+ self.grab_release()
196
+ except tk.TclError:
197
+ pass
198
+ try:
199
+ self.destroy()
200
+ except tk.TclError:
201
+ pass
202
+
203
+ def _tick_spinner(self) -> None:
204
+ if self._finished:
205
+ return
206
+ self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER_FRAMES)
207
+ try:
208
+ self._spinner_var.set(_SPINNER_FRAMES[self._spinner_idx])
209
+ except tk.TclError:
210
+ return
211
+ self._spinner_after_id = self.after(200, self._tick_spinner)
212
+
213
+ def _on_cancel(self) -> None:
214
+ if self._cancel_requested:
215
+ return
216
+ self._cancel_requested = True
217
+ try:
218
+ self._cancel_btn.configure(
219
+ state="disabled", text="Cancelling…",
220
+ )
221
+ except tk.TclError:
222
+ pass
223
+
224
+ def destroy(self) -> None: # noqa: D401 - override for cleanup
225
+ if self._spinner_after_id is not None:
226
+ try:
227
+ self.after_cancel(self._spinner_after_id)
228
+ except tk.TclError:
229
+ pass
230
+ self._spinner_after_id = None
231
+ if self._pump_after_id is not None:
232
+ try:
233
+ self.after_cancel(self._pump_after_id)
234
+ except tk.TclError:
235
+ pass
236
+ self._pump_after_id = None
237
+ super().destroy()