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,360 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from flextool.gui.data_models import (
9
+ ExecutionLimits,
10
+ GlobalSettings,
11
+ PlotSettings,
12
+ ProjectSettings,
13
+ ScenarioRun,
14
+ SourceRecord,
15
+ ViewerSettings,
16
+ )
17
+
18
+ SETTINGS_FILENAME = "settings.yaml"
19
+ GLOBAL_SETTINGS_FILENAME = "projects.yaml"
20
+
21
+
22
+ def _as_geometry_map(raw: object) -> dict[str, str]:
23
+ """Normalise a saved geometry field into a {signature -> geometry} map.
24
+
25
+ Accepts the new dict form, or a legacy plain string (from before
26
+ per-monitor-configuration memory) which is migrated under the
27
+ ``"legacy"`` key — used as a layout-agnostic fallback until the window
28
+ is next closed and re-saved under a concrete signature. Anything else
29
+ yields an empty map.
30
+ """
31
+ if isinstance(raw, dict):
32
+ return {str(k): str(v) for k, v in raw.items() if isinstance(v, str) and v}
33
+ if isinstance(raw, str) and raw:
34
+ return {"legacy": raw}
35
+ return {}
36
+
37
+
38
+ def load_project_settings(project_path: Path) -> ProjectSettings:
39
+ """Load project settings from settings.yaml in the project directory.
40
+
41
+ Returns defaults if the file does not exist or cannot be parsed.
42
+ """
43
+ settings_file = project_path / SETTINGS_FILENAME
44
+ if not settings_file.exists():
45
+ return ProjectSettings()
46
+
47
+ try:
48
+ with open(settings_file, "r", encoding="utf-8") as f:
49
+ data = yaml.safe_load(f)
50
+ except (yaml.YAMLError, OSError):
51
+ return ProjectSettings()
52
+
53
+ if not isinstance(data, dict):
54
+ return ProjectSettings()
55
+
56
+ settings = ProjectSettings()
57
+ settings.auto_generate_scen_plots = data.get(
58
+ "auto_generate_scen_plots", settings.auto_generate_scen_plots
59
+ )
60
+ settings.auto_generate_scen_excels = data.get(
61
+ "auto_generate_scen_excels", settings.auto_generate_scen_excels
62
+ )
63
+ settings.auto_generate_scen_csvs = data.get(
64
+ "auto_generate_scen_csvs", settings.auto_generate_scen_csvs
65
+ )
66
+ settings.auto_generate_comp_plots = data.get(
67
+ "auto_generate_comp_plots", settings.auto_generate_comp_plots
68
+ )
69
+ settings.auto_generate_comp_excel = data.get(
70
+ "auto_generate_comp_excel", settings.auto_generate_comp_excel
71
+ )
72
+ settings.auto_generate_comp_spinedb = data.get(
73
+ "auto_generate_comp_spinedb", settings.auto_generate_comp_spinedb
74
+ )
75
+ # Legacy compat: pre-tiered ``debug: bool`` settings.yaml entries
76
+ # map True→"full" (preserves their old behaviour: tracemalloc +
77
+ # csv-dump) and False→"off". New entries use ``debug_level``
78
+ # directly.
79
+ if "debug_level" in data:
80
+ _level = data.get("debug_level", settings.debug_level)
81
+ if _level not in ("off", "basic", "full"):
82
+ _level = "off"
83
+ settings.debug_level = _level
84
+ elif "debug" in data:
85
+ settings.debug_level = "full" if bool(data["debug"]) else "off"
86
+ settings.save_memory = bool(data.get("save_memory", settings.save_memory))
87
+
88
+ # Solver options — validate each against its allowed set / type and
89
+ # fall back to the dataclass default on anything malformed so a
90
+ # hand-edited settings.yaml can't break the GUI.
91
+ _sll = data.get("solver_log_level", settings.solver_log_level)
92
+ if _sll in ("silent", "normal", "verbose"):
93
+ settings.solver_log_level = _sll
94
+ _stl = data.get("solver_time_limit", settings.solver_time_limit)
95
+ if isinstance(_stl, int) and not isinstance(_stl, bool) and _stl >= 0:
96
+ settings.solver_time_limit = _stl
97
+ _smg = data.get("solver_mip_gap", settings.solver_mip_gap)
98
+ if isinstance(_smg, (int, float)) and not isinstance(_smg, bool) and _smg >= 0:
99
+ settings.solver_mip_gap = float(_smg)
100
+ _smg_set = data.get("solver_mip_gap_set", settings.solver_mip_gap_set)
101
+ if isinstance(_smg_set, bool):
102
+ settings.solver_mip_gap_set = _smg_set
103
+ _mff = data.get("matrix_file_format", settings.matrix_file_format)
104
+ if _mff in ("mps", "lp"):
105
+ settings.matrix_file_format = _mff
106
+ _scl = data.get("scaling", settings.scaling)
107
+ if _scl in ("off", "solver_only", "basic", "full"):
108
+ settings.scaling = _scl
109
+ _ps = data.get("presolve", settings.presolve)
110
+ if _ps in ("on", "off", "choose"):
111
+ settings.presolve = _ps
112
+
113
+ # Calibrate-investments dialog — validate each field against its
114
+ # allowed set / type and fall back to the dataclass default on
115
+ # anything malformed so a hand-edited settings.yaml can't break the
116
+ # GUI. bool is an int subclass, so ints/floats exclude it explicitly.
117
+ _c_n_rp = data.get("calib_rp_n_rp", settings.calib_rp_n_rp)
118
+ if isinstance(_c_n_rp, int) and not isinstance(_c_n_rp, bool):
119
+ settings.calib_rp_n_rp = _c_n_rp
120
+ _c_plen = data.get("calib_rp_period_length", settings.calib_rp_period_length)
121
+ if isinstance(_c_plen, int) and not isinstance(_c_plen, bool):
122
+ settings.calib_rp_period_length = _c_plen
123
+ _c_sust = data.get("calib_rp_force_sustained", settings.calib_rp_force_sustained)
124
+ if isinstance(_c_sust, bool):
125
+ settings.calib_rp_force_sustained = _c_sust
126
+ _c_peak = data.get("calib_rp_force_peak", settings.calib_rp_force_peak)
127
+ if isinstance(_c_peak, bool):
128
+ settings.calib_rp_force_peak = _c_peak
129
+ _c_win = data.get("calib_rp_force_window", settings.calib_rp_force_window)
130
+ if isinstance(_c_win, int) and not isinstance(_c_win, bool):
131
+ settings.calib_rp_force_window = _c_win
132
+ _c_mode = data.get("calib_rp_scenario_mode")
133
+ if isinstance(_c_mode, str) and _c_mode in ("detached", "add", "new_scenario"):
134
+ settings.calib_rp_scenario_mode = _c_mode
135
+ else:
136
+ # Legacy migration: the pre-3-way boolean "add to scenario" flag
137
+ # (True -> append to the scenario, False -> leave the alt detached).
138
+ _c_add = data.get("calib_rp_add_to_scenario")
139
+ if isinstance(_c_add, bool):
140
+ settings.calib_rp_scenario_mode = "add" if _c_add else "detached"
141
+ _c_solves = data.get("calib_selected_solves", settings.calib_selected_solves)
142
+ if isinstance(_c_solves, list):
143
+ settings.calib_selected_solves = [
144
+ s for s in _c_solves if isinstance(s, str) and s
145
+ ]
146
+ _c_iter = data.get("calib_max_iterations", settings.calib_max_iterations)
147
+ if isinstance(_c_iter, int) and not isinstance(_c_iter, bool):
148
+ settings.calib_max_iterations = _c_iter
149
+ _c_sizing = data.get("calib_sizing", settings.calib_sizing)
150
+ if _c_sizing in ("timed", "uniform"):
151
+ settings.calib_sizing = _c_sizing
152
+ _c_over = data.get("calib_overshoot_pct", settings.calib_overshoot_pct)
153
+ if isinstance(_c_over, (int, float)) and not isinstance(_c_over, bool):
154
+ settings.calib_overshoot_pct = float(_c_over)
155
+ _c_damp1 = data.get("calib_damping_first", settings.calib_damping_first)
156
+ if isinstance(_c_damp1, (int, float)) and not isinstance(_c_damp1, bool):
157
+ settings.calib_damping_first = float(_c_damp1)
158
+ _c_dampr = data.get("calib_damping_remaining", settings.calib_damping_remaining)
159
+ if isinstance(_c_dampr, (int, float)) and not isinstance(_c_dampr, bool):
160
+ settings.calib_damping_remaining = float(_c_dampr)
161
+ _c_stall = data.get("calib_stall_fraction", settings.calib_stall_fraction)
162
+ if isinstance(_c_stall, (int, float)) and not isinstance(_c_stall, bool):
163
+ settings.calib_stall_fraction = float(_c_stall)
164
+ _c_keep = data.get("calib_keep_artifacts", settings.calib_keep_artifacts)
165
+ if isinstance(_c_keep, bool):
166
+ settings.calib_keep_artifacts = _c_keep
167
+
168
+ settings.input_source_numbers = data.get(
169
+ "input_source_numbers", settings.input_source_numbers
170
+ )
171
+
172
+ # Per-number source identity. Keys are stringified numbers; values are
173
+ # {name, path} dicts. Tolerate a hand-edited / malformed map by
174
+ # validating each entry and dropping the rest.
175
+ registry_data = data.get("source_registry", {})
176
+ if isinstance(registry_data, dict):
177
+ registry: dict[str, SourceRecord] = {}
178
+ for num_key, rec in registry_data.items():
179
+ key = str(num_key)
180
+ if not key.isdigit():
181
+ continue
182
+ if isinstance(rec, dict):
183
+ registry[key] = SourceRecord(
184
+ name=str(rec.get("name", "")),
185
+ path=str(rec.get("path", "")),
186
+ )
187
+ settings.source_registry = registry
188
+
189
+ settings.external_refs = data.get("external_refs", settings.external_refs)
190
+ settings.bare_output_owners = data.get(
191
+ "bare_output_owners", settings.bare_output_owners
192
+ )
193
+ settings.scenario_order = data.get("scenario_order", settings.scenario_order)
194
+ settings.executed_scenario_order = data.get(
195
+ "executed_scenario_order", settings.executed_scenario_order
196
+ )
197
+ settings.comp_plots_scenarios = data.get("comp_plots_scenarios", [])
198
+ settings.comp_excel_scenarios = data.get("comp_excel_scenarios", [])
199
+ settings.comp_viewer_scenarios = data.get("comp_viewer_scenarios", [])
200
+ settings.checked_input_sources = data.get("checked_input_sources", [])
201
+ settings.checked_available_scenarios = data.get("checked_available_scenarios", [])
202
+ settings.checked_executed_scenarios = data.get("checked_executed_scenarios", [])
203
+
204
+ def _clean_variant_durations(raw: object) -> dict[str, int]:
205
+ if not isinstance(raw, dict):
206
+ return {}
207
+ cleaned: dict[str, int] = {}
208
+ for k, v in raw.items():
209
+ if isinstance(v, bool):
210
+ # bool is an int subclass — exclude explicitly
211
+ continue
212
+ if isinstance(v, (int, float)):
213
+ cleaned[str(k)] = int(v)
214
+ return cleaned
215
+
216
+ single_plot = data.get("single_plot_settings")
217
+ if isinstance(single_plot, dict):
218
+ settings.single_plot_settings = PlotSettings(
219
+ start_time=single_plot.get("start_time", 0),
220
+ duration=single_plot.get("duration", 0),
221
+ config_file=single_plot.get("config_file", ""),
222
+ active_configs=single_plot.get("active_configs", []),
223
+ only_first_file=single_plot.get("only_first_file", False),
224
+ variant_durations=_clean_variant_durations(
225
+ single_plot.get("variant_durations", {})
226
+ ),
227
+ )
228
+
229
+ comp_plot = data.get("comparison_plot_settings")
230
+ if isinstance(comp_plot, dict):
231
+ settings.comparison_plot_settings = PlotSettings(
232
+ start_time=comp_plot.get("start_time", 0),
233
+ duration=comp_plot.get("duration", 0),
234
+ config_file=comp_plot.get("config_file", ""),
235
+ active_configs=comp_plot.get("active_configs", []),
236
+ dispatch_plots=comp_plot.get("dispatch_plots", True),
237
+ only_first_file=comp_plot.get("only_first_file", False),
238
+ variant_durations=_clean_variant_durations(
239
+ comp_plot.get("variant_durations", {})
240
+ ),
241
+ )
242
+
243
+ viewer = data.get("viewer_settings")
244
+ if isinstance(viewer, dict):
245
+ settings.viewer_settings = ViewerSettings(
246
+ last_scenario=viewer.get("last_scenario", ""),
247
+ last_entry=viewer.get("last_entry", ""),
248
+ last_variant=viewer.get("last_variant", ""),
249
+ last_mode=viewer.get("last_mode", "single"),
250
+ window_geometry=_as_geometry_map(viewer.get("window_geometry")),
251
+ left_pane_width=viewer.get("left_pane_width", 0),
252
+ scenario_pane_height=viewer.get("scenario_pane_height", 0),
253
+ layout_cw=viewer.get("layout_cw", 0),
254
+ cache_gb=viewer.get("cache_gb", 0.5),
255
+ )
256
+
257
+ history_data = data.get("scenario_resource_history", {})
258
+ if isinstance(history_data, dict):
259
+ history: dict[str, ScenarioRun] = {}
260
+ for subdir, run_data in history_data.items():
261
+ if not isinstance(run_data, dict):
262
+ continue
263
+ history[str(subdir)] = ScenarioRun(
264
+ peak_rss_mb=float(run_data.get("peak_rss_mb", 0.0)),
265
+ runtime_s=float(run_data.get("runtime_s", 0.0)),
266
+ last_run=str(run_data.get("last_run", "")),
267
+ )
268
+ settings.scenario_resource_history = history
269
+
270
+ limits_data = data.get("execution_limits")
271
+ if isinstance(limits_data, dict):
272
+ settings.execution_limits = ExecutionLimits(
273
+ max_cores_per_job=int(limits_data.get(
274
+ "max_cores_per_job", settings.execution_limits.max_cores_per_job
275
+ )),
276
+ memory_cap_per_job_gb=float(limits_data.get(
277
+ "memory_cap_per_job_gb", settings.execution_limits.memory_cap_per_job_gb
278
+ )),
279
+ system_reserve_gb=float(limits_data.get(
280
+ "system_reserve_gb", settings.execution_limits.system_reserve_gb
281
+ )),
282
+ swap_allowance_gb=float(limits_data.get(
283
+ "swap_allowance_gb", settings.execution_limits.swap_allowance_gb
284
+ )),
285
+ )
286
+ mw = data.get("max_workers")
287
+ if isinstance(mw, int) and mw > 0:
288
+ settings.max_workers = mw
289
+
290
+ return settings
291
+
292
+
293
+ def save_project_settings(project_path: Path, settings: ProjectSettings) -> None:
294
+ """Save project settings to settings.yaml in the project directory."""
295
+ settings_file = project_path / SETTINGS_FILENAME
296
+ project_path.mkdir(parents=True, exist_ok=True)
297
+
298
+ data = asdict(settings)
299
+ with open(settings_file, "w", encoding="utf-8") as f:
300
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
301
+
302
+
303
+ def load_global_settings(projects_dir: Path) -> GlobalSettings:
304
+ """Load global settings from projects/projects.yaml.
305
+
306
+ Returns defaults if the file does not exist or cannot be parsed.
307
+ """
308
+ settings_file = projects_dir / GLOBAL_SETTINGS_FILENAME
309
+ if not settings_file.exists():
310
+ return GlobalSettings()
311
+
312
+ try:
313
+ with open(settings_file, "r", encoding="utf-8") as f:
314
+ data = yaml.safe_load(f)
315
+ except (yaml.YAMLError, OSError):
316
+ return GlobalSettings()
317
+
318
+ if not isinstance(data, dict):
319
+ return GlobalSettings()
320
+
321
+ theme = data.get("theme", "dark")
322
+ if theme not in ("dark", "light", "os"):
323
+ theme = "dark"
324
+
325
+ limits_data = data.get("execution_limits")
326
+ if isinstance(limits_data, dict):
327
+ execution_limits = ExecutionLimits(
328
+ max_cores_per_job=limits_data.get("max_cores_per_job", 1),
329
+ memory_cap_per_job_gb=limits_data.get("memory_cap_per_job_gb", 0.0),
330
+ system_reserve_gb=limits_data.get("system_reserve_gb", 4.0),
331
+ swap_allowance_gb=limits_data.get("swap_allowance_gb", 0.0),
332
+ )
333
+ else:
334
+ execution_limits = ExecutionLimits()
335
+
336
+ return GlobalSettings(
337
+ recent_project=data.get("recent_project"),
338
+ theme=theme,
339
+ exec_jobs_sash=data.get("exec_jobs_sash", 0),
340
+ exec_jobs_layout_cw=data.get("exec_jobs_layout_cw", 0),
341
+ exec_jobs_geometry=_as_geometry_map(data.get("exec_jobs_geometry")),
342
+ main_window_geometry=_as_geometry_map(data.get("main_window_geometry")),
343
+ main_window_layout_cw=data.get("main_window_layout_cw", 0),
344
+ max_workers=data.get("max_workers", 0),
345
+ execution_limits=execution_limits,
346
+ font_size_pt=data.get("font_size_pt", 10),
347
+ code_font_size_pt=data.get("code_font_size_pt", 0),
348
+ check_updates_on_startup=data.get("check_updates_on_startup", True),
349
+ polars_check_fingerprint=data.get("polars_check_fingerprint", ""),
350
+ )
351
+
352
+
353
+ def save_global_settings(projects_dir: Path, settings: GlobalSettings) -> None:
354
+ """Save global settings to projects/projects.yaml."""
355
+ projects_dir.mkdir(parents=True, exist_ok=True)
356
+ settings_file = projects_dir / GLOBAL_SETTINGS_FILENAME
357
+
358
+ data = asdict(settings)
359
+ with open(settings_file, "w", encoding="utf-8") as f:
360
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
@@ -0,0 +1,103 @@
1
+ """Read-only helper: enumerate a scenario's solves and flag investment solves.
2
+
3
+ The Calibrate-investments GUI needs, for a given input DB + scenario, the
4
+ ordered list of solves that scenario runs and — for each — whether it is an
5
+ *investment solve*. A solve is an investment solve when its ``invest_periods``
6
+ Array parameter is present and non-empty ("Array of periods where investments
7
+ are allowed."); an empty or absent ``invest_periods`` marks a dispatch-style
8
+ solve.
9
+
10
+ The scenario's solve list is read from ``model.solves`` (schema: "Sequence of
11
+ solves in the model. Array."), the authoritative ordered list of solves a model
12
+ runs, resolved under the scenario's alternative stack. It is intersected with
13
+ the ``solve`` entities that actually exist so a dangling name in ``model.solves``
14
+ cannot fabricate a checklist row. This mirrors ``SolveConfig.load_from_db``:
15
+ when no ``model.solves`` is defined and exactly one ``solve`` exists, that solve
16
+ is auto-wired as the model's single solve.
17
+
18
+ The DB is opened scenario-filtered and never written to.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+
25
+ import spinedb_api as api
26
+ from spinedb_api import DatabaseMapping
27
+
28
+ from flextool.engine_polars._db_reader import (
29
+ DictMode,
30
+ get_single_entities,
31
+ params_to_dict,
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SolveInfo:
37
+ """One solve referenced by a scenario.
38
+
39
+ Attributes:
40
+ name: The solve entity name.
41
+ has_invest_periods: ``True`` when the solve's ``invest_periods`` Array
42
+ is present and non-empty (an investment solve); ``False`` for a
43
+ dispatch-style solve with an empty or absent ``invest_periods``.
44
+ """
45
+
46
+ name: str
47
+ has_invest_periods: bool
48
+
49
+
50
+ def read_scenario_solves(db_url: str, scenario_name: str) -> list[SolveInfo]:
51
+ """Return the solves *scenario_name* runs, flagged by investment status.
52
+
53
+ Args:
54
+ db_url: URL of a FlexTool input database (e.g. ``sqlite:///path.sqlite``).
55
+ scenario_name: Name of the scenario to resolve.
56
+
57
+ Returns:
58
+ One :class:`SolveInfo` per solve the scenario runs, in the order the
59
+ scenario's ``model.solves`` lists them (dangling names dropped).
60
+
61
+ Raises:
62
+ ValueError: If *scenario_name* is not a scenario in the database.
63
+ """
64
+ scen_config = api.filters.scenario_filter.scenario_filter_config(scenario_name)
65
+ with DatabaseMapping(db_url) as db:
66
+ # Existence check before filtering: an unknown scenario would otherwise
67
+ # silently resolve to an empty stack rather than a loud error.
68
+ scenario_names = {s["name"] for s in db.find_scenarios()}
69
+ if scenario_name not in scenario_names:
70
+ raise ValueError(
71
+ f"No scenario named {scenario_name!r} in the database. "
72
+ f"Available scenarios: {sorted(scenario_names)}."
73
+ )
74
+
75
+ api.filters.scenario_filter.scenario_filter_from_dict(db, scen_config)
76
+ db.fetch_all("parameter_value")
77
+
78
+ # Authoritative ordered solve list per model (Array → list of names).
79
+ model_solves: dict = params_to_dict(
80
+ db=db, cl="model", par="solves", mode=DictMode.DEFAULTDICT
81
+ )
82
+ existing_solves = get_single_entities(db=db, entity_class_name="solve")
83
+
84
+ # Auto-wire the single-solve case exactly as SolveConfig.load_from_db.
85
+ if len(model_solves) == 0 and len(existing_solves) == 1:
86
+ model_solves = {"flextool": [existing_solves[0]]}
87
+
88
+ # invest_periods Array per solve (Array → list of period names).
89
+ invest_periods: dict = params_to_dict(
90
+ db=db, cl="solve", par="invest_periods", mode=DictMode.DEFAULTDICT
91
+ )
92
+
93
+ existing = set(existing_solves)
94
+ result: list[SolveInfo] = []
95
+ seen: set[str] = set()
96
+ for solve_names in model_solves.values():
97
+ for solve_name in solve_names:
98
+ if solve_name in seen or solve_name not in existing:
99
+ continue
100
+ seen.add(solve_name)
101
+ has_invest = bool(invest_periods.get(solve_name))
102
+ result.append(SolveInfo(name=solve_name, has_invest_periods=has_invest))
103
+ return result
@@ -0,0 +1,88 @@
1
+ """Mouse drag-and-drop reordering for ttk.Treeview rows."""
2
+ from __future__ import annotations
3
+
4
+ import tkinter as tk
5
+ from tkinter import ttk
6
+ from typing import Callable
7
+
8
+
9
+ class DragReorderController:
10
+ """Adds drag-to-reorder behaviour to a ttk.Treeview.
11
+
12
+ Engages only on a ButtonPress-1 inside a row's BODY (cell region),
13
+ so it composes with the CheckTreeController whose click handler
14
+ captures clicks in the check column. The drag is ignored unless the
15
+ pointer actually moves (so a plain click still selects the row via
16
+ default Treeview behaviour).
17
+ """
18
+
19
+ _DRAG_THRESHOLD_PX = 4
20
+
21
+ def __init__(
22
+ self,
23
+ tree: ttk.Treeview,
24
+ *,
25
+ check_column: str = "check",
26
+ on_reorder: Callable[[list[str]], None] | None = None,
27
+ ) -> None:
28
+ self._tree = tree
29
+ self._check_column = check_column
30
+ self._on_reorder = on_reorder
31
+ self._press_x: int | None = None
32
+ self._press_y: int | None = None
33
+ self._press_iid: str | None = None
34
+ self._dragging: bool = False
35
+
36
+ tree.bind("<ButtonPress-1>", self._on_press, add="+")
37
+ tree.bind("<B1-Motion>", self._on_motion, add="+")
38
+ tree.bind("<ButtonRelease-1>", self._on_release, add="+")
39
+
40
+ def _click_in_check_column(self, event: tk.Event) -> bool: # type: ignore[type-arg]
41
+ if self._tree.identify("region", event.x, event.y) != "cell":
42
+ return False
43
+ column = self._tree.identify_column(event.x)
44
+ try:
45
+ idx = int(column.lstrip("#")) - 1
46
+ except ValueError:
47
+ return False
48
+ cols = self._tree["columns"]
49
+ return 0 <= idx < len(cols) and cols[idx] == self._check_column
50
+
51
+ def _on_press(self, event: tk.Event) -> None: # type: ignore[type-arg]
52
+ if self._click_in_check_column(event):
53
+ return
54
+ iid = self._tree.identify_row(event.y)
55
+ if not iid:
56
+ return
57
+ self._press_x = event.x
58
+ self._press_y = event.y
59
+ self._press_iid = iid
60
+ self._dragging = False
61
+
62
+ def _on_motion(self, event: tk.Event) -> None: # type: ignore[type-arg]
63
+ if self._press_iid is None or self._press_x is None or self._press_y is None:
64
+ return
65
+ if not self._dragging:
66
+ if abs(event.x - self._press_x) < self._DRAG_THRESHOLD_PX and \
67
+ abs(event.y - self._press_y) < self._DRAG_THRESHOLD_PX:
68
+ return
69
+ self._dragging = True
70
+ self._tree.config(cursor="fleur")
71
+ target = self._tree.identify_row(event.y)
72
+ if not target or target == self._press_iid:
73
+ return
74
+ # Move the source row to where the target row currently sits.
75
+ try:
76
+ target_index = self._tree.index(target)
77
+ self._tree.move(self._press_iid, "", target_index)
78
+ except tk.TclError:
79
+ pass
80
+
81
+ def _on_release(self, event: tk.Event) -> None: # type: ignore[type-arg]
82
+ was_dragging = self._dragging
83
+ self._press_x = self._press_y = None
84
+ self._press_iid = None
85
+ self._dragging = False
86
+ self._tree.config(cursor="")
87
+ if was_dragging and self._on_reorder is not None:
88
+ self._on_reorder(list(self._tree.get_children()))