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,699 @@
1
+ """Adder sizing for the adequacy calibrator (C1b) — the numerically
2
+ load-bearing slice.
3
+
4
+ The engine param ``energy_margin_adder`` is a per-timestep MWh value
5
+ SUBTRACTED from a node's inflow in the invest solve, broadcast CONSTANT
6
+ over the invest ``(d, t)`` grid (P1). ``node_slack_up_d_e`` reports the
7
+ resulting unserved energy as **annual MWh**, via
8
+ :func:`flextool.process_outputs._annualize.annualize_dt_to_d`::
9
+
10
+ annual(d) = ( Σ_t value(d, t) · timestep_weight(d, t) ) / period_share(d)
11
+
12
+ A CONSTANT per-timestep adder ``a`` (MWh/step) therefore adds annual demand
13
+ ``ΔE = a · W`` where
14
+
15
+ W = Σ_{d ∈ invest periods} ( Σ_t timestep_weight(d, t) ) / period_share(d).
16
+
17
+ So to inject ``X`` MWh of annual demand the scalar adder is ``a = X / W``.
18
+ The calibrator's per-node target is ``X = λ · residual_unserved(node)``, and
19
+ hence ``increment_adder(node) = λ · residual(node) / W``.
20
+
21
+ Why ``W`` and not the ``/n_invest_steps`` shorthand
22
+ ---------------------------------------------------
23
+ ``/n_invest_steps`` is only correct for a full-year, weight≡1 timeline. For
24
+ the representative-period timelines the calibrator TARGETS, ``period_share``
25
+ is far below 1 (e.g. a 72-step year fraction of ``72/8760``), so the true
26
+ divisor ``W`` is ``8760`` per period, not ``72`` — the naive divisor would
27
+ overshoot the first correction by the rep-weight factor (~121× here).
28
+
29
+ The W source (why it is robust)
30
+ -------------------------------
31
+ ``W`` is a fixed property of the scenario's invest timeline, computed ONCE
32
+ per run straight from the input DB by REUSING the engine's own per-solve
33
+ derivations — the same code paths preprocessing uses — never the
34
+ ``--csv-dump`` files (``solve_data/*.csv``), which are gated AND overwritten
35
+ by the LAST (dispatch) sub-solve and so do NOT reflect the invest grid:
36
+
37
+ * the invest ``(d, t)`` grid and per-period
38
+ ``complete_period_share_of_year`` come from
39
+ :func:`flextool.engine_polars._per_solve_sets.derive_per_solve_aggregates`;
40
+ * ``Σ_t timestep_weight(d, t)`` per period comes from the ACTUAL per-``(d,
41
+ t)`` weights the engine builds, NOT a step-count shortcut —
42
+ :func:`flextool.engine_polars._derived_params.p_timestep_weight_from_source`
43
+ for the default / ``timeset_weights`` regimes, and
44
+ :func:`flextool.engine_polars._emit_solve_writers._compute_rp_frames` for
45
+ ``representative_period_weights`` (RP).
46
+
47
+ Why the ACTUAL weight, not the step count ``n_d``
48
+ -------------------------------------------------
49
+ ``annualize_dt_to_d`` weights every ``(d, t)`` by ``p_timestep_weight``, and
50
+ RP weights flow into ``p_timestep_weight`` (``_compute_rp_frames`` folds
51
+ ``representative_period_weights`` into ``timestep_weight.csv``; the CSV
52
+ loader puts it in ``p_timestep_weight``; ``out_node.py`` annualises
53
+ ``node_slack_up_d_e`` with it) — so ``W`` MUST use the same weights. For the
54
+ default and ``timeset_weights`` regimes the weights normalise to ``Σ_t = n_d``
55
+ (the step count), so those reduce to ``n_d``; but a general RP timeset with
56
+ UNEQUAL rep-block lengths or un-normalised weights does NOT, so ``W`` reads
57
+ the real ``Σ_t timestep_weight`` and is correct for every regime.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ from collections import defaultdict
63
+
64
+ import polars as pl
65
+
66
+ from flextool.engine_polars._derived_params import (
67
+ p_timestep_weight_from_source,
68
+ )
69
+ from flextool.engine_polars._emit_solve_writers import _compute_rp_frames
70
+ from flextool.engine_polars._per_solve_sets import derive_per_solve_aggregates
71
+ from flextool.engine_polars._solve_config import SolveConfig
72
+ from flextool.engine_polars._spinedb_reader import SpineDbReader
73
+ from flextool.engine_polars._timeline import TimelineConfig
74
+
75
+ # Per-node absolute shed tolerance (MWh). Nodes whose residual unserved
76
+ # energy is at/below this are treated as non-shedding and get NO increment,
77
+ # so numerical dust never seeds a spurious adder.
78
+ _SHED_TOL_MWH = 1e-6
79
+
80
+
81
+ def _normalise_url(url: str) -> str:
82
+ """Promote a bare filesystem path to a ``sqlite:///`` URL; pass through
83
+ anything already carrying a ``"://"`` scheme."""
84
+ return url if "://" in url else f"sqlite:///{url}"
85
+
86
+
87
+ def _invest_solves(sc: "SolveConfig") -> list[str]:
88
+ """Return the model's top-level solve names — the invest solves.
89
+
90
+ ``model.solves`` (``SolveConfig.model_solve``) lists the solves the model
91
+ runs; in a nested-invest model the top-level solve IS the invest solve
92
+ (it CONTAINS the dispatch sub-solves), and in a flat single-solve model
93
+ that one solve is both invest and dispatch. Either way the top-level
94
+ solve carries the ``(d, t)`` grid the ``energy_margin_adder`` is
95
+ broadcast over, which is exactly the grid ``W`` must be measured on.
96
+ """
97
+ solves: list[str] = []
98
+ for solve_list in sc.model_solve.values():
99
+ for s in solve_list:
100
+ if s not in solves:
101
+ solves.append(s)
102
+ return solves
103
+
104
+
105
+ def w_from_grids(
106
+ weight_by_period: dict[str, int | float],
107
+ share_by_period: dict[str, float],
108
+ ) -> float:
109
+ """Return ``W = Σ_d weight_d / share_d`` from per-period weight-sums + shares.
110
+
111
+ ``weight_by_period[d]`` is the ACTUAL ``Σ_t timestep_weight(d, t)`` and
112
+ ``share_by_period[d]`` is ``complete_period_share_of_year(d)``. Pure
113
+ arithmetic — the DB-reading :func:`invest_weight_W` builds the two dicts
114
+ and calls this.
115
+
116
+ Reductions the tests pin: with ``weight ≡ 1`` and ``share ≡ 1``
117
+ (full-year, evenly-sampled) ``W`` collapses to the total step count
118
+ ``Σ_d n_d``; with NON-unit weights (a general RP grid) ``W`` uses
119
+ ``Σ_t weight``, NOT the step count.
120
+ """
121
+ total = 0.0
122
+ for d, w in weight_by_period.items():
123
+ share = share_by_period[d]
124
+ if share <= 0.0:
125
+ raise ValueError(
126
+ f"period {d!r}: non-positive period_share {share!r} — cannot "
127
+ "annualise."
128
+ )
129
+ total += float(w) / float(share)
130
+ return total
131
+
132
+
133
+ def _rp_weight_sum_by_period(
134
+ tc: "TimelineConfig", period: str, timeset: str,
135
+ ) -> dict[str, float]:
136
+ """Actual ``Σ_t timestep_weight`` per period for one RP *timeset*.
137
+
138
+ Reuses the engine writer :func:`._emit_solve_writers._compute_rp_frames`
139
+ — the single source of truth that folds
140
+ ``representative_period_weights`` into ``timestep_weight.csv`` — so the
141
+ weights match those the annualiser applies to ``node_slack_up_d_e``
142
+ byte-for-byte. The per-``(d, t)`` weight is independent of the RP chain
143
+ TOPOLOGY (``within_solve`` vs ``within_period`` produce identical
144
+ ``timestep_weight`` rows; the ``within_period`` writer itself calls this
145
+ same single-timeset path per period), so calling it once per active RP
146
+ timeset and summing is correct for both.
147
+ """
148
+ timeline_name = tc.timesets__timeline[timeset]
149
+ timeline_steps = [step for step, _dur in tc.timelines[timeline_name]]
150
+ frames = _compute_rp_frames(
151
+ tc.rp_weights[timeset],
152
+ tc.timeset_durations[timeset],
153
+ period,
154
+ timeline_steps,
155
+ )
156
+ tw = frames["timestep_weight.csv"].with_columns(
157
+ pl.col("weight").cast(pl.Float64)
158
+ )
159
+ return {
160
+ str(p): float(w)
161
+ for p, w in tw.group_by("period").agg(pl.col("weight").sum()).iter_rows()
162
+ }
163
+
164
+
165
+ def _weight_sum_by_period(
166
+ source: "SpineDbReader",
167
+ sc: "SolveConfig",
168
+ tc: "TimelineConfig",
169
+ solve: str,
170
+ dt_complete: "pl.DataFrame",
171
+ ) -> dict[str, float]:
172
+ """Return ``{period: Σ_t timestep_weight(d, t)}`` for *solve*.
173
+
174
+ Uses the ACTUAL engine-built weights for every regime:
175
+
176
+ * **default / ``timeset_weights``** — the native
177
+ :func:`._derived_params.p_timestep_weight_from_source` (returns dense
178
+ 1.0 or the normalised ``timeset_weights``), summed per period;
179
+ * **``representative_period_weights`` (RP)** — that helper returns
180
+ ``None`` (RP weights live in the CSV the writer folds), so each active
181
+ RP timeset is folded via :func:`_rp_weight_sum_by_period`. Any non-RP
182
+ period sharing an RP solve falls back to its step count ``n_d`` (which
183
+ the non-RP normalisation makes equal to ``Σ_t weight``).
184
+ """
185
+ active = sc.timesets_used_by_solves.get(solve, [])
186
+ rp_present = any(ts in tc.rp_weights for _period, ts in active)
187
+
188
+ if not rp_present:
189
+ param = p_timestep_weight_from_source(source, dt_complete, solve)
190
+ if param is not None and param.frame.height > 0:
191
+ agg = param.frame.group_by("d").agg(pl.col("value").sum())
192
+ return {str(d): float(v) for d, v in agg.iter_rows()}
193
+ # No period_timeset / weights resolvable → default 1.0 ⇒ Σ = n_d.
194
+ return {
195
+ str(d): float(n)
196
+ for d, n in dt_complete.group_by("d").len().iter_rows()
197
+ }
198
+
199
+ # At least one active timeset is RP.
200
+ n_by_d = {
201
+ str(d): float(n)
202
+ for d, n in dt_complete.group_by("d").len().iter_rows()
203
+ }
204
+ out: dict[str, float] = {}
205
+ for period, ts in active:
206
+ period = str(period)
207
+ if ts in tc.rp_weights:
208
+ for p, w in _rp_weight_sum_by_period(tc, period, ts).items():
209
+ out[p] = out.get(p, 0.0) + w
210
+ else:
211
+ out[period] = out.get(period, 0.0) + n_by_d.get(period, 0.0)
212
+ return out
213
+
214
+
215
+ def invest_weight_W(url: str, scenario: str) -> float:
216
+ """Compute the annualisation weight ``W`` for *scenario*'s invest solve.
217
+
218
+ ``W = Σ_{d ∈ invest periods} (Σ_t timestep_weight(d, t)) / period_share(d)``
219
+ using the ACTUAL per-``(d, t)`` ``timestep_weight`` the engine builds
220
+ (default / ``timeset_weights`` / ``representative_period_weights``), so a
221
+ constant per-timestep adder ``a`` injects annual demand ``a · W`` for
222
+ every weighting regime; see the module docstring.
223
+
224
+ Computed ONCE per calibration run (``W`` is independent of the adder).
225
+ Reuses the engine's own per-solve derivations so it is correct-by-
226
+ construction against the running engine version, never the gated /
227
+ dispatch-overwritten ``solve_data`` CSVs.
228
+
229
+ Raises
230
+ ------
231
+ ValueError
232
+ If no invest solve / grid can be resolved, or ``W`` is non-positive.
233
+ """
234
+ url = _normalise_url(url)
235
+ sc = SolveConfig.load_from_db_url(url, scenario)
236
+ invest_solves = _invest_solves(sc)
237
+ if not invest_solves:
238
+ raise ValueError(
239
+ f"scenario {scenario!r}: no model.solves — cannot resolve the "
240
+ "invest solve to size the adder against."
241
+ )
242
+
243
+ source = SpineDbReader(url, scenario)
244
+ tc = TimelineConfig.load_from_db_url(url, scenario)
245
+
246
+ total = 0.0
247
+ for solve in invest_solves:
248
+ agg = derive_per_solve_aggregates(source, solve)
249
+ if agg is None:
250
+ raise ValueError(
251
+ f"scenario {scenario!r}, solve {solve!r}: could not derive the "
252
+ "per-solve (d, t) grid / period share from the DB "
253
+ "(missing solve.period_timeset / timeline.timestep_duration). "
254
+ "W cannot be computed."
255
+ )
256
+ # Σ_t timestep_weight per period (actual engine weights, all regimes).
257
+ weight_by_d = _weight_sum_by_period(source, sc, tc, solve, agg.dt_complete)
258
+ share_by_d = {
259
+ str(d): float(v)
260
+ for d, v in agg.complete_period_share_of_year.select(
261
+ "d", "value"
262
+ ).iter_rows()
263
+ }
264
+ # Restrict to the periods that actually have a share (the grid).
265
+ weight_by_d = {d: weight_by_d[d] for d in share_by_d if d in weight_by_d}
266
+ total += w_from_grids(weight_by_d, share_by_d)
267
+
268
+ if total <= 0.0:
269
+ raise ValueError(
270
+ f"scenario {scenario!r}: computed W={total!r} is non-positive."
271
+ )
272
+ return total
273
+
274
+
275
+ def scalar_adder(residual_mwh: float, W: float, lam: float) -> float:
276
+ """Return the per-timestep adder that injects ``lam · residual`` annual MWh.
277
+
278
+ ``a = lam · residual_mwh / W`` (the exact inverse of ``ΔE = a · W``).
279
+ ``W`` must be positive (a valid invest-timeline annualiser).
280
+ """
281
+ if W <= 0.0:
282
+ raise ValueError(f"W must be positive, got {W!r}")
283
+ return lam * residual_mwh / W
284
+
285
+
286
+ def sized_increments(
287
+ residual: dict[str, float],
288
+ *,
289
+ W: float,
290
+ lam: float,
291
+ overshoot: float = 1.0,
292
+ tol: float = _SHED_TOL_MWH,
293
+ ) -> dict[str, float]:
294
+ """Return ``{node: adder_increment}`` for every SHEDDING node.
295
+
296
+ A node is shedding when its residual unserved energy exceeds *tol*;
297
+ non-shedding nodes are skipped entirely (no key), so the loop bumps only
298
+ the nodes that are actually short. Each increment is
299
+ ``overshoot · scalar_adder(residual[node], W, lam)`` — the constant
300
+ per-timestep adder that (undamped, ``lam=1``, ``overshoot=1``) would
301
+ inject exactly that node's residual annual MWh back as demand.
302
+
303
+ ``overshoot`` (default ``1.0`` = off) is a ``>1`` planning-margin SAFETY
304
+ multiplier: a single-year (or single-year RP) solve under-estimates true
305
+ multi-year severity, so ``overshoot`` deliberately provisions beyond the
306
+ measured slack (``overshoot=1.2`` ⇒ ~20 % extra headroom). The right
307
+ value is MODEL-DEPENDENT.
308
+ """
309
+ if W <= 0.0:
310
+ raise ValueError(f"W must be positive, got {W!r}")
311
+ out: dict[str, float] = {}
312
+ for node, res in residual.items():
313
+ if res > tol:
314
+ out[node] = overshoot * scalar_adder(res, W, lam)
315
+ return out
316
+
317
+
318
+ # ===========================================================================
319
+ # T2 — the ``timed`` sizer: place the additive margin at the low-VRE stress
320
+ # hours (per-timestep) instead of spreading it flat.
321
+ # ===========================================================================
322
+ #
323
+ # The uniform sizer above injects ``ΔE = λ·residual`` annual MWh as a CONSTANT
324
+ # per-timestep adder ``a = λ·residual/W``. The ``timed`` sizer injects the
325
+ # SAME total energy but distributes it by the stress SHAPE — the per-cell
326
+ # ``node_slack_up_dt_e`` profile — so the demand lands exactly at the hours the
327
+ # invest solve could not serve.
328
+ #
329
+ # Timeline fold (base → representative)
330
+ # -------------------------------------
331
+ # ``node_slack_up_dt_e`` is the invest-solve up-slack UNFOLDED onto the full
332
+ # base timeline. For a representative-period (RP) timeset every base block
333
+ # ``b`` decomposes convexly over representative blocks ``rep`` with hull weights
334
+ # ``rp_weights[timeset][base_start][rep_start]`` (``Σ_rep weight(b→rep)=1`` per
335
+ # base block — the SAME source ``invest_weight_W`` reuses). For a real hour =
336
+ # (base block ``b``, within-block offset ``h``) carrying slack ``e(b, h)`` we
337
+ # fold it back onto the representative cell that shares its offset::
338
+ #
339
+ # slack_rep(rep, h) = Σ_b weight(b→rep) · e(b, h)
340
+ #
341
+ # Total is conserved: ``Σ_rep slack_rep = Σ_b e(b,·) = residual`` (because
342
+ # ``Σ_rep weight(b→rep)=1``), so the folded stress carries the node's full
343
+ # residual — never the ~15 % that a raw subset of the invest-grid timestamps
344
+ # would (those base rows are convex combinations that sum to a fraction of the
345
+ # residual).
346
+ #
347
+ # Sizing (SHAPE from the fold, MAGNITUDE from the true annual residual)
348
+ # ---------------------------------------------------------------------
349
+ # The fold gives the per-cell stress SHAPE, but its raw magnitude is NOT a
350
+ # reliable proxy for the node's annual residual: ``node_slack_up_dt_e`` equals
351
+ # the annual ``node_slack_up_d_e`` only when the dt table is UNFOLDED onto the
352
+ # full base timeline (as it is for the H2 model, dt≈annual). On a model whose
353
+ # dt table stays on the REPRESENTATIVE grid, ``Σ_dt slack ≠ annual residual``
354
+ # (measured ~0.10 on one RP model), so folding the raw dt total would
355
+ # under-inject ~10×. We therefore use the fold only for the shape and
356
+ # NORMALISE it to the true annual residual ``res = node_slack_up_d_e[node]``.
357
+ #
358
+ # With the ENGINE per-cell timestep weight ``tw_rep(rep, h)`` (from
359
+ # ``_compute_rp_frames``'s ``timestep_weight.csv`` — the same weights the
360
+ # annualiser applies) and the folded shape ``slack_rep(rep, h)`` summing to
361
+ # ``S = Σ_cell slack_rep`` over the cells we can inject on::
362
+ #
363
+ # adder(rep, h) = overshoot · λ · res · (slack_rep(rep, h) / S) / tw_rep(rep, h)
364
+ #
365
+ # The annualised injected energy is then EXACT and independent of both the
366
+ # dt-table form and ``tw_rep`` (it CANCELS in the weighted sum)::
367
+ #
368
+ # Σ_cell adder·tw_rep = overshoot · λ · res · (Σ_cell slack_rep / S)
369
+ # = overshoot · λ · res
370
+ #
371
+ # — the SAME total energy the uniform sizer injects (``overshoot·λ·res``),
372
+ # placed at the stressed cells, correct whether the dt table is pre-unfolded
373
+ # (``S == res`` ⇒ ``adder = overshoot·λ·slack_rep/tw_rep``, unchanged from the
374
+ # naive fold) or on the representative grid (``S ≠ res`` ⇒ the shape is scaled
375
+ # up to the annual magnitude). ``λ`` is ``damping_first`` on the first
376
+ # correction else ``damping_remaining`` (identical to uniform); ``overshoot``
377
+ # is the same ``>1`` planning-margin safety multiplier the uniform sizer uses.
378
+ #
379
+ # Non-RP invest solve
380
+ # -------------------
381
+ # When the invest solve is NOT representative-period the base timeline IS the
382
+ # invest grid; the fold degenerates to identity (each cell maps to itself with
383
+ # weight 1) and ``tw_rep`` is the per-cell ``p_timestep_weight`` (dense 1.0 or
384
+ # the normalised ``timeset_weights``). The formula then places the adder at
385
+ # each timestep in proportion to that timestep's own slack, normalised to the
386
+ # annual residual — the sensible single-representative reduction.
387
+
388
+
389
+ def size_timed(
390
+ dt_slack: dict[str, dict[tuple[str, str], float]],
391
+ residual: dict[str, float],
392
+ fold_edges: list[tuple[str, str, str, float]],
393
+ tw_rep: dict[tuple[str, str], float],
394
+ *,
395
+ lam: float,
396
+ overshoot: float = 1.0,
397
+ tol: float = _SHED_TOL_MWH,
398
+ ) -> dict[str, dict[tuple[str, str], float]]:
399
+ """Pure timed sizer — fold + normalised per-cell sizing, no DB / no solver.
400
+
401
+ The fold supplies only the per-cell stress SHAPE; the injected MAGNITUDE
402
+ is taken from the TRUE annual residual *residual[node]* (see the module's
403
+ T2 comment). Each shedding node's folded shape is normalised so the
404
+ annualised injected energy equals ``overshoot · λ · residual[node]`` —
405
+ correct whether ``dt_slack`` was pre-unfolded onto the base timeline
406
+ (``Σ shape == residual``, so the per-cell adder is unchanged from the
407
+ naive fold) or left on the representative grid (``Σ shape ≠ residual``, so
408
+ the shape is scaled to the annual magnitude).
409
+
410
+ Parameters
411
+ ----------
412
+ dt_slack:
413
+ ``{node: {(period, time): slack}}`` — the base-timeline up-slack
414
+ profile (``read_residual_unserved_dt``, converted to a lookup). Used
415
+ for SHAPE only; its total need not equal the annual residual.
416
+ residual:
417
+ ``{node: annual_MWh}`` — the per-node TRUE annual residual
418
+ (``node_slack_up_d_e``); a node is SHEDDING (and thus sized) only when
419
+ it exceeds *tol*, matching the uniform :func:`sized_increments` gate,
420
+ and it also sets the injected magnitude the shape is normalised to.
421
+ fold_edges:
422
+ ``[(period, base_time, rep_time, weight), ...]`` — the sparse fold
423
+ operator: each edge sends ``weight · e(period, base_time)`` onto the
424
+ representative cell ``(period, rep_time)``. For a total-conserving
425
+ fold every base cell's out-edge weights sum to 1.
426
+ tw_rep:
427
+ ``{(period, rep_time): timestep_weight}`` — the engine per-cell weight
428
+ the adder is divided by (and the annualiser multiplies back).
429
+ lam:
430
+ Damping factor λ.
431
+ overshoot:
432
+ Planning-margin safety multiplier (default ``1.0`` = off); ``>1``
433
+ provisions beyond the measured slack, exactly as in the uniform sizer.
434
+
435
+ Returns
436
+ -------
437
+ ``{node: {(period, rep_time): adder}}`` for every shedding node. Cells
438
+ with zero folded slack (or a non-positive ``tw_rep``) are omitted, so the
439
+ map carries only the stressed representative cells, and
440
+ ``Σ_cell adder·tw_rep == overshoot · λ · residual[node]`` over the kept
441
+ cells.
442
+ """
443
+ out: dict[str, dict[tuple[str, str], float]] = {}
444
+ for node, res in residual.items():
445
+ if res <= tol:
446
+ continue
447
+ node_slack = dt_slack.get(node)
448
+ if not node_slack:
449
+ continue
450
+ # Fold the base-timeline slack onto the representative cells → the
451
+ # per-cell stress SHAPE (its raw magnitude is unreliable; see below).
452
+ slack_rep: dict[tuple[str, str], float] = defaultdict(float)
453
+ for period, base_time, rep_time, weight in fold_edges:
454
+ e = node_slack.get((period, base_time))
455
+ if e:
456
+ slack_rep[(period, rep_time)] += weight * e
457
+ # Keep only the cells we can actually inject on (positive folded slack,
458
+ # positive engine weight) BEFORE normalising, so the annualised
459
+ # injected total is EXACTLY overshoot·λ·res over the kept cells.
460
+ valid: dict[tuple[str, str], tuple[float, float]] = {}
461
+ for cell, sr in slack_rep.items():
462
+ if sr <= 0.0:
463
+ continue
464
+ tw = tw_rep.get(cell)
465
+ if tw is None or tw <= 0.0:
466
+ continue
467
+ valid[cell] = (sr, tw)
468
+ shape_total = sum(sr for sr, _tw in valid.values())
469
+ if shape_total <= 0.0:
470
+ continue
471
+ # NORMALISE the shape to the true annual residual: distribute
472
+ # overshoot·λ·res over the cells by their shape fraction, then divide
473
+ # by tw_rep so the annualiser recovers exactly that energy.
474
+ scale = overshoot * lam * res / shape_total
475
+ adder = {cell: scale * sr / tw for cell, (sr, tw) in valid.items()}
476
+ if adder:
477
+ out[node] = adder
478
+ return out
479
+
480
+
481
+ def _timeline_steps_and_index(
482
+ tc: "TimelineConfig", timeset: str,
483
+ ) -> tuple[list[str], dict[str, int]]:
484
+ """Ordered timeline steps + ``{step: idx}`` for *timeset*'s timeline."""
485
+ timeline_name = tc.timesets__timeline[timeset]
486
+ steps = [step for step, _dur in tc.timelines[timeline_name]]
487
+ return steps, {s: i for i, s in enumerate(steps)}
488
+
489
+
490
+ def _rp_fold_edges_and_tw(
491
+ tc: "TimelineConfig", period: str, timeset: str,
492
+ ) -> tuple[list[tuple[str, str, str, float]], dict[tuple[str, str], float]]:
493
+ """Build the RP fold edges + per-cell ``tw_rep`` for one (period, timeset).
494
+
495
+ ``edges`` maps every base-timeline cell ``(period, base_time)`` onto the
496
+ representative cell ``(period, rep_time)`` that shares its within-block
497
+ offset ``h``, weighted by the hull weight ``rp_weights[base][rep]``.
498
+ ``tw_rep`` comes straight from the engine writer's ``timestep_weight.csv``
499
+ (:func:`._emit_solve_writers._compute_rp_frames`) so the sizer divides by
500
+ exactly the weight the annualiser multiplies back.
501
+
502
+ Two representative-period regimes are handled:
503
+
504
+ * **Hull / equal-length blocks** (the ``timed`` sizer's target, e.g.
505
+ ``hull_5rp_168h``): many base blocks each the SAME length as the
506
+ representative blocks, and ``node_slack_up_dt_e`` is unfolded onto the
507
+ FULL base timeline. The offset-preserving fold above applies.
508
+ * **``representative_period_weights``**: a base period REPRESENTED by a
509
+ few weighted sub-blocks (base-block length ≫ rep-block length), where the
510
+ invest slack already lives on the representative grid. There is nothing
511
+ to unfold — the fold degenerates to identity on the representative cells
512
+ (each rep cell maps to itself, weight 1), which the total-conservation
513
+ contract (Σ out-weight per source cell = 1) still satisfies.
514
+
515
+ The regime is decided by block-length alignment; a mismatch is NOT an
516
+ error (it is the second regime), so no valid RP model is ever rejected.
517
+ """
518
+ steps, idx = _timeline_steps_and_index(tc, timeset)
519
+ rpw = tc.rp_weights[timeset] # {base_start: {rep_start: weight}}
520
+
521
+ # Representative block ranges, anchored to real timeline steps.
522
+ rep_count: dict[str, int] = {}
523
+ for start, count in tc.timeset_durations[timeset]:
524
+ rep_count[str(start)] = int(float(count))
525
+
526
+ frames = _compute_rp_frames(
527
+ rpw, tc.timeset_durations[timeset], period, steps,
528
+ )
529
+ tw = frames["timestep_weight.csv"].with_columns(
530
+ pl.col("weight").cast(pl.Float64)
531
+ )
532
+ tw_rep = {(str(p), str(t)): float(w) for p, t, w in tw.iter_rows()}
533
+
534
+ # Base blocks TILE the timeline: sort the rp_weights base starts by
535
+ # timeline position; each base block spans from its start to the next
536
+ # base start (the last runs to the timeline end). Deriving the length
537
+ # from the tiling handles a short trailing block.
538
+ base_starts = sorted(rpw.keys(), key=lambda s: idx.get(s, len(steps)))
539
+ base_ranges: list[tuple[str, int, int]] = [] # (base_start, start_idx, count)
540
+ for i, bs in enumerate(base_starts):
541
+ if bs not in idx:
542
+ raise ValueError(
543
+ f"RP fold: base block start {bs!r} not in timeline "
544
+ f"(period={period!r}, timeset={timeset!r})."
545
+ )
546
+ bstart_idx = idx[bs]
547
+ bend_idx = idx[base_starts[i + 1]] if i + 1 < len(base_starts) else len(steps)
548
+ base_ranges.append((bs, bstart_idx, bend_idx - bstart_idx))
549
+
550
+ # Aligned iff every base block is no longer than every rep block it maps to
551
+ # (offset h has a representative counterpart). Otherwise this is the
552
+ # representative_period_weights regime → identity fold on the rep grid.
553
+ aligned = all(
554
+ bcount <= rep_count.get(str(rep_start), 0)
555
+ for _bs, _si, bcount in base_ranges
556
+ for rep_start, w in rpw[_bs].items()
557
+ if w > 1e-12
558
+ )
559
+
560
+ if not aligned:
561
+ # Identity on the representative cells: the invest slack is already
562
+ # representative, so each rep cell maps to itself with weight 1.
563
+ edges = [(str(p), str(t), str(t), 1.0) for (p, t) in tw_rep]
564
+ return edges, tw_rep
565
+
566
+ edges: list[tuple[str, str, str, float]] = []
567
+ for bs, bstart_idx, bcount in base_ranges:
568
+ for rep_start, weight in rpw[bs].items():
569
+ if weight <= 1e-12:
570
+ continue
571
+ r_idx = idx.get(rep_start)
572
+ if r_idx is None:
573
+ raise ValueError(
574
+ f"RP fold: representative block start {rep_start!r} not in "
575
+ f"timeline (period={period!r}, timeset={timeset!r})."
576
+ )
577
+ for h in range(bcount):
578
+ edges.append(
579
+ (period, steps[bstart_idx + h], steps[r_idx + h], float(weight))
580
+ )
581
+ return edges, tw_rep
582
+
583
+
584
+ def _identity_edges_and_tw(
585
+ source: "SpineDbReader",
586
+ sc: "SolveConfig",
587
+ solve: str,
588
+ dt_complete: "pl.DataFrame",
589
+ periods: set[str] | None = None,
590
+ ) -> tuple[list[tuple[str, str, str, float]], dict[tuple[str, str], float]]:
591
+ """Identity fold (non-RP): each invest cell maps to itself, weight 1.
592
+
593
+ ``tw_rep`` is the per-cell ``p_timestep_weight`` (dense 1.0 or the
594
+ normalised ``timeset_weights``); *periods*, when given, restricts the grid
595
+ to the non-RP periods of a mixed solve.
596
+ """
597
+ grid = dt_complete
598
+ if periods is not None:
599
+ grid = grid.filter(pl.col("d").cast(pl.Utf8).is_in(list(periods)))
600
+ edges = [
601
+ (str(d), str(t), str(t), 1.0)
602
+ for d, t in grid.select("d", "t").iter_rows()
603
+ ]
604
+ param = p_timestep_weight_from_source(source, dt_complete, solve)
605
+ tw_rep: dict[tuple[str, str], float] = {}
606
+ if param is not None and param.frame.height > 0:
607
+ for d, t, v in param.frame.select("d", "t", "value").iter_rows():
608
+ tw_rep[(str(d), str(t))] = float(v)
609
+ # Any cell without an explicit weight defaults to the trivial 1.0.
610
+ for period, base_time, _rt, _w in edges:
611
+ tw_rep.setdefault((period, base_time), 1.0)
612
+ return edges, tw_rep
613
+
614
+
615
+ def timed_increments(
616
+ residual: dict[str, float],
617
+ dt_slack: dict[str, dict[tuple[str, str], float]],
618
+ url: str,
619
+ scenario: str,
620
+ *,
621
+ lam: float,
622
+ overshoot: float = 1.0,
623
+ tol: float = _SHED_TOL_MWH,
624
+ ) -> dict[str, dict[tuple[str, str], float]]:
625
+ """Return ``{node: {(period, time): adder}}`` for every SHEDDING node.
626
+
627
+ Assembles the RP (or identity) fold for *scenario*'s invest solve(s) from
628
+ the DB and applies the pure :func:`size_timed`. Each shedding node's
629
+ base-timeline slack supplies the stress SHAPE, normalised to the node's
630
+ TRUE annual residual and converted to a per-cell ``energy_margin_adder``
631
+ that (weighted by ``tw_rep``) injects exactly ``overshoot · λ · residual``
632
+ annual MWh — the same total as uniform, placed at the stressed hours.
633
+ ``overshoot`` (default ``1.0``) is the planning-margin safety multiplier.
634
+
635
+ The fold reuses the engine's own rep-weight machinery
636
+ (``rp_weights`` + ``_compute_rp_frames``), never the on-disk
637
+ ``timeline_matching_map.csv`` (the wrong, unreliable artifact).
638
+ """
639
+ url = _normalise_url(url)
640
+ sc = SolveConfig.load_from_db_url(url, scenario)
641
+ tc = TimelineConfig.load_from_db_url(url, scenario)
642
+ source = SpineDbReader(url, scenario)
643
+ invest_solves = _invest_solves(sc)
644
+ if not invest_solves:
645
+ raise ValueError(
646
+ f"scenario {scenario!r}: no model.solves — cannot resolve the "
647
+ "invest solve for timed sizing."
648
+ )
649
+
650
+ all_edges: list[tuple[str, str, str, float]] = []
651
+ tw_rep: dict[tuple[str, str], float] = {}
652
+ for solve in invest_solves:
653
+ active = sc.timesets_used_by_solves.get(solve, [])
654
+ rp_pairs = [(str(p), ts) for p, ts in active if ts in tc.rp_weights]
655
+ nonrp_periods = {str(p) for p, ts in active if ts not in tc.rp_weights}
656
+ if rp_pairs:
657
+ for period, ts in rp_pairs:
658
+ edges, tw = _rp_fold_edges_and_tw(tc, period, ts)
659
+ all_edges.extend(edges)
660
+ tw_rep.update(tw)
661
+ # Mixed solve: any non-RP period folds through the identity path.
662
+ if nonrp_periods:
663
+ agg = derive_per_solve_aggregates(source, solve)
664
+ if agg is not None:
665
+ edges, tw = _identity_edges_and_tw(
666
+ source, sc, solve, agg.dt_complete, nonrp_periods,
667
+ )
668
+ all_edges.extend(edges)
669
+ tw_rep.update(tw)
670
+ else:
671
+ agg = derive_per_solve_aggregates(source, solve)
672
+ if agg is None:
673
+ raise ValueError(
674
+ f"scenario {scenario!r}, solve {solve!r}: could not derive "
675
+ "the invest (d, t) grid for identity-fold timed sizing."
676
+ )
677
+ edges, tw = _identity_edges_and_tw(source, sc, solve, agg.dt_complete)
678
+ all_edges.extend(edges)
679
+ tw_rep.update(tw)
680
+
681
+ if not all_edges:
682
+ raise ValueError(
683
+ f"scenario {scenario!r}: timed sizing resolved no fold cells from "
684
+ "the invest solve(s)."
685
+ )
686
+ return size_timed(
687
+ dt_slack, residual, all_edges, tw_rep,
688
+ lam=lam, overshoot=overshoot, tol=tol,
689
+ )
690
+
691
+
692
+ __all__ = [
693
+ "invest_weight_W",
694
+ "scalar_adder",
695
+ "size_timed",
696
+ "sized_increments",
697
+ "timed_increments",
698
+ "w_from_grids",
699
+ ]