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,558 @@
1
+ """Driver skeleton for the adequacy-margin calibrator (C1a).
2
+
3
+ This is the loop that COMPOSES P1's ``energy_margin_adder`` knob and P2's
4
+ solve-success detector into an iterate-until-adequate cycle, plus the pure
5
+ readers of :mod:`flextool.calibrate._readers`. It runs end to end today —
6
+ solve, verify, read the per-node residual unserved energy — but it does
7
+ **not** yet size the adder or guard against over-build. Those are C1b
8
+ (sizing) and C1c (the over-build guard); this slice leaves a clean,
9
+ signature-stable seam for them in :func:`compute_step`.
10
+
11
+ Loop shape (per iteration ``k`` in ``range(iterations + 1)``; ``k=0`` is the
12
+ BASELINE)::
13
+
14
+ 1. write_calib_alt(url, scenario, adders) # k=0 writes empty/zero
15
+ 2. clear (or, in debug, archive) the prior output_parquet
16
+ 3. run = run_solve(...); outcome = assess_solve(run..., started_at=...)
17
+ -> not outcome.succeeded ⇒ raise CalibError (fail-closed)
18
+ 4. residual = read_residual_unserved(run.assess_dir)
19
+ curtailment, penalty read too; record the iteration
20
+ 5. total_unserved <= slack_threshold_mwh ⇒ converged, break
21
+ 6. increments = compute_step(..., W=W) # C1b sizing (no guard)
22
+ adders[node] += increment # bumps shedding nodes
23
+
24
+ The solve is *fail-closed*: an unverified solve (missing/stale/empty
25
+ required outputs, or an unoverridable nonzero exit) raises rather than
26
+ letting the calibrator step on numbers it cannot trust.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import shutil
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+
36
+ from flextool.calibrate._db_alt import calib_alt_name, write_calib_alt
37
+ from flextool.calibrate._guard import guard_freeze
38
+ from flextool.calibrate._readers import (
39
+ read_curtailment_by_sink,
40
+ read_residual_unserved,
41
+ read_residual_unserved_dt,
42
+ read_slack_penalty,
43
+ )
44
+ from flextool.calibrate._sizing import (
45
+ _SHED_TOL_MWH,
46
+ invest_weight_W,
47
+ sized_increments,
48
+ timed_increments,
49
+ )
50
+ from flextool.calibrate._solve import run_solve
51
+ from flextool.calibrate._solve_status import assess_solve
52
+
53
+
54
+ class CalibError(RuntimeError):
55
+ """Raised when an iteration's solve cannot be trusted (fail-closed).
56
+
57
+ Carries the solve-success detector's ``reason`` so the operator sees
58
+ exactly why the run was rejected (missing/stale outputs, unoverridable
59
+ nonzero exit, …) instead of a bare failure.
60
+ """
61
+
62
+
63
+ @dataclass
64
+ class CalibConfig:
65
+ """Configuration for a calibration run.
66
+
67
+ ``iterations`` — number of ADJUSTMENT iterations after the
68
+ baseline; the loop runs ``iterations + 1``
69
+ solves (iteration 0 is the baseline).
70
+ ``slack_threshold_mwh`` — total residual unserved energy at or below
71
+ which the run is considered converged.
72
+ ``damping_first`` /
73
+ ``damping_remaining`` — sizing damping factors (consumed by C1b).
74
+ ``overshoot`` — planning-margin SAFETY multiplier on every sized
75
+ increment (default 1.0 = off; >1 provisions
76
+ beyond the measured slack for unmodeled
77
+ multi-year risk; MODEL-DEPENDENT).
78
+ ``stall_fraction`` — over-build-guard STALL fraction (C1c): a shedding
79
+ node whose residual drops by less than this
80
+ fraction of its prior gap in response to its bump
81
+ is frozen as resource-capped. Higher freezes
82
+ sooner. Default 0.05.
83
+ ``over_build_tightness``— RETAINED for CLI/config compatibility only; the
84
+ C1c guard no longer gates on curtailment
85
+ efficiency, so this value is not consulted (the
86
+ freeze is driven by ``stall_fraction``).
87
+ ``warm_start_cache_dir``— stable basis-cache dir shared across
88
+ iterations.
89
+ ``work_dir`` — subprocess working directory.
90
+ ``out_root`` — output-location root; results land under
91
+ ``out_root/output_parquet/<scenario>/``.
92
+ ``debug`` — when True, archive each iteration's outputs to
93
+ ``out_root/out_iter_<k>/`` instead of clearing
94
+ them.
95
+ ``sizing`` — adder placement mode: ``"uniform"`` (default) —
96
+ a constant per-timestep margin sized ``λ·res/W``;
97
+ or ``"timed"`` — the same total energy placed
98
+ per-cell at the low-VRE stress hours (folded from
99
+ ``node_slack_up_dt_e``).
100
+ ``final_write_methods`` — output formats to regenerate from the final
101
+ surviving parquet AFTER the loop, without
102
+ re-solving (a subset of csv/excel/spinedb/plot;
103
+ parquet is always already present). Empty ⇒ leave
104
+ the parquet-only outputs as-is. Consumed by the
105
+ CLI, not the loop itself.
106
+ """
107
+
108
+ iterations: int
109
+ slack_threshold_mwh: float
110
+ damping_first: float
111
+ damping_remaining: float
112
+ over_build_tightness: float
113
+ warm_start_cache_dir: Path
114
+ work_dir: Path
115
+ out_root: Path
116
+ debug: bool = False
117
+ sizing: str = "uniform"
118
+ overshoot: float = 1.0
119
+ stall_fraction: float = 0.05
120
+ final_write_methods: tuple[str, ...] = ("csv",)
121
+
122
+
123
+ @dataclass
124
+ class IterRecord:
125
+ """One iteration's observed state (the calibration trajectory element).
126
+
127
+ ``adders`` is the per-node adder snapshot that was WRITTEN and SOLVED
128
+ for this iteration (captured before any increment is applied), so the
129
+ trajectory pairs each observation with the input that produced it. For
130
+ ``uniform`` sizing each value is a scalar float; for ``timed`` sizing it
131
+ is a ``{(period, time): float}`` per-cell map.
132
+
133
+ ``solve_seconds`` is this iteration's wall-clock solve time (end −
134
+ ``started_at``), so the report surfaces the per-iteration cost (and the
135
+ warm-start speedup across iterations) directly.
136
+ """
137
+
138
+ iteration: int
139
+ adders: "dict[str, float | dict[tuple[str, str], float]]"
140
+ residual: dict[str, float]
141
+ curtailment: dict[str, float]
142
+ penalty_total: float
143
+ penalty_by_node: dict[str, float]
144
+ solve_seconds: float = 0.0
145
+
146
+ @property
147
+ def total_unserved(self) -> float:
148
+ """Total residual unserved energy (MWh) across all nodes."""
149
+ return float(sum(self.residual.values()))
150
+
151
+
152
+ @dataclass
153
+ class CalibResult:
154
+ """Outcome of a calibration run.
155
+
156
+ ``converged`` — whether total unserved fell to/under the
157
+ threshold within the iteration budget.
158
+ ``stop_reason`` — the finer three-way signal of WHY the loop
159
+ stopped: ``"converged"`` (total unserved met
160
+ the threshold), ``"stalled"`` (no further bump
161
+ was possible — every remaining shedding node is
162
+ resource-capped, so the demand-margin lever is
163
+ exhausted — while still above threshold), or
164
+ ``"budget_exhausted"`` (ran the full
165
+ ``iterations`` budget without converging or
166
+ stalling). ``converged`` stays ``True`` only
167
+ for the threshold case, so downstream flags keep
168
+ working; ``stop_reason`` distinguishes the two
169
+ non-converged exits.
170
+ ``iterations_run`` — number of solves actually performed.
171
+ ``final_adders`` — the per-node adders after the last step.
172
+ ``trajectory`` — per-iteration :class:`IterRecord` list.
173
+ ``guard_flagged_nodes`` — nodes the over-build guard flagged (C1c;
174
+ empty for now).
175
+ """
176
+
177
+ converged: bool
178
+ iterations_run: int
179
+ final_adders: dict[str, float]
180
+ trajectory: list[IterRecord]
181
+ guard_flagged_nodes: list[str] = field(default_factory=list)
182
+ stop_reason: str = "budget_exhausted"
183
+
184
+
185
+ def compute_step(
186
+ residual: dict[str, float],
187
+ penalty_by_node: dict[str, float],
188
+ prev_record: IterRecord | None,
189
+ config: CalibConfig,
190
+ *,
191
+ W: float,
192
+ flagged: set[str],
193
+ url: str | None = None,
194
+ scenario: str | None = None,
195
+ dt_slack: "dict[str, dict[tuple[str, str], float]] | None" = None,
196
+ ) -> "tuple[dict[str, float | dict[tuple[str, str], float]], set[str]]":
197
+ """Compute per-node adder INCREMENTS for the next iteration.
198
+
199
+ **C1b — sizing.** Each shedding node's residual unserved energy is
200
+ converted into an ``energy_margin_adder`` increment that (undamped) would
201
+ inject that residual annual MWh back as demand, in one of two modes:
202
+
203
+ * ``config.sizing == "uniform"`` — a CONSTANT per-timestep increment
204
+ ``increment(node) = λ · residual(node) / W`` (see
205
+ :func:`flextool.calibrate._sizing.sized_increments`); ``W`` is the
206
+ invest-timeline annualisation weight, computed ONCE by the loop.
207
+ * ``config.sizing == "timed"`` — the SAME total energy folded from the
208
+ node's ``node_slack_up_dt_e`` stress profile onto the representative
209
+ cells, so ``increment(node)`` is a ``{(period, time): float}`` map placed
210
+ at the stressed hours (see
211
+ :func:`flextool.calibrate._sizing.timed_increments`). Requires *url*,
212
+ *scenario* and this iteration's *dt_slack* profile.
213
+
214
+ Every sized increment carries the ``config.overshoot`` planning-margin
215
+ SAFETY multiplier (default 1.0 = off; >1 provisions beyond the measured
216
+ slack) — applied identically in both sizing modes.
217
+
218
+ ``λ`` is the damping factor: ``config.damping_first`` on the FIRST
219
+ correction (no prior bump yet — ``prev_record is None``) and
220
+ ``config.damping_remaining`` thereafter. Non-shedding nodes get no
221
+ increment.
222
+
223
+ **C1c — over-build guard.** On top of sizing this step:
224
+
225
+ 1. drops any node already in *flagged* — a node flagged resource-capped
226
+ stays frozen for the rest of the run and is never bumped again;
227
+ 2. from the SECOND correction onward (``prev_record is not None``) runs
228
+ :func:`flextool.calibrate._guard.guard_freeze` to REMOVE and FLAG any
229
+ node whose residual FAILED to respond to its prior bump — the freeze
230
+ keys off *residual* (this iteration) and *prev_record.residual* (the
231
+ prior iteration): a node that was shedding last round
232
+ (``prev_record.residual > shed_tol``, the same tolerance the sizer used
233
+ above) but whose gap dropped by less than ``config.stall_fraction`` of
234
+ its prior value is resource-capped (margin buys it no adequacy) and is
235
+ frozen. Curtailment is NOT consulted (a demand node never curtails, so
236
+ keying the freeze on curtailment could never flag it). A node that only
237
+ STARTS shedding this round was never bumped, so it gets its first bump
238
+ rather than being frozen. On the first correction there is no prior to
239
+ diff, so nothing is flagged and every shedding node is bumped.
240
+
241
+ ``penalty_by_node`` is this iteration's monetised slack, carried for
242
+ reporting/diagnostics.
243
+
244
+ Returns ``(increments, newly_flagged)``: ``{node: increment_MWh}`` to ADD
245
+ to the running adders (a missing node means "no change"), and the set of
246
+ nodes newly flagged this round for the loop to union into its persistent
247
+ flagged set.
248
+ """
249
+ lam = config.damping_first if prev_record is None else config.damping_remaining
250
+ if config.sizing == "timed":
251
+ if url is None or scenario is None or dt_slack is None:
252
+ raise ValueError(
253
+ "timed sizing requires url, scenario and dt_slack to be "
254
+ "threaded into compute_step."
255
+ )
256
+ increments = timed_increments(
257
+ residual, dt_slack, url, scenario,
258
+ lam=lam, overshoot=config.overshoot,
259
+ )
260
+ else:
261
+ increments = sized_increments(
262
+ residual, W=W, lam=lam, overshoot=config.overshoot,
263
+ )
264
+ # A persistently-flagged node is resource-capped: never bump it again.
265
+ increments = {n: v for n, v in increments.items() if n not in flagged}
266
+
267
+ # The guard can only diff against a prior iteration, so it acts from the
268
+ # SECOND correction onward; the first correction bumps all shedding nodes.
269
+ if prev_record is None:
270
+ return increments, set()
271
+
272
+ return guard_freeze(
273
+ increments,
274
+ residual=residual,
275
+ prev_residual=prev_record.residual,
276
+ stall_fraction=config.stall_fraction,
277
+ # Same shedding tolerance the sizer used above, so guard and sizer
278
+ # agree on which nodes were shedding (and thus bumped) last round.
279
+ shed_tol=_SHED_TOL_MWH,
280
+ )
281
+
282
+
283
+ def _dt_slack_lookup(
284
+ assess_dir: Path,
285
+ ) -> dict[str, dict[tuple[str, str], float]]:
286
+ """Read ``node_slack_up_dt_e`` into a ``{node: {(period, time): slack}}``
287
+ lookup for the timed sizer (drops null / zero cells)."""
288
+ out: dict[str, dict[tuple[str, str], float]] = {}
289
+ for node, frame in read_residual_unserved_dt(assess_dir).items():
290
+ cells: dict[tuple[str, str], float] = {}
291
+ for period, time_, value in frame.itertuples(index=False):
292
+ v = float(value)
293
+ if v != 0.0:
294
+ cells[(str(period), str(time_))] = v
295
+ if cells:
296
+ out[str(node)] = cells
297
+ return out
298
+
299
+
300
+ def _copy_adders(
301
+ adders: "dict[str, float | dict[tuple[str, str], float]]",
302
+ ) -> "dict[str, float | dict[tuple[str, str], float]]":
303
+ """Snapshot the adder state (per-cell maps copied, scalars passed through)."""
304
+ return {
305
+ node: (dict(val) if isinstance(val, dict) else val)
306
+ for node, val in adders.items()
307
+ }
308
+
309
+
310
+ def _accumulate_adder(
311
+ adders: "dict[str, float | dict[tuple[str, str], float]]",
312
+ node: str,
313
+ inc: "float | dict[tuple[str, str], float]",
314
+ ) -> None:
315
+ """Add *inc* into ``adders[node]`` in place.
316
+
317
+ Scalar increments accumulate arithmetically (uniform sizing); per-cell map
318
+ increments accumulate CELL-WISE (timed sizing), so a node's stressed cells
319
+ keep rising across corrections just as the scalar adder does.
320
+ """
321
+ if isinstance(inc, dict):
322
+ cur = adders.get(node)
323
+ if not isinstance(cur, dict):
324
+ cur = {}
325
+ for cell, v in inc.items():
326
+ cur[cell] = cur.get(cell, 0.0) + v
327
+ adders[node] = cur
328
+ else:
329
+ base = adders.get(node, 0.0)
330
+ adders[node] = (base if isinstance(base, float) else 0.0) + inc
331
+
332
+
333
+ def _prepare_out_root(out_root: Path, iteration: int, debug: bool) -> None:
334
+ """Clear (or, in debug, archive) the prior ``output_parquet`` tree.
335
+
336
+ A clean output directory each iteration is what makes P2's freshness
337
+ check meaningful: a genuinely failed solve skips output writing and so
338
+ would leave the previous iteration's files in place. In debug mode the
339
+ prior tree is preserved under ``out_iter_<k-1>/`` for inspection; a
340
+ stray tree present before the baseline (k=0) is simply removed.
341
+ """
342
+ prior = Path(out_root) / "output_parquet"
343
+ if not prior.exists():
344
+ return
345
+ if debug and iteration > 0:
346
+ dest = Path(out_root) / f"out_iter_{iteration - 1}"
347
+ if dest.exists():
348
+ shutil.rmtree(dest)
349
+ shutil.move(str(prior), str(dest))
350
+ else:
351
+ shutil.rmtree(prior)
352
+
353
+
354
+ def run_calibration(
355
+ url: str, scenario: str, config: CalibConfig,
356
+ ) -> CalibResult:
357
+ """Run the adequacy-margin calibration loop for *scenario*.
358
+
359
+ Solves ``config.iterations + 1`` times (iteration 0 is the baseline),
360
+ verifying every solve with P2's detector and reading the per-node
361
+ residual unserved energy each time. With the C1b :func:`compute_step`
362
+ each shedding node's residual is sized into an ``energy_margin_adder``
363
+ increment (``λ · residual / W``) and accumulated, so the run actually
364
+ raises adders and drives slack down across iterations. C1c adds the
365
+ over-build guard on top without changing the loop.
366
+
367
+ Raises
368
+ ------
369
+ CalibError
370
+ If any iteration's solve cannot be trusted (fail-closed).
371
+ """
372
+ # W — the invest-timeline annualisation weight; a fixed property of the
373
+ # scenario's invest grid, so compute it ONCE and thread it to every
374
+ # sizing step. Reads the DB only here (see _sizing.invest_weight_W).
375
+ W = invest_weight_W(url, scenario)
376
+
377
+ adders: dict[str, float] = {}
378
+ trajectory: list[IterRecord] = []
379
+ prev_record: IterRecord | None = None
380
+ # C1c over-build guard state: the PERSISTENT set of nodes frozen as
381
+ # resource-capped (never bumped again once flagged). The freeze keys off
382
+ # the residual RESPONSE to a bump, not curtailment, so no baseline spill is
383
+ # tracked.
384
+ flagged: set[str] = set()
385
+ converged = False
386
+ # Why the loop stopped; stays "budget_exhausted" unless an exit below
387
+ # sets it (threshold met -> "converged"; no bump possible -> "stalled").
388
+ stop_reason = "budget_exhausted"
389
+ iterations_run = 0
390
+ # How many nodes were bumped by the PRIOR iteration's step; drives the
391
+ # "bumping N node(s)" figure in the next iteration's pre-solve banner
392
+ # (purely observational — see the progress prints below).
393
+ last_bump_count = 0
394
+
395
+ for k in range(config.iterations + 1):
396
+ # Progress banner (streamed to the GUI's live console). k=0 is the
397
+ # baseline; from k=1 on it recalls the prior iteration's unserved and
398
+ # how many nodes were bumped into this solve. print(flush=True) is
399
+ # DELIBERATE: the deep solve dispatch redirects stdout for its own
400
+ # banner and swallows logger.info, so only a flushed print reliably
401
+ # reaches the streamed console.
402
+ if k == 0:
403
+ print(
404
+ f"====== calibrate[{scenario}] iteration "
405
+ f"0/{config.iterations} — baseline solve ======",
406
+ flush=True,
407
+ )
408
+ else:
409
+ prior_unserved = trajectory[-1].total_unserved
410
+ print(
411
+ f"====== calibrate[{scenario}] iteration "
412
+ f"{k}/{config.iterations} — prior unserved "
413
+ f"{prior_unserved:.1f} MWh, bumping {last_bump_count} "
414
+ f"node(s) ======",
415
+ flush=True,
416
+ )
417
+
418
+ # 1. Materialise the current adders in the calibration alternative
419
+ # (k=0 writes an empty/zero alt so the scenario is solved THROUGH
420
+ # the calib alt from the very first iteration; the alt stack is
421
+ # then constant across the run).
422
+ write_calib_alt(url, scenario, adders)
423
+
424
+ # 2. Give this iteration a clean output dir (archive in debug mode).
425
+ _prepare_out_root(config.out_root, k, config.debug)
426
+
427
+ # 3. Solve, then verify fail-closed (always pass started_at).
428
+ run = run_solve(
429
+ url,
430
+ scenario,
431
+ work_dir=config.work_dir,
432
+ out_root=config.out_root,
433
+ cache_dir=config.warm_start_cache_dir,
434
+ )
435
+ solve_seconds = time.time() - run.started_at
436
+ iterations_run = k + 1
437
+ outcome = assess_solve(
438
+ run.assess_dir,
439
+ exit_code=run.returncode,
440
+ started_at=run.started_at,
441
+ )
442
+ if not outcome.succeeded:
443
+ raise CalibError(
444
+ f"iteration {k} solve not trusted: {outcome.reason}"
445
+ )
446
+
447
+ # 4. Read the signals and record this iteration.
448
+ residual = read_residual_unserved(run.assess_dir)
449
+ curtailment = read_curtailment_by_sink(run.assess_dir)
450
+ penalty_total, penalty_by_node = read_slack_penalty(run.assess_dir)
451
+ # The per-cell stress profile is only needed for timed sizing.
452
+ dt_slack = (
453
+ _dt_slack_lookup(run.assess_dir)
454
+ if config.sizing == "timed"
455
+ else None
456
+ )
457
+ record = IterRecord(
458
+ iteration=k,
459
+ adders=_copy_adders(adders),
460
+ residual=residual,
461
+ curtailment=curtailment,
462
+ penalty_total=penalty_total,
463
+ penalty_by_node=penalty_by_node,
464
+ solve_seconds=solve_seconds,
465
+ )
466
+ trajectory.append(record)
467
+
468
+ def _emit_summary(newly_flagged_count: int) -> None:
469
+ """Print this iteration's one-line post-solve summary (streamed)."""
470
+ print(
471
+ f"------ iteration {k}/{config.iterations} done: "
472
+ f"{record.total_unserved:.1f} MWh unserved, "
473
+ f"{record.penalty_total:.1f} M€ penalty, "
474
+ f"{newly_flagged_count} node(s) newly flagged, "
475
+ f"{solve_seconds:.1f} s ------",
476
+ flush=True,
477
+ )
478
+
479
+ # 5. Converge on total residual unserved energy.
480
+ if record.total_unserved <= config.slack_threshold_mwh:
481
+ converged = True
482
+ stop_reason = "converged"
483
+ _emit_summary(0)
484
+ break
485
+
486
+ # 6. Size the next step, apply the over-build guard, and accumulate —
487
+ # but ONLY when a further solve will VALIDATE it. On the FINAL
488
+ # iteration (k == config.iterations) there is no subsequent solve,
489
+ # so applying an increment here would leave a phantom, unverified
490
+ # adder in final_adders that _report.py would mispair against the
491
+ # LAST solve's residual (the misleading budget_exhausted case).
492
+ # Skipping the step keeps final_adders == the adders actually SOLVED
493
+ # at the last iteration; converged/stalled still break earlier, so
494
+ # only the terminal budget_exhausted path changes.
495
+ if k < config.iterations:
496
+ increments, newly_flagged = compute_step(
497
+ residual, penalty_by_node, prev_record, config,
498
+ W=W, flagged=flagged,
499
+ url=url, scenario=scenario, dt_slack=dt_slack,
500
+ )
501
+ # A newly-flagged node is resource-capped from here on: persist it
502
+ # so no future iteration bumps it again. Union FIRST so the
503
+ # flagged set is complete before the stall check reads it.
504
+ flagged |= newly_flagged
505
+ _emit_summary(len(newly_flagged))
506
+
507
+ # Early stop — STALLED. An empty increment set means no node will
508
+ # be bumped this round: either every remaining shedding node is
509
+ # flagged resource-capped, or no single node exceeds the per-node
510
+ # sizing tolerance. Since the adders are then frozen and the guard
511
+ # set is monotone, the next solve would run on an IDENTICAL model
512
+ # and no future iteration could differ. We are above the threshold
513
+ # (the converge check above did not fire), so this is the
514
+ # "converged modulo the resource-capped nodes" stop: end the run
515
+ # now instead of burning the rest of the --iterations budget on
516
+ # identical solves.
517
+ if not increments:
518
+ stop_reason = "stalled"
519
+ break
520
+
521
+ for node, inc in increments.items():
522
+ _accumulate_adder(adders, node, inc)
523
+ prev_record = record
524
+ last_bump_count = len(increments)
525
+ else:
526
+ # Final iteration: no subsequent solve validates a step, so none is
527
+ # taken — but the solve still happened, so summarise it (no node can
528
+ # be newly flagged here).
529
+ _emit_summary(0)
530
+
531
+ # Final status line reflecting how the loop terminated (streamed).
532
+ print(
533
+ f"====== calibrate[{scenario}] finished: {stop_reason} "
534
+ f"(converged={converged}), {iterations_run} iteration(s), "
535
+ f"{len(flagged)} node(s) flagged resource-capped ======",
536
+ flush=True,
537
+ )
538
+
539
+ return CalibResult(
540
+ converged=converged,
541
+ stop_reason=stop_reason,
542
+ iterations_run=iterations_run,
543
+ final_adders=dict(adders),
544
+ trajectory=trajectory,
545
+ guard_flagged_nodes=sorted(flagged),
546
+ )
547
+
548
+
549
+ __all__ = [
550
+ "CalibConfig",
551
+ "CalibError",
552
+ "CalibResult",
553
+ "IterRecord",
554
+ "calib_alt_name",
555
+ "compute_step",
556
+ "guard_freeze",
557
+ "run_calibration",
558
+ ]