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,649 @@
1
+ """Reserve subsystem: per-(reserve, upDown, group) reservation balance and
2
+ per-(process, reserve, upDown, node) reserve allocation upper bounds.
3
+
4
+ Self-contained module that mirrors the four reserve constraints in
5
+ ``flextool.mod`` and the ``vq_reserve`` slack term in the objective:
6
+
7
+ * ``reserveBalance_timeseries_eq`` (mod 2408-2431)
8
+ * ``reserveBalance_dynamic_eq`` (mod 2433-2464)
9
+ * ``reserveBalance_up_n_1_eq`` (mod 2466-2496)
10
+ * ``reserveBalance_down_n_1_eq`` (mod 2498-2532)
11
+ * ``reserve_process_upward`` (mod 3451-3472)
12
+ * ``reserve_process_downward`` (mod 3474-3486)
13
+ * ``vq_reserve`` slack penalty in the objective (mod 2100-2101 / objective_audit.md §9.4)
14
+
15
+ Variables introduced by ``add_variables``/``add_constraints``:
16
+
17
+ v_reserve [(p, r, ud, n, d, t)] in prundt (>= 0)
18
+ vq_reserve[(r, ud, ng, d, t)] in reserve__upDown__group × dt (>= 0, <= 1)
19
+
20
+ Module API (mirrors what ``_group_slack`` is expected to look like):
21
+
22
+ has_feature(d) -> bool
23
+ load_data(inp, sd, ...) -> dict of new FlexData fields (caller merges)
24
+ add_variables(m, d) -> dict of new vars (v_reserve, vq_reserve)
25
+ add_constraints(m, d, vars) -> emits the six reserve constraints
26
+ add_objective_terms(m, d, vars, op_factor) -> Expr (+vq_reserve penalty term)
27
+
28
+ Pending downstream patches (FOR A4 / merge agent — listed in
29
+ ``audit/integration_manifest.md``): the .mod also adds
30
+ ``+ Σ v_reserve * unitsize`` LHS terms to the existing ``maxFlow``,
31
+ ``ramp_*``, and ``profile_flow_*`` constraints. Wiring those couplings
32
+ requires *editing those constraints in flextool/model.py*, which this
33
+ module deliberately does not do. See the manifest for the explicit list
34
+ of LHS terms to add.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from pathlib import Path
40
+ import polars as pl
41
+
42
+ from polar_high import Sum, Where, Param
43
+ from polar_high.engine import Var
44
+
45
+ from ._axis_enums import rename_to_axis, schema_dtype
46
+ from ._emit_provider_io import _provider_key
47
+
48
+
49
+ def _provider_get(provider, path: "Path") -> "pl.DataFrame | None":
50
+ """Provider-only fetch. Returns ``None`` when the Provider is
51
+ missing or doesn't carry *path*'s canonical key.
52
+ """
53
+ if provider is None:
54
+ return None
55
+ key = _provider_key(path)
56
+ if not provider.has(key):
57
+ return None
58
+ return provider.get(key)
59
+
60
+ # Substrate handle for the cascade-wide axis enum vocabulary.
61
+ # Bare ``None`` here; ``cast_dim`` / ``schema_dtype`` in
62
+ # ``_axis_enums`` fall back to ``_LIVE_AXIS_ENUMS_CTX`` (the live
63
+ # ContextVar) when this is ``None``, so substrate sites pick up
64
+ # activation set by ``load_flextool`` automatically.
65
+ _enums: "dict | None" = None
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Field requirements
70
+ # These are the names this module will look up on FlexData (``d``). The
71
+ # caller's loader is expected to populate them via load_data() (then merge
72
+ # the returned dict into FlexData).
73
+
74
+ RESERVE_FIELDS: tuple[str, ...] = (
75
+ # core sets
76
+ "reserve_upDown_group", # (r, ud, g) — present iff feature active
77
+ "prundt", # (p, r, ud, n, d, t) — v_reserve domain
78
+ "process_reserve_upDown_node_active", # (p, r, ud, n)
79
+ "group_node", # (g, n)
80
+ # method partitions of reserve__upDown__group
81
+ "reserve_upDown_group_method_timeseries", # (r, ud, g, method) — may be empty
82
+ "reserve_upDown_group_method_dynamic", # (r, ud, g, method)
83
+ "reserve_upDown_group_method_n_1", # (r, ud, g, method)
84
+ # parameters
85
+ "p_process_reserve_upDown_node_reliability", # (p, r, ud, n) — already coalesced
86
+ "pdtReserve_upDown_group_reservation", # (r, ud, g, d, t) — RHS demand
87
+ "p_reserve_upDown_group_penalty_reserve", # (r, ud, g) — objective penalty
88
+ )
89
+
90
+
91
+ def has_feature(d) -> bool:
92
+ """Reserve subsystem is active iff at least one (r, ud, g) tuple exists
93
+ in ``reserve__upDown__group``. An empty file (column headers only) is
94
+ treated as inactive.
95
+
96
+ Mirrors the convention used by every other feature gate in
97
+ ``flextool.model.build_flextool``."""
98
+ rug = getattr(d, "reserve_upDown_group", None)
99
+ return rug is not None and rug.height > 0
100
+
101
+
102
+ def _check(d, fields: tuple[str, ...]) -> None:
103
+ """Same fail-fast contract as ``flextool.model._check``."""
104
+ missing = [f for f in fields if getattr(d, f, None) is None]
105
+ if missing:
106
+ raise ValueError(
107
+ f"_reserve: feature is active but data fields are not populated "
108
+ f"(None): {missing}. Populate them via _reserve.load_data()."
109
+ )
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Loader
114
+
115
+ def load_data(inp: Path | str, sd: Path | str,
116
+ dt: pl.DataFrame | None = None,
117
+ *, provider=None) -> dict:
118
+ """Read all reserve CSVs from ``inp/`` (input) and ``sd/`` (solve_data),
119
+ return a plain dict of ``field_name -> Param-or-DataFrame``.
120
+
121
+ The caller is expected to merge this dict into FlexData (e.g. via
122
+ ``setattr(d, k, v)``). Returns an empty dict if reserves are not
123
+ active in this scenario.
124
+
125
+ Parameters
126
+ ----------
127
+ inp : Path
128
+ ``input/`` directory (raw CSVs from preprocessing).
129
+ sd : Path
130
+ ``solve_data/`` directory (preprocessed / Python-emitted CSVs).
131
+ dt : DataFrame, optional
132
+ Time index ``(d, t)``. Used to clip pdtReserve_upDown_group to
133
+ rows actually present in the dispatch horizon. If None, the
134
+ loader keeps every row.
135
+ """
136
+ inp = Path(inp)
137
+ sd = Path(sd)
138
+ out: dict = {}
139
+
140
+ # ── Core (r, ud, g) set ─────────────────────────────────────────────
141
+ rug_path = sd / "reserve__upDown__group.csv"
142
+ rug = _provider_get(provider, rug_path)
143
+ if rug is None or rug.height == 0:
144
+ return out
145
+ rug = rug.pipe(rename_to_axis, {"reserve": "r", "upDown": "ud", "group": "g"}) \
146
+ .select("r", "ud", "g")
147
+ out["reserve_upDown_group"] = rug
148
+
149
+ # ── Method partitions ──────────────────────────────────────────────
150
+ for method, attr in [
151
+ ("timeseries", "reserve_upDown_group_method_timeseries"),
152
+ ("dynamic", "reserve_upDown_group_method_dynamic"),
153
+ ("n_1", "reserve_upDown_group_method_n_1"),
154
+ ]:
155
+ path = sd / f"reserve__upDown__group__method_{method}.csv"
156
+ df = _provider_get(provider, path)
157
+ if df is not None and df.height > 0:
158
+ df = df.pipe(rename_to_axis, {"reserve": "r", "upDown": "ud", "group": "g"}) \
159
+ .select("r", "ud", "g", "method")
160
+ else:
161
+ df = pl.DataFrame(schema={
162
+ "r": schema_dtype(_enums, "r"),
163
+ "ud": schema_dtype(_enums, "ud"),
164
+ "g": schema_dtype(_enums, "g"),
165
+ "method": pl.Utf8})
166
+ out[attr] = df
167
+
168
+ # Δ.12-drop: ``prundt`` produced authoritatively by
169
+ # ``apply_derived_g.prundt_from_source``. Seed dropped.
170
+ # Δ.12-drop: ``process_reserve_upDown_node_active`` produced
171
+ # authoritatively by ``apply_derived_d``
172
+ # (``process_reserve_upDown_node_active_from_source``). Seed dropped.
173
+
174
+ # ── process_reserve_upDown_node_increase_reserve_ratio (dynamic RHS) ─
175
+ irr_path = sd / "process_reserve_upDown_node_increase_reserve_ratio.csv"
176
+ irr = _provider_get(provider, irr_path)
177
+ if irr is not None:
178
+ if irr.height > 0:
179
+ irr = irr.pipe(rename_to_axis, {"process": "p", "reserve": "r",
180
+ "upDown": "ud", "node": "n"}) \
181
+ .select("p", "r", "ud", "n")
182
+ else:
183
+ irr = pl.DataFrame(schema={
184
+ "p": schema_dtype(_enums, "p"),
185
+ "r": schema_dtype(_enums, "r"),
186
+ "ud": schema_dtype(_enums, "ud"),
187
+ "n": schema_dtype(_enums, "n")})
188
+ out["process_reserve_upDown_node_increase_reserve_ratio"] = irr
189
+
190
+ # ── process_reserve_upDown_node_large_failure_ratio (n-1 RHS) ──────
191
+ lfr_path = sd / "process_reserve_upDown_node_large_failure_ratio.csv"
192
+ lfr = _provider_get(provider, lfr_path)
193
+ if lfr is not None:
194
+ if lfr.height > 0:
195
+ lfr = lfr.pipe(rename_to_axis, {"process": "p", "reserve": "r",
196
+ "upDown": "ud", "node": "n"}) \
197
+ .select("p", "r", "ud", "n")
198
+ else:
199
+ lfr = pl.DataFrame(schema={
200
+ "p": schema_dtype(_enums, "p"),
201
+ "r": schema_dtype(_enums, "r"),
202
+ "ud": schema_dtype(_enums, "ud"),
203
+ "n": schema_dtype(_enums, "n")})
204
+ out["process_reserve_upDown_node_large_failure_ratio"] = lfr
205
+
206
+ # ── group_node ────────────────────────────────────────────────────
207
+ # Canonical preprocessing target: solve_data/group_node.csv.
208
+ # Fallback: input/group__node.csv (raw user input). Mirrors the
209
+ # defensive read in _group_slack.py:389. Single-source non-defensive
210
+ # reads are vulnerable to the bug class fixed upstream in flextool
211
+ # 042fae23 (preprocessing path typo silently dropped group data).
212
+ gn = None
213
+ gn_paths = (sd / "group_node.csv", inp / "group__node.csv")
214
+ seen_any = False
215
+ for path in gn_paths:
216
+ df = _provider_get(provider, path)
217
+ if df is None:
218
+ continue
219
+ seen_any = True
220
+ if df.height > 0:
221
+ gn = df.pipe(rename_to_axis, {"group": "g", "node": "n"}).select("g", "n").unique()
222
+ break
223
+ if gn is not None:
224
+ out["group_node"] = gn
225
+ elif seen_any:
226
+ out["group_node"] = pl.DataFrame(schema={
227
+ "g": schema_dtype(_enums, "g"),
228
+ "n": schema_dtype(_enums, "n")})
229
+
230
+ # Δ.12-drop: ``p_process_reserve_upDown_node_reliability`` produced
231
+ # authoritatively by ``apply_direct_params`` (Δ.4b). Seed dropped.
232
+
233
+ # ── pdtReserve_upDown_group: long-format reservation timeseries ───
234
+ pdtR_path = sd / "pdtReserve_upDown_group.csv"
235
+ pdtR = _provider_get(provider, pdtR_path)
236
+ if pdtR is not None:
237
+ if pdtR.height > 0:
238
+ pdtR = pdtR.pipe(rename_to_axis, {"reserve": "r", "upDown": "ud",
239
+ "group": "g", "period": "d", "time": "t"})
240
+ res_only = (pdtR.filter(pl.col("param") == "reservation")
241
+ .select("r", "ud", "g", "d", "t", "value")
242
+ .with_columns(value=pl.col("value")
243
+ .cast(pl.Float64, strict=False)
244
+ .fill_null(0.0)))
245
+ if dt is not None and dt.height > 0:
246
+ res_only = res_only.join(dt, on=["d", "t"], how="inner")
247
+ out["pdtReserve_upDown_group_reservation"] = Param(
248
+ ("r", "ud", "g", "d", "t"), res_only)
249
+
250
+ # Δ.12-drop: ``p_reserve_upDown_group_penalty_reserve`` /
251
+ # ``p_process_reserve_upDown_node_max_share`` /
252
+ # ``p_process_reserve_upDown_node_large_failure_ratio_value`` /
253
+ # ``p_process_reserve_upDown_node_increase_reserve_ratio_value``
254
+ # produced authoritatively by ``apply_direct_params`` (Δ.4b).
255
+ # Seeds dropped.
256
+
257
+ return out
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # Variables
262
+
263
+ def add_variables(m, d) -> dict:
264
+ """Declare ``v_reserve`` and ``vq_reserve`` on the problem ``m``.
265
+
266
+ Returns the two Var objects keyed by name so the constraint emitter
267
+ (and the merge agent's downstream LHS patches) can reference them
268
+ without re-declaring."""
269
+ if not has_feature(d):
270
+ return {}
271
+ _check(d, ("reserve_upDown_group", "prundt", "dt"))
272
+
273
+ # v_reserve domain = prundt (only entries that the preprocessor flagged)
274
+ v_reserve = m.add_var("v_reserve",
275
+ ("p", "r", "ud", "n", "d", "t"),
276
+ d.prundt, lower=0.0)
277
+
278
+ # vq_reserve: domain = reserve__upDown__group × dt. Cap at 1 to match
279
+ # the .mod's ``var vq_reserve … <= 1`` declaration.
280
+ rug_dt = d.reserve_upDown_group.join(d.dt, how="cross")
281
+ vq_reserve = m.add_var("vq_reserve",
282
+ ("r", "ud", "g", "d", "t"),
283
+ rug_dt, lower=0.0, upper=1.0)
284
+
285
+ return {"v_reserve": v_reserve, "vq_reserve": vq_reserve}
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Constraints
290
+
291
+ def _flow_node_view(v_flow: "Var", side: str) -> "Var":
292
+ """Return a ``v_flow`` *view* whose native ``side`` axis
293
+ (``"source"`` or ``"sink"``) is relabelled to ``n``.
294
+
295
+ The dynamic / n-1 reserve RHS sum ``v_flow`` over flows whose landing
296
+ node coincides with a reserve node ``n``. ``v_flow`` is declared over
297
+ ``(p, source, sink, d, t)``; joining its restriction frame on a
298
+ separately-renamed ``n`` column leaves the flow's own ``source`` /
299
+ ``sink`` axis untied and un-summed, so ``canonicalise()`` rejects the
300
+ term with an *open dims* error. Relabelling the relevant flow axis to
301
+ ``n`` here ties it to the reserve node (and to the ratio Param's ``n``)
302
+ so the enclosing ``Sum`` collapses it cleanly. ``rename_to_axis``
303
+ preserves the underlying LP column ids, so the view references the
304
+ same ``v_flow`` columns and keeps the ``"v_flow"`` name for autoscale.
305
+ """
306
+ dims = tuple("n" if c == side else c for c in v_flow.dims)
307
+ return Var(
308
+ name=v_flow.name,
309
+ dims=dims,
310
+ frame=v_flow.frame.pipe(rename_to_axis, {side: "n"}),
311
+ )
312
+
313
+
314
+ def add_constraints(m, d, vars: dict) -> None:
315
+ """Emit reserve constraints. ``vars`` is the dict returned by
316
+ ``add_variables`` (keys ``v_reserve``, ``vq_reserve``). ``m``,
317
+ ``d`` are the ``Problem`` and ``FlexData``.
318
+
319
+ Caller-provided dependencies on FlexData:
320
+ * ``v_flow`` — read off ``vars["v_flow"]`` if present (needed for
321
+ the dynamic and n-1 RHS terms). If ``v_flow`` is missing, those
322
+ constraint families are emitted with a constant-zero RHS, which
323
+ matches the .mod's ``sum {} … = 0`` behaviour when the underlying
324
+ sets are empty.
325
+ * ``p_unitsize`` — read off ``d.p_unitsize`` (Param keyed on ``p``).
326
+ * ``p_slope`` — read off ``d.p_slope`` (Param keyed on (p, d, t)).
327
+ """
328
+ if not has_feature(d):
329
+ return
330
+ _check(d, ("reserve_upDown_group", "prundt", "dt",
331
+ "process_reserve_upDown_node_active", "group_node"))
332
+
333
+ v_reserve = vars["v_reserve"]
334
+ vq_reserve = vars["vq_reserve"]
335
+ p_unitsize = d.p_unitsize
336
+ rug = d.reserve_upDown_group # (r, ud, g)
337
+ rug.join(d.dt, how="cross") # (r, ud, g, d, t)
338
+ pruna = d.process_reserve_upDown_node_active # (p, r, ud, n)
339
+ gn = d.group_node.pipe(rename_to_axis, {"n": "n"}) # (g, n)
340
+
341
+ # ── LHS (shared across all four reserveBalance variants) ────────────
342
+ #
343
+ # + Σ_{(p,r,ud,n) ∈ pruna : (g,n) ∈ group_node, (r,ud,g) ∈ rug}
344
+ # v_reserve[p,r,ud,n,d,t] · unitsize[p] · reliability[p,r,ud,n]
345
+ # + Σ … (1var-per-way + source case, multiplied by pdtProcess_slope)
346
+ # + vq_reserve · pdtReserve_reservation
347
+ #
348
+ # Without the slope distinction (V1 simplification — see manifest TODO
349
+ # for the eventual 1var-per-way + source split), we collapse both
350
+ # process partitions into a single sum without the slope factor. This
351
+ # is exact whenever no reserve-providing processes are 1var_per_way
352
+ # (the common case in the test fixtures). The slope-bearing partition
353
+ # can be added by joining ``pruna`` against ``method_1var_per_way`` and
354
+ # ``process_source`` to split the sum and applying ``d.p_slope`` to
355
+ # the source-side rows only.
356
+
357
+ # Restrict pruna to (p, r, ud, n) with (g, n) ∈ group_node and
358
+ # (r, ud, g) ∈ rug (=> bring g into the dim set).
359
+ pruna_g = (pruna.join(gn, on="n", how="inner") # adds g
360
+ .join(rug, on=["r", "ud", "g"], how="inner")
361
+ .select("p", "r", "ud", "n", "g"))
362
+
363
+ if (d.p_process_reserve_upDown_node_reliability is not None
364
+ and pruna_g.height > 0):
365
+ rel = d.p_process_reserve_upDown_node_reliability
366
+ # v_reserve has dims (p, r, ud, n, d, t); multiply by unitsize and
367
+ # reliability (both keyed inside (p, r, ud, n)), then Where-join to
368
+ # pruna_g to add the ``g`` dim, and Sum out (p, n) to leave (r, ud, g, d, t).
369
+ lhs_reserve_core = Sum(
370
+ Where(v_reserve * p_unitsize * rel, pruna_g),
371
+ over=("p", "n"),
372
+ )
373
+ else:
374
+ lhs_reserve_core = None
375
+
376
+ # vq_reserve · pdtReserve_reservation, dims = (r, ud, g, d, t) already
377
+ if d.pdtReserve_upDown_group_reservation is not None:
378
+ lhs_vq = vq_reserve * d.pdtReserve_upDown_group_reservation
379
+ else:
380
+ lhs_vq = vq_reserve
381
+
382
+ # ── reserveBalance_timeseries_eq ─────────────────────────────────────
383
+ method_ts = d.reserve_upDown_group_method_timeseries
384
+ if method_ts is not None and method_ts.height > 0:
385
+ # Restrict the constraint domain to (r, ud, g, d, t) where
386
+ # (r, ud, g) ∈ method_ts.
387
+ ts_rug = method_ts.select("r", "ud", "g").unique()
388
+ ts_dt = ts_rug.join(d.dt, how="cross")
389
+ rhs = (d.pdtReserve_upDown_group_reservation
390
+ if d.pdtReserve_upDown_group_reservation is not None
391
+ else 0.0)
392
+ lhs_terms = {"vq_term": lhs_vq}
393
+ if lhs_reserve_core is not None:
394
+ lhs_terms["reserve_sum"] = Where(lhs_reserve_core, ts_rug)
395
+ m.add_cstr(
396
+ "reserveBalance_timeseries_eq",
397
+ over = ts_dt,
398
+ sense = ">=",
399
+ lhs_terms = lhs_terms,
400
+ rhs_terms = {"demand": rhs} if not isinstance(rhs, float) else {"demand": rhs},
401
+ )
402
+
403
+ # ── reserveBalance_dynamic_eq ────────────────────────────────────────
404
+ method_dyn = d.reserve_upDown_group_method_dynamic
405
+ if method_dyn is not None and method_dyn.height > 0:
406
+ dyn_rug = method_dyn.select("r", "ud", "g").unique()
407
+ dyn_dt = dyn_rug.join(d.dt, how="cross")
408
+ # RHS: Σ v_flow · unitsize · increase_reserve_ratio (over flows
409
+ # touching n in (g, n)) + Σ pdtNodeInflow · ratio / step_duration.
410
+ # V1: emit *only* the LHS + vq slack vs. RHS = 0 if v_flow / the
411
+ # ratio set are absent. When v_flow is present and the
412
+ # increase_reserve_ratio param is loaded, build the dynamic RHS.
413
+ rhs_terms: dict = {}
414
+ v_flow = vars.get("v_flow")
415
+ irr_set = getattr(d, "process_reserve_upDown_node_increase_reserve_ratio", None)
416
+ irr_param = getattr(d, "p_process_reserve_upDown_node_increase_reserve_ratio_value", None)
417
+ if (v_flow is not None and irr_set is not None and irr_set.height > 0
418
+ and irr_param is not None and d.process_source_sink is not None):
419
+ # join irr_set to group_node to add g, restrict to dyn_rug
420
+ irr_g = (irr_set.join(gn, on="n", how="inner")
421
+ .join(dyn_rug, on=["r", "ud", "g"], how="inner")
422
+ .select("p", "r", "ud", "n", "g"))
423
+ if irr_g.height > 0:
424
+ # All flow rows whose (p, n) (either as source or sink) is
425
+ # listed in process_source_sink contribute. Simplification:
426
+ # use process_source_sink (the union) and let the join
427
+ # over (p, n) pick up both source-side and sink-side rows.
428
+ # The .mod splits noEff/eff for the slope factor; we keep
429
+ # the no-slope variant here and let A4 patch in slope where
430
+ # applicable.
431
+ #
432
+ # rename source/sink so the join landing column is "n"
433
+ pss = d.process_source_sink
434
+ # Sum pieces: source-side (n appears as 'sink') + sink-side
435
+ # (n appears as 'source'). We unify by inner-joining both
436
+ # ways and summing the contributions.
437
+ # NOTE: this is a structural simplification. The .mod's
438
+ # full RHS additionally splits noEff/eff and applies slope
439
+ # to the eff sink-side rows. Listed in manifest TODO.
440
+ pieces = []
441
+ # n on the "sink" side: the reserve node is the flow's
442
+ # sink. Relabel v_flow's ``sink`` axis to ``n`` so it ties
443
+ # to the reserve node (see _flow_node_view).
444
+ v_flow_sink_n = _flow_node_view(v_flow, "sink")
445
+ pss_as_sink = pss.pipe(rename_to_axis, {"sink": "n"}).select("p", "source", "n")
446
+ pieces.append(Sum(
447
+ Where(v_flow_sink_n * p_unitsize * irr_param,
448
+ pss_as_sink.join(irr_g, on=["p", "n"], how="inner")),
449
+ over=("p", "source", "n"),
450
+ ))
451
+ # n on the "source" side: the reserve node is the flow's
452
+ # source. Relabel v_flow's ``source`` axis to ``n``.
453
+ v_flow_source_n = _flow_node_view(v_flow, "source")
454
+ pss_as_source = pss.pipe(rename_to_axis, {"source": "n"}).select("p", "n", "sink")
455
+ pieces.append(Sum(
456
+ Where(v_flow_source_n * p_unitsize * irr_param,
457
+ pss_as_source.join(irr_g, on=["p", "n"], how="inner")),
458
+ over=("p", "sink", "n"),
459
+ ))
460
+ rhs_dyn = pieces[0]
461
+ for piece in pieces[1:]:
462
+ rhs_dyn = rhs_dyn + piece
463
+ rhs_terms["dynamic_flow"] = rhs_dyn
464
+
465
+ # demand term: pdtReserve_reservation is the reservation timeseries.
466
+ # The .mod's dynamic_eq RHS does *not* include pdtReserve_reservation
467
+ # directly — the demand is dynamic. However the LHS keeps the
468
+ # vq_reserve · pdtReserve_reservation term. When the dynamic RHS
469
+ # is unavailable in V1 we fall back to a 0 RHS, matching the .mod
470
+ # in the empty-set degenerate case.
471
+ if not rhs_terms:
472
+ rhs_terms = {"zero": 0.0}
473
+
474
+ lhs_terms = {"vq_term": Where(lhs_vq, dyn_rug)}
475
+ if lhs_reserve_core is not None:
476
+ lhs_terms["reserve_sum"] = Where(lhs_reserve_core, dyn_rug)
477
+ m.add_cstr(
478
+ "reserveBalance_dynamic_eq",
479
+ over = dyn_dt,
480
+ sense = ">=",
481
+ lhs_terms = lhs_terms,
482
+ rhs_terms = rhs_terms,
483
+ )
484
+
485
+ # ── reserveBalance_up_n_1_eq / reserveBalance_down_n_1_eq ──────────
486
+ # The n-1 family adds an extra index ``p_n_1 in process_large_failure``
487
+ # to the constraint domain — one row per (failing process × group ×
488
+ # d × t). V1 simplification: emit per-(r, ud, g, d, t) only, with the
489
+ # large-failure RHS summed across all p_n_1 candidates. The .mod
490
+ # imposes the constraint *for each* p_n_1 separately, which is tighter
491
+ # in pathological cases. Listed in manifest TODO.
492
+ method_n1 = d.reserve_upDown_group_method_n_1
493
+ if method_n1 is not None and method_n1.height > 0:
494
+ for ud_filter, name in [
495
+ ("up", "reserveBalance_up_n_1_eq"),
496
+ ("down", "reserveBalance_down_n_1_eq"),
497
+ ]:
498
+ n1_rug = method_n1.filter(pl.col("ud") == ud_filter) \
499
+ .select("r", "ud", "g").unique()
500
+ if n1_rug.height == 0:
501
+ continue
502
+ n1_dt = n1_rug.join(d.dt, how="cross")
503
+ rhs_terms: dict = {}
504
+ v_flow = vars.get("v_flow")
505
+ lfr_set = getattr(d, "process_reserve_upDown_node_large_failure_ratio", None)
506
+ lfr_param = getattr(d, "p_process_reserve_upDown_node_large_failure_ratio_value", None)
507
+ if (v_flow is not None and lfr_set is not None and lfr_set.height > 0
508
+ and lfr_param is not None and d.process_source_sink is not None):
509
+ lfr_g = (lfr_set.join(gn, on="n", how="inner")
510
+ .join(n1_rug, on=["r", "ud", "g"], how="inner")
511
+ .select("p", "r", "ud", "n", "g"))
512
+ if lfr_g.height > 0:
513
+ pss = d.process_source_sink
514
+ if ud_filter == "up":
515
+ # n is the failing-process sink (delivering side).
516
+ # Relabel v_flow's ``sink`` axis to ``n`` so it ties
517
+ # to the failing node (see _flow_node_view).
518
+ v_flow_n = _flow_node_view(v_flow, "sink")
519
+ pss_idx = pss.pipe(rename_to_axis, {"sink": "n"}).select("p", "source", "n")
520
+ rhs_terms["n_1_failure"] = Sum(
521
+ Where(v_flow_n * p_unitsize * lfr_param,
522
+ pss_idx.join(lfr_g, on=["p", "n"], how="inner")),
523
+ over=("p", "source", "n"),
524
+ )
525
+ else:
526
+ # n is the failing-process source (consuming side).
527
+ # Relabel v_flow's ``source`` axis to ``n``.
528
+ v_flow_n = _flow_node_view(v_flow, "source")
529
+ pss_idx = pss.pipe(rename_to_axis, {"source": "n"}).select("p", "n", "sink")
530
+ rhs_terms["n_1_failure"] = Sum(
531
+ Where(v_flow_n * p_unitsize * lfr_param,
532
+ pss_idx.join(lfr_g, on=["p", "n"], how="inner")),
533
+ over=("p", "sink", "n"),
534
+ )
535
+ if not rhs_terms:
536
+ rhs_terms = {"zero": 0.0}
537
+
538
+ lhs_terms = {"vq_term": Where(lhs_vq, n1_rug)}
539
+ if lhs_reserve_core is not None:
540
+ lhs_terms["reserve_sum"] = Where(lhs_reserve_core, n1_rug)
541
+ m.add_cstr(
542
+ name,
543
+ over = n1_dt,
544
+ sense = ">=",
545
+ lhs_terms = lhs_terms,
546
+ rhs_terms = rhs_terms,
547
+ )
548
+
549
+ # ── reserve_process_upward / reserve_process_downward ──────────────
550
+ # Per-(p, r, ud, n, d, t):
551
+ # v_reserve · unitsize ≤ max_share · (online · unitsize) [if online]
552
+ # v_reserve · unitsize ≤ max_share · existing-capacity [otherwise]
553
+ #
554
+ # V1: online-not-aware variant. RHS uses max_share · ( existing_count +
555
+ # Σ_{d_inv} v_invest_p[d_inv] - Σ_{d_div} v_divest_p[d_div] ) (the
556
+ # invest/divest terms are moved to the LHS as -invest / +divest).
557
+ # The .mod's online variant requires the online_set + v_online from
558
+ # the online module — that wiring is left to A4.
559
+ max_share = getattr(d, "p_process_reserve_upDown_node_max_share", None)
560
+ if max_share is not None and d.prundt.height > 0:
561
+ existing = getattr(d, "p_process_existing_count", None)
562
+ if existing is not None:
563
+ v_invest_p = vars.get("v_invest_p")
564
+ v_divest_p = vars.get("v_divest_p")
565
+ edd_inv_set = getattr(d, "edd_invest_set", None)
566
+ edd_div_act = getattr(d, "edd_divest_active", None)
567
+ for ud_filter, name in [
568
+ ("up", "reserve_process_upward"),
569
+ ("down", "reserve_process_downward"),
570
+ ]:
571
+ idx = d.prundt.filter(pl.col("ud") == ud_filter)
572
+ if idx.height == 0:
573
+ continue
574
+ # max_share is keyed on (p, r, ud, n). existing_count
575
+ # keyed on (p, d). Together these give RHS dims
576
+ # (p, r, ud, n, d) — broadcast-with-Sum across t at row
577
+ # binding. We emit on idx (which has all six dims).
578
+ rhs_param = max_share * existing
579
+ lhs: dict = {"reserve": v_reserve}
580
+ # Restrict invest-tightening to processes appearing in idx.
581
+ p_in_idx = idx.select("p").unique()
582
+ # invest tightening: -Σ_{d_inv} v_invest_p[d_inv] · max_share
583
+ if (v_invest_p is not None and edd_inv_set is not None
584
+ and edd_inv_set.height > 0):
585
+ edd_p = (edd_inv_set.pipe(rename_to_axis, {"e": "p"})
586
+ .join(p_in_idx, on="p", how="inner"))
587
+ if edd_p.height > 0:
588
+ v_inv_at = Var(
589
+ name=v_invest_p.name + f"__at_{name}",
590
+ dims=("p", "d_invest"),
591
+ frame=v_invest_p.frame.pipe(rename_to_axis, {"d": "d_invest"}),
592
+ lower=v_invest_p.lower, upper=v_invest_p.upper,
593
+ )
594
+ inv_sum = Sum(Where(v_inv_at, edd_p), over=("d_invest",))
595
+ lhs["invest_neg"] = -(inv_sum * max_share)
596
+ # divest tightening: +Σ_{d_div} v_divest_p[d_div] · max_share
597
+ if (v_divest_p is not None and edd_div_act is not None
598
+ and edd_div_act.height > 0):
599
+ edd_p_div = edd_div_act.join(p_in_idx, on="p", how="inner")
600
+ if edd_p_div.height > 0:
601
+ v_div_at = Var(
602
+ name=v_divest_p.name + f"__at_{name}",
603
+ dims=("p", "d_divest"),
604
+ frame=v_divest_p.frame.pipe(rename_to_axis, {"d": "d_divest"}),
605
+ lower=v_divest_p.lower, upper=v_divest_p.upper,
606
+ )
607
+ div_sum = Sum(Where(v_div_at, edd_p_div), over=("d_divest",))
608
+ lhs["divest"] = div_sum * max_share
609
+ m.add_cstr(
610
+ name,
611
+ over = idx,
612
+ sense = "<=",
613
+ lhs_terms = lhs,
614
+ rhs_terms = {"max": rhs_param},
615
+ )
616
+
617
+
618
+ # ---------------------------------------------------------------------------
619
+ # Objective
620
+
621
+ def add_objective_terms(m, d, vars: dict, op_factor):
622
+ """Return the ``vq_reserve`` slack penalty Expr to be added to the
623
+ objective. Mirrors objective_audit.md §9.4 / flextool.mod 2100-2101.
624
+
625
+ + Σ vq_reserve · pdtReserve_reservation · penalty_reserve · op_factor
626
+
627
+ where ``op_factor = step_duration · timestep_weight · inflation_op /
628
+ period_share`` (the same factor used elsewhere in the objective).
629
+
630
+ Returns ``None`` (zero contribution) if the reserve subsystem is not
631
+ active or if any dependency is missing — same pattern as the rest of
632
+ the optional objective terms.
633
+ """
634
+ if not has_feature(d):
635
+ return None
636
+ if "vq_reserve" not in vars:
637
+ return None
638
+
639
+ vq_reserve = vars["vq_reserve"]
640
+ res_param = d.pdtReserve_upDown_group_reservation
641
+ pen = d.p_reserve_upDown_group_penalty_reserve
642
+ if res_param is None or pen is None:
643
+ return None
644
+
645
+ # ``op_factor`` carries ``pdt_branch_weight`` when stochastics is
646
+ # active (folded in by the model.py caller — see A6 close). In
647
+ # deterministic single-branch runs ``pdt_branch_weight`` is None and
648
+ # ``op_factor`` is the four-Param product the .mod uses.
649
+ return Sum(vq_reserve * res_param * pen * op_factor)