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,420 @@
1
+ """Central font metrics + scaling helpers for the FlexTool GUI.
2
+
3
+ All windows and dialogs should read sizes through ``get_metrics()`` so a
4
+ single ``setup_fonts()`` call at startup controls the whole UI.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ import re as _re
10
+ import tkinter as tk
11
+ import tkinter.font as tkfont
12
+
13
+
14
+ _GEOMETRY_RE = _re.compile(
15
+ r"^(?P<w>\d+)x(?P<h>\d+)"
16
+ r"(?:(?P<xs>[+-])(?P<x>\d+)(?P<ys>[+-])(?P<y>\d+))?$"
17
+ )
18
+
19
+
20
+ def clamp_geometry(
21
+ saved: str,
22
+ screen_w: int,
23
+ screen_h: int,
24
+ *,
25
+ min_w: int,
26
+ min_h: int,
27
+ margin: int = 40,
28
+ ) -> str | None:
29
+ """Sanitise a saved Tk geometry string against the current screen.
30
+
31
+ Returns ``None`` if *saved* is empty or malformed. Otherwise:
32
+
33
+ * Width/height are clamped to ``[min_w, screen_w - margin]`` and
34
+ ``[min_h, screen_h - margin]``.
35
+ * If an ``+X+Y`` offset is present, it is kept only when the window
36
+ would fit fully on-screen; otherwise the offset is dropped and the
37
+ window is placed at ``+0+0`` (the WM picks the position).
38
+
39
+ The ``margin`` reserves space for taskbars / docks.
40
+ """
41
+ if not saved:
42
+ return None
43
+ m = _GEOMETRY_RE.match(saved.strip())
44
+ if not m:
45
+ return None
46
+ orig_w = int(m["w"])
47
+ orig_h = int(m["h"])
48
+ w = max(min_w, min(orig_w, max(min_w, screen_w - margin)))
49
+ h = max(min_h, min(orig_h, max(min_h, screen_h - margin)))
50
+ clamped_size = (w != orig_w) or (h != orig_h)
51
+ if m["x"] is None:
52
+ return f"{w}x{h}"
53
+ x = int(m["x"])
54
+ y = int(m["y"])
55
+ if m["xs"] == "-":
56
+ x = -x
57
+ if m["ys"] == "-":
58
+ y = -y
59
+ # Drop the offset if the width/height had to be clamped or the window
60
+ # would run off-screen — saved offsets from a different resolution
61
+ # are not trustworthy in those cases.
62
+ if (
63
+ clamped_size
64
+ or x < 0
65
+ or y < 0
66
+ or x + w > screen_w
67
+ or y + h > screen_h - margin
68
+ ):
69
+ return f"{w}x{h}"
70
+ return f"{w}x{h}+{x}+{y}"
71
+
72
+
73
+ def clamp_sash(
74
+ saved_px: int,
75
+ pane_total: int,
76
+ *,
77
+ min_px: int,
78
+ max_frac: float = 0.85,
79
+ ) -> int:
80
+ """Keep a saved sash position inside the current pane.
81
+
82
+ Returns 0 when *saved_px* is 0 or ``pane_total`` is too small to honour
83
+ ``min_px`` — callers should treat 0 as "leave default".
84
+ """
85
+ if saved_px <= 0 or pane_total <= 0:
86
+ return 0
87
+ upper = int(pane_total * max_frac)
88
+ if upper <= min_px:
89
+ return 0
90
+ return max(min_px, min(saved_px, upper))
91
+
92
+
93
+ def rescale_pixels(saved_px: int, saved_cw: int, current_cw: int) -> int:
94
+ """Rescale a pixel value saved under one font metric to the current one.
95
+
96
+ Returns ``saved_px`` unchanged when ``saved_cw`` is 0 (unknown), when
97
+ the ratio is within 10%, or when either cw is non-positive. Otherwise
98
+ returns ``round(saved_px * current_cw / saved_cw)``.
99
+
100
+ Used to make saved sash positions and (optionally) window dimensions
101
+ survive a DPI change between sessions.
102
+ """
103
+ if saved_px <= 0 or saved_cw <= 0 or current_cw <= 0:
104
+ return saved_px
105
+ ratio = current_cw / saved_cw
106
+ if 0.90 <= ratio <= 1.10:
107
+ return saved_px
108
+ return round(saved_px * ratio)
109
+
110
+
111
+ def rescale_geometry(saved: str, saved_cw: int, current_cw: int) -> str:
112
+ """Apply rescale_pixels to W and H in a Tk geometry string."""
113
+ if not saved or saved_cw <= 0 or current_cw <= 0 or saved_cw == current_cw:
114
+ return saved
115
+ m = _GEOMETRY_RE.match(saved.strip())
116
+ if not m:
117
+ return saved
118
+ new_w = rescale_pixels(int(m["w"]), saved_cw, current_cw)
119
+ new_h = rescale_pixels(int(m["h"]), saved_cw, current_cw)
120
+ if m["x"] is None:
121
+ return f"{new_w}x{new_h}"
122
+ xs = m["xs"]
123
+ ys = m["ys"]
124
+ return f"{new_w}x{new_h}{xs}{m['x']}{ys}{m['y']}"
125
+
126
+
127
+ def monitor_signature(window: tk.Misc | None = None) -> str:
128
+ """Stable key identifying the current monitor *configuration*.
129
+
130
+ Used to remember window placements per monitor layout: a window
131
+ dragged onto the 4K while docked, then the laptop undocked, should not
132
+ have its docked offset reused on the single internal panel. The
133
+ signature changes whenever a monitor is added/removed/moved/resized, so
134
+ each configuration keeps its own saved geometry.
135
+
136
+ Built from the sorted ``WxH+X+Y`` rectangles reported by ``screeninfo``
137
+ (order-independent). Falls back to ``virtual:<W>x<H>`` from the Tk
138
+ virtual-desktop size when ``screeninfo`` is unavailable, and finally to
139
+ ``"virtual:unknown"`` so callers always get a usable, hashable key.
140
+ """
141
+ try:
142
+ from screeninfo import get_monitors
143
+ monitors = list(get_monitors())
144
+ if monitors:
145
+ parts = sorted(
146
+ f"{int(m.width)}x{int(m.height)}+{int(m.x)}+{int(m.y)}"
147
+ for m in monitors
148
+ )
149
+ return "|".join(parts)
150
+ except Exception:
151
+ pass
152
+ if window is not None:
153
+ try:
154
+ return f"virtual:{window.winfo_screenwidth()}x{window.winfo_screenheight()}"
155
+ except tk.TclError:
156
+ pass
157
+ return "virtual:unknown"
158
+
159
+
160
+ def primary_monitor_bounds() -> tuple[int, int, int, int] | None:
161
+ """Bounds ``(x, y, w, h)`` of the primary monitor, or ``None``.
162
+
163
+ Used to place a window that has no prior position to detect a "current"
164
+ monitor from (e.g. the main window at first launch). Prefers the monitor
165
+ ``screeninfo`` flags ``is_primary``; otherwise the first enumerated one.
166
+ Returns ``None`` when ``screeninfo`` is unavailable or enumerates none,
167
+ so callers fall back to virtual-desktop sizing.
168
+ """
169
+ try:
170
+ from screeninfo import get_monitors
171
+ monitors = list(get_monitors())
172
+ except Exception:
173
+ return None
174
+ if not monitors:
175
+ return None
176
+ chosen = next((m for m in monitors if getattr(m, "is_primary", False)), monitors[0])
177
+ if chosen.width <= 1 or chosen.height <= 1:
178
+ return None
179
+ return (int(chosen.x), int(chosen.y), int(chosen.width), int(chosen.height))
180
+
181
+
182
+ def _monitor_bounds_screeninfo(
183
+ window: tk.Misc,
184
+ ) -> tuple[int, int, int, int] | None:
185
+ """Per-monitor rectangle via the ``screeninfo`` package (flicker-free).
186
+
187
+ Picks the monitor whose rectangle contains the centre of *window*; if
188
+ no monitor claims the centre (e.g. the window straddles a gap), falls
189
+ back to the primary monitor, then the first enumerated one. Returns
190
+ ``None`` when ``screeninfo`` is missing or enumerates no monitors, so
191
+ the caller can drop to the maximise-probe.
192
+ """
193
+ try:
194
+ from screeninfo import get_monitors
195
+ except Exception:
196
+ return None
197
+ try:
198
+ monitors = list(get_monitors())
199
+ except Exception:
200
+ # screeninfo raises on headless / unusual display backends.
201
+ return None
202
+ if not monitors:
203
+ return None
204
+ try:
205
+ window.update_idletasks()
206
+ cx = window.winfo_x() + window.winfo_width() // 2
207
+ cy = window.winfo_y() + window.winfo_height() // 2
208
+ except tk.TclError:
209
+ cx = cy = None
210
+ chosen = None
211
+ if cx is not None:
212
+ for m in monitors:
213
+ if m.x <= cx < m.x + m.width and m.y <= cy < m.y + m.height:
214
+ chosen = m
215
+ break
216
+ if chosen is None:
217
+ chosen = next((m for m in monitors if getattr(m, "is_primary", False)), None)
218
+ if chosen is None:
219
+ chosen = monitors[0]
220
+ if chosen.width <= 1 or chosen.height <= 1:
221
+ return None
222
+ return (int(chosen.x), int(chosen.y), int(chosen.width), int(chosen.height))
223
+
224
+
225
+ def current_monitor_bounds(window: tk.Misc) -> tuple[int, int, int, int] | None:
226
+ """Best-effort bounds ``(x, y, w, h)`` of the monitor showing *window*.
227
+
228
+ Tk exposes no per-monitor geometry: ``winfo_screenwidth`` /
229
+ ``winfo_screenheight`` report the FULL virtual desktop spanning *every*
230
+ monitor, so sizing a window to those values stretches it across all
231
+ screens (the dual-monitor "window covers both displays" bug).
232
+
233
+ Prefers the ``screeninfo`` package (flicker-free, also yields the
234
+ monitor's ``x``/``y`` origin). When it is unavailable or enumerates no
235
+ monitors, falls back to momentarily maximising *window* — the window
236
+ manager snaps a maximised window to the *current* monitor's usable area
237
+ — then reading the resulting offset/size and restoring normal state.
238
+
239
+ *window* should already be positioned (e.g. via ``geometry("+x+y")``) on
240
+ the target monitor before calling, so both paths pick the right display.
241
+ Returns ``None`` when every approach fails, so callers fall back to
242
+ virtual-desktop sizing (no worse than before).
243
+
244
+ Note: ``screeninfo`` reports the full monitor rectangle, not the WM work
245
+ area, so callers should still reserve taskbar/dock space themselves; the
246
+ maximise-probe path already excludes it.
247
+ """
248
+ bounds = _monitor_bounds_screeninfo(window)
249
+ if bounds is not None:
250
+ return bounds
251
+
252
+ def _set_maximised(on: bool) -> bool:
253
+ # Windows / macOS use state('zoomed'); most Linux WMs honour the
254
+ # '-zoomed' attribute. Try both; report whether either took.
255
+ try:
256
+ window.tk.call("wm", "state", window._w, "zoomed" if on else "normal")
257
+ return True
258
+ except tk.TclError:
259
+ pass
260
+ try:
261
+ window.tk.call("wm", "attributes", window._w, "-zoomed", "1" if on else "0")
262
+ return True
263
+ except tk.TclError:
264
+ return False
265
+
266
+ try:
267
+ window.update_idletasks()
268
+ if not _set_maximised(True):
269
+ return None
270
+ window.update_idletasks()
271
+ x = window.winfo_x()
272
+ y = window.winfo_y()
273
+ w = window.winfo_width()
274
+ h = window.winfo_height()
275
+ _set_maximised(False)
276
+ window.update_idletasks()
277
+ if w <= 1 or h <= 1:
278
+ return None
279
+ return (x, y, w, h)
280
+ except tk.TclError:
281
+ return None
282
+
283
+
284
+ def resolve_saved_geometry(
285
+ geom_map: dict[str, str],
286
+ signature: str,
287
+ saved_cw: int,
288
+ current_cw: int,
289
+ screen_w: int,
290
+ screen_h: int,
291
+ *,
292
+ min_w: int,
293
+ min_h: int,
294
+ ) -> str | None:
295
+ """Pick the geometry saved for *signature* and make it safe to apply.
296
+
297
+ Looks up *geom_map* by the current monitor *signature*, falling back to
298
+ a ``"legacy"`` entry migrated from the old single-string format. The
299
+ result is rescaled for any font/DPI change (``rescale_geometry``) and
300
+ clamped to the current screen (``clamp_geometry``). Returns ``None``
301
+ when nothing usable is saved, so callers keep the default placement.
302
+ """
303
+ if not geom_map:
304
+ return None
305
+ saved = geom_map.get(signature) or geom_map.get("legacy", "")
306
+ if not saved:
307
+ return None
308
+ geom = rescale_geometry(saved, saved_cw, current_cw)
309
+ return clamp_geometry(geom, screen_w, screen_h, min_w=min_w, min_h=min_h)
310
+
311
+
312
+ @dataclass(frozen=True)
313
+ class FontMetrics:
314
+ """Snapshot of derived font metrics. Cheap to recompute, immutable."""
315
+ cw: int # char width — TkDefaultFont.measure("0")
316
+ lh: int # line height — TkDefaultFont.metrics("linespace")
317
+ em: int # alias for cw, exposed for readability in spacing code
318
+ row_height: int # treeview row height (max(24, int(lh * 1.25)))
319
+ bold_font: tkfont.Font # body font with weight=bold
320
+
321
+
322
+ def setup_fonts(root: tk.Misc, *, body_pt: int = 10, code_pt: int = 10) -> None:
323
+ """Configure the six standard Tk named fonts with role-aware sizes.
324
+
325
+ Sets:
326
+ TkDefaultFont / TkTextFont / TkMenuFont = body_pt
327
+ TkHeadingFont = body_pt + 2, bold
328
+ TkTooltipFont = body_pt - 1
329
+ TkFixedFont = code_pt
330
+
331
+ All sizes are POINTS (positive numbers), so tk scaling DPI-scales them.
332
+
333
+ Idempotent: safe to call again to restyle the whole UI at runtime.
334
+ """
335
+ # body / text / menu — same size, regular weight
336
+ for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont"):
337
+ try:
338
+ font = tkfont.nametofont(name)
339
+ font.configure(size=body_pt, weight="normal")
340
+ except tk.TclError:
341
+ pass
342
+
343
+ # heading — one point larger, bold. Bold weight already reads as a
344
+ # heading next to the regular body; +2 looked oversized on Windows
345
+ # (esp. the "Input sources" / "File outputs" section labels), so +1 is
346
+ # enough lift without dominating the surrounding sv_ttk widgets.
347
+ try:
348
+ heading = tkfont.nametofont("TkHeadingFont")
349
+ heading.configure(size=body_pt + 1, weight="bold")
350
+ except tk.TclError:
351
+ pass
352
+
353
+ # tooltip — one point smaller, regular
354
+ try:
355
+ tip = tkfont.nametofont("TkTooltipFont")
356
+ tip.configure(size=max(body_pt - 1, 6), weight="normal")
357
+ except tk.TclError:
358
+ pass
359
+
360
+ # monospace / code
361
+ try:
362
+ fixed = tkfont.nametofont("TkFixedFont")
363
+ # keep the platform-default family; only override size
364
+ fixed.configure(size=code_pt)
365
+ except tk.TclError:
366
+ pass
367
+
368
+
369
+ def monitor_dpi(widget: tk.Misc) -> float:
370
+ """Return the current monitor's effective DPI as Tk sees it.
371
+
372
+ ``winfo_fpixels('1i')`` returns the pixels-per-inch Tk uses for sizing
373
+ fonts and ttk widgets; it reflects the current value of ``tk scaling``,
374
+ not the OS DPI on the monitor the window happens to be on. Wrapping it
375
+ keeps callers from sprinkling magic strings.
376
+
377
+ Note: for a true per-monitor DPI on Windows, the code path would be
378
+ Win32 ``MonitorFromWindow`` + ``GetDpiForMonitor``; that is out of
379
+ scope here. Users can set ``FLEXTOOL_DPI=...`` at startup as an
380
+ override when auto-detection picks the wrong monitor.
381
+ """
382
+ try:
383
+ return float(widget.tk.call("winfo", "fpixels", widget._w, "1i"))
384
+ except (tk.TclError, AttributeError):
385
+ return 96.0
386
+
387
+
388
+ def get_metrics(root: tk.Misc | None = None) -> FontMetrics:
389
+ """Compute current font metrics from TkDefaultFont. Safe to call any time."""
390
+ default = tkfont.nametofont("TkDefaultFont")
391
+ cw = default.measure("0")
392
+ lh = default.metrics("linespace")
393
+ bold = default.copy()
394
+ bold.configure(weight="bold")
395
+ # Treeview row height must track the font the tree ACTUALLY renders cell
396
+ # text with — under sv_ttk that's SunValleyBodyFont, whose linespace runs
397
+ # ~20% tighter than TkDefaultFont's at the same visual size. Deriving the
398
+ # row height from TkDefaultFont (the old code) over-padded every row, so
399
+ # rows looked sparse at all DPIs. A flat 1.3× of the real linespace gives
400
+ # a comfortable click target without the gaps; the old max(24, …) px floor
401
+ # is dropped because a fixed pixel doesn't scale and only inflated rows at
402
+ # 96 DPI.
403
+ tree_lh = lh
404
+ try:
405
+ import tkinter.ttk as ttk
406
+ # lookup() returns a Tcl_Obj, not a str; str() yields the clean font
407
+ # name ("SunValleyBodyFont"), whereas passing the Tcl_Obj straight to
408
+ # nametofont raises "named font … does not already exist".
409
+ tree_font_name = str(ttk.Style().lookup("Treeview", "font") or "")
410
+ if tree_font_name:
411
+ tree_lh = tkfont.nametofont(tree_font_name).metrics("linespace")
412
+ except (tk.TclError, RuntimeError):
413
+ pass
414
+ return FontMetrics(
415
+ cw=cw,
416
+ lh=lh,
417
+ em=cw,
418
+ row_height=max(18, round(tree_lh * 1.3)),
419
+ bold_font=bold,
420
+ )
@@ -0,0 +1,281 @@
1
+ """SpineDBBackend output → :class:`FlexDataProvider`.
2
+
3
+ The :mod:`input_derivation` tier sits between
4
+ :mod:`flextool.spinedb_backend` (raw EAV → polars materialisation) and
5
+ the in-cascade :class:`flextool.engine_polars._flex_data_provider.FlexDataProvider`
6
+ that the rest of the engine consumes.
7
+
8
+ Architecture
9
+ ------------
10
+
11
+ ::
12
+
13
+ SpineDB → SpineDBBackend → input_derivation → FlexDataProvider → cascade
14
+
15
+ A derivation in this package reads its raw inputs **through the
16
+ :class:`SpineDBBackend`** (or, for derivations that compose other
17
+ derivation outputs, the Provider) and emits one or more canonical-
18
+ schema :class:`polars.DataFrame` objects to the Provider via
19
+ ``provider.put(key, frame)``.
20
+
21
+ **No disk writes.** The derivation tier is purely in-memory; CSV
22
+ emission is the Provider's job (``snapshot_processed_inputs`` /
23
+ ``--csv-dump``).
24
+
25
+ Hard rules
26
+ ----------
27
+
28
+ * No disk writes anywhere in this package.
29
+ * No disk-fallback reads. ``provider`` and ``backend`` are required
30
+ keyword arguments; the derivation fails loudly if either is missing.
31
+ * Each derivation populates the Provider via ``provider.put(...)``;
32
+ return values are reserved for derivation-internal carriers (e.g.
33
+ ``ct_method_overrides`` from DC power flow, consumed by process
34
+ method).
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import os
40
+ from pathlib import Path
41
+ from typing import TYPE_CHECKING
42
+
43
+ import spinedb_api as api
44
+ from spinedb_api import DatabaseMapping
45
+
46
+ if TYPE_CHECKING:
47
+ from flextool.engine_polars._flex_data_provider import FlexDataProvider
48
+
49
+
50
+ __all__ = ["run"]
51
+
52
+
53
+ def _provider_key(filename: str) -> str:
54
+ """Canonical Provider key for ``input/<stem>.csv`` style paths.
55
+
56
+ Matches the convention in :mod:`flextool.engine_polars._emit_provider_io`:
57
+ ``"<parent_dir_name>/<stem>"``.
58
+ """
59
+ p = Path(filename)
60
+ return f"{p.parent.name}/{p.stem}" if p.parent.name else p.stem
61
+
62
+
63
+ def run(
64
+ backend_or_db_url,
65
+ provider: "FlexDataProvider",
66
+ logger: "logging.Logger | None" = None,
67
+ *,
68
+ scenario_name: "str | None" = None,
69
+ work_folder: "Path | None" = None,
70
+ precision_digits: int = 0,
71
+ memory_recorder=None,
72
+ ) -> None:
73
+ """Populate *provider* with the canonical ``input/`` + derivation
74
+ frames produced from the Spine database.
75
+
76
+ Pipeline
77
+ --------
78
+
79
+ 1. **SpineDBBackend spec loops** — three loops over the canonical
80
+ spec lists (``_DEFAULT_VALUES_SPECS``, ``_ENTITY_SPECS``,
81
+ ``_PARAMETER_SPECS``) materialise ~120 EAV frames into the
82
+ Provider under their canonical ``input/<stem>`` key.
83
+ 2. **Validators** (timeline timestep duration, ladder methods,
84
+ capacity margin storage exclusion, group output memberships).
85
+ 3. **DB-driven derivations** (DC power flow, process method,
86
+ commodity ladder cumulative + annual + sets).
87
+ 4. **Native preprocessing emitters** — the dozen modules under
88
+ :mod:`flextool.engine_polars._emit_*` that produce the
89
+ ``solve_data/*.csv`` artefacts the cascade consumes pre-solve.
90
+ Each is invoked directly with ``provider=`` so its frames flow
91
+ into the Provider and disk emission is skipped.
92
+
93
+ Parameters
94
+ ----------
95
+ backend_or_db_url
96
+ Either a Spine DB URL string (``sqlite:///...``) or a
97
+ pre-constructed :class:`SpineDBBackend`. The URL form opens an
98
+ ephemeral DatabaseMapping for the duration of the call.
99
+ provider
100
+ Cascade-input :class:`FlexDataProvider`. All frames land here.
101
+ logger
102
+ Optional logger. Defaults to ``logging.getLogger(__name__)``.
103
+ scenario_name
104
+ Scenario filter applied to the DB mapping. ``None`` runs
105
+ without a filter.
106
+ work_folder
107
+ Workdir for the (still-disk) batches that have not yet been
108
+ ported to Provider-only operation. When ``None`` defaults to
109
+ :func:`pathlib.Path.cwd`. Becomes optional in a later step
110
+ once every writer is Provider-only.
111
+ precision_digits
112
+ Float precision forwarded to ``SpineDBBackend.parameter_values``.
113
+ """
114
+ from flextool.spinedb_backend import SpineDBBackend
115
+ from flextool.input_derivation._specs import (
116
+ _ENTITY_SPECS,
117
+ _PARAMETER_SPECS,
118
+ _DEFAULT_VALUES_SPECS,
119
+ )
120
+ from flextool.input_derivation._validators import (
121
+ validate_timeline_timestep_duration,
122
+ validate_ladder_methods,
123
+ validate_group_output_memberships,
124
+ validate_capacity_margin_groups,
125
+ validate_connection_node_memberships,
126
+ )
127
+ from flextool.input_derivation._dc_power_flow import derive_dc_power_flow
128
+ from flextool.input_derivation._process_method import derive_process_method
129
+ from flextool.input_derivation._commodity_ladder import (
130
+ derive_commodity_ladder_cumulative,
131
+ derive_commodity_ladder_annual,
132
+ )
133
+ from flextool.input_derivation._commodity_ladder_sets import (
134
+ derive_commodity_ladder_sets,
135
+ )
136
+
137
+ if logger is None:
138
+ logger = logging.getLogger(__name__)
139
+ if provider is None:
140
+ raise TypeError("input_derivation.run requires a FlexDataProvider")
141
+
142
+ def _mem(label: str, user_label: str) -> None:
143
+ """Emit a phase checkpoint when a recorder was supplied; no-op
144
+ otherwise. Lets the user follow input-pipeline phase progress
145
+ with section-delta accounting matching the cascade/build
146
+ checkpoints emitted by :mod:`_orchestration`.
147
+
148
+ The recorder's log lines are user-visible by default; full
149
+ tracemalloc-peak diagnostics and CSV emission are opt-in via
150
+ ``FLEXTOOL_MEMORY_DIAGNOSTICS=1`` (handled inside the recorder).
151
+ """
152
+ if memory_recorder is not None:
153
+ memory_recorder.checkpoint(label, logger, user_label=user_label)
154
+
155
+ wf = work_folder if work_folder is not None else Path.cwd()
156
+
157
+ def _do(db) -> None:
158
+ # Step 1 — SpineDBBackend spec loops.
159
+ backend = SpineDBBackend.__new__(SpineDBBackend)
160
+ backend._db = db # type: ignore[attr-defined]
161
+ backend._api = api # type: ignore[attr-defined]
162
+ backend._precision_digits = precision_digits # type: ignore[attr-defined]
163
+ backend._scenario_name = scenario_name # type: ignore[attr-defined]
164
+
165
+ for spec in _DEFAULT_VALUES_SPECS:
166
+ frame = backend.parameter_defaults(
167
+ cl_pars=spec["cl_pars"],
168
+ header=spec["header"],
169
+ filter_in_type=spec.get("filter_in_type"),
170
+ only_value=spec.get("only_value", False),
171
+ )
172
+ provider.put(_provider_key(spec["filename"]), frame)
173
+
174
+ for spec in _ENTITY_SPECS:
175
+ frame = backend.entities(
176
+ classes=spec.classes,
177
+ header=spec.header,
178
+ entity_dimens=spec.entity_dimens,
179
+ )
180
+ provider.put(_provider_key(spec.filename), frame)
181
+
182
+ validate_timeline_timestep_duration(db)
183
+
184
+ for spec in _PARAMETER_SPECS:
185
+ kwargs = {k: v for k, v in spec.items() if k != "filename"}
186
+ frame = backend.parameter_values(**kwargs)
187
+ provider.put(_provider_key(spec["filename"]), frame)
188
+ _mem("spine_db_read_end", "Spine DB loaded")
189
+
190
+ # Step 2 — DB-driven derivations.
191
+ ct_method_overrides = derive_dc_power_flow(backend, provider, logger)
192
+ derive_process_method(
193
+ backend, provider, logger,
194
+ ct_method_overrides=ct_method_overrides,
195
+ )
196
+ validate_ladder_methods(db, logger)
197
+ derive_commodity_ladder_cumulative(backend, provider, logger)
198
+ derive_commodity_ladder_annual(backend, provider, logger)
199
+ derive_commodity_ladder_sets(backend, provider)
200
+ _mem("db_derivations_end", "DB-driven derivations done")
201
+
202
+ # Step 3 — write_input-time native preprocessing emitters.
203
+ # Each emit_* threads the Provider directly so frames flow into
204
+ # memory rather than disk.
205
+ from flextool.engine_polars import (
206
+ _emit_leaf_sets as _leaf,
207
+ _emit_mid_sets as _mid,
208
+ _emit_calc_params as _calc,
209
+ _emit_arc_unions as _arc,
210
+ _emit_dispatchers as _disp,
211
+ )
212
+ input_dir = wf / "input"
213
+ solve_data_dir = wf / "solve_data"
214
+ os.makedirs(solve_data_dir, exist_ok=True)
215
+ _leaf.emit_period_param_sets(input_dir, solve_data_dir, provider=provider)
216
+ _leaf.emit_invest_method_sets(input_dir, solve_data_dir, provider=provider)
217
+ _leaf.emit_co2_method_sets(input_dir, solve_data_dir, provider=provider)
218
+ _leaf.emit_optional_yes(input_dir, solve_data_dir, provider=provider)
219
+ _leaf.emit_reserve_upDown_group(input_dir, solve_data_dir, provider=provider)
220
+ _leaf.emit_group_loss_share(input_dir, solve_data_dir, provider=provider)
221
+ _mid.emit_node_type_sets(input_dir, solve_data_dir, provider=provider)
222
+ _mid.emit_entity_lifetime_method(input_dir, solve_data_dir, provider=provider)
223
+ _mid.emit_process_ct_method(input_dir, solve_data_dir, provider=provider)
224
+ _mid.emit_process_startup_method(input_dir, solve_data_dir, provider=provider)
225
+ _mid.emit_node_inflow_method(input_dir, solve_data_dir, provider=provider)
226
+ _mid.emit_node_storage_binding_method(input_dir, solve_data_dir, provider=provider)
227
+ _mid.emit_node_penalty_method(input_dir, solve_data_dir, provider=provider)
228
+ _mid.emit_process_group_inside_group_nonsync(input_dir, solve_data_dir, provider=provider)
229
+ _mid.emit_process__sink_nonSync(input_dir, solve_data_dir, provider=provider)
230
+ _mid.emit_group_entity(input_dir, solve_data_dir, provider=provider)
231
+ _mid.emit_process_delayed__duration(input_dir, solve_data_dir, provider=provider)
232
+ _calc.emit_entity_total_caps(input_dir, solve_data_dir, provider=provider)
233
+ _calc.emit_process_method_projections(input_dir, provider=provider)
234
+ _calc.emit_process_VRE(input_dir, provider=provider)
235
+ _calc.emit_process_arc_method_joins(input_dir, provider=provider)
236
+ _calc.emit_process_profile_method_joins(input_dir, provider=provider)
237
+ _mid.emit_reserve_partitions(input_dir, solve_data_dir, provider=provider)
238
+ _mid.emit_commodity_node_co2(input_dir, solve_data_dir, provider=provider)
239
+ _mid.emit_process_coeff_zero_sets(input_dir, solve_data_dir, provider=provider)
240
+ _leaf.emit_def_optional_yes(input_dir, solve_data_dir, provider=provider)
241
+ _leaf.emit_process_delayed(input_dir, solve_data_dir, provider=provider)
242
+ _leaf.emit_simple_setof_projections(input_dir, solve_data_dir, provider=provider)
243
+ # emit_period_solve depends on solve_data outputs from
244
+ # emit_simple_setof_projections, so must run after.
245
+ _leaf.emit_period_solve(solve_data_dir, provider=provider)
246
+ _leaf.emit_time_set(input_dir, solve_data_dir, provider=provider)
247
+ _leaf.emit_enable_optional_outputs(solve_data_dir, provider=provider)
248
+ _leaf.emit_node_state_subsets(solve_data_dir, provider=provider)
249
+ _mid.emit_invest_total_sets(input_dir, solve_data_dir, provider=provider)
250
+ _mid.emit_ci_ladder_cumulative(input_dir, solve_data_dir, provider=provider)
251
+ _disp.emit_process_arc_unions(input_dir, solve_data_dir, provider=provider)
252
+ _arc.emit_param_in_use_sets(input_dir, solve_data_dir, provider=provider)
253
+
254
+ _mem("preprocessing_writers_end", "Preprocessing writers done")
255
+
256
+ # Step 4 — validators that need DB access.
257
+ validate_capacity_margin_groups(db, logger)
258
+ validate_group_output_memberships(db, logger)
259
+ validate_connection_node_memberships(db, logger)
260
+
261
+ # Accept either a backend, a DatabaseMapping or a URL string.
262
+ if isinstance(backend_or_db_url, str):
263
+ scen_config = (
264
+ api.filters.scenario_filter.scenario_filter_config(scenario_name)
265
+ if scenario_name else None
266
+ )
267
+ with DatabaseMapping(backend_or_db_url) as db:
268
+ # Hot-load the DB: fetching upfront is faster than repeated
269
+ # parameter_value lookups.
270
+ db.fetch_all("entity")
271
+ db.fetch_all("parameter_value")
272
+ if scen_config is not None:
273
+ api.filters.scenario_filter.scenario_filter_from_dict(db, scen_config)
274
+ os.makedirs(wf / "input", exist_ok=True)
275
+ _do(db)
276
+ else:
277
+ # Accept a SpineDBBackend (with ._db) or a DatabaseMapping
278
+ # directly; both expose the find_* API we need.
279
+ db = getattr(backend_or_db_url, "_db", backend_or_db_url)
280
+ os.makedirs(wf / "input", exist_ok=True)
281
+ _do(db)