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,1253 @@
1
+ """Group-level slack constraints: ``capacity_margin``, ``inertia``, ``non_sync``.
2
+
3
+ Each is a "soft floor" constraint: a sum over processes (or unit existence)
4
+ must meet a per-group floor; a slack variable absorbs any shortfall and is
5
+ priced at a high penalty in the objective.
6
+
7
+ Module API
8
+ ----------
9
+
10
+ ``has_feature(d) -> bool``
11
+ True if any of the three group-slack features is active in this scenario.
12
+
13
+ ``load_data(inp, sd, dt, nb, pss_eff, pss_noEff, p_unitsize, **_) -> dict``
14
+ Read the solve_data / input CSVs the features need and return a dict
15
+ of new ``FlexData`` fields. Fields that are unused in the current
16
+ scenario are returned as ``None`` (mirroring the rest of the loader's
17
+ convention so the merge agent can `**` -merge into the FlexData ctor).
18
+
19
+ ``add_constraints(m, d, vars) -> None``
20
+ Emit ``capacityMargin``, ``inertia_constraint`` and ``non_sync_constraint``
21
+ where their data is non-empty. Declares its own slack variables
22
+ ``vq_capacity_margin``, ``vq_inertia``, ``vq_non_synchronous`` and
23
+ publishes them back into ``vars`` (key names match var names) so
24
+ ``add_objective_terms`` can consume them.
25
+
26
+ ``add_objective_terms(m, d, vars, op_factor) -> Expr | None``
27
+ Build the slack-penalty contribution of these three features. Returns
28
+ ``None`` if the feature is not active (caller must guard).
29
+
30
+ New FlexData fields (set by ``load_data``)
31
+ ------------------------------------------
32
+
33
+ Sets
34
+ ~~~~
35
+
36
+ * ``groupCapacityMargin`` — ``(g,)`` rows are groups whose
37
+ ``pdGroup_capacity_margin`` is non-empty.
38
+ * ``groupInertia`` — ``(g,)`` rows are groups whose
39
+ ``pdGroup_inertia_limit`` is non-empty.
40
+ * ``groupNonSync`` — ``(g,)`` rows are groups in
41
+ ``input/groupNonSync.csv`` (mirrors the .mod's hand-curated set).
42
+ * ``group_node`` — ``(g, n)`` flextool's group__node (sd copy).
43
+ * ``process_sink_inertia`` — ``(p, sink)`` rows for which
44
+ ``p_process_sink[p, sink, 'inertia_constant']`` exists.
45
+ * ``process_source_inertia`` — ``(p, source)`` analogous.
46
+ * ``process_sink_nonSync`` — ``(p, sink)`` flextool's process__sink_nonSync.
47
+ * ``process_group_inside_nonSync`` — ``(p, g)`` set of process-inside-group
48
+ pairs that are excluded from non-sync flow accounting.
49
+
50
+ Parameters
51
+ ~~~~~~~~~~
52
+
53
+ * ``p_inv_group_cap`` — ``(g, d)`` inverse of
54
+ group_capacity_for_scaling, used as a row-scaler in capacity_margin and
55
+ non_sync. Equals ``1 / group_capacity_for_scaling`` per (g, d).
56
+ * ``p_group_capacity_for_scaling`` — ``(g, d)`` the raw scaling
57
+ factor — used by the slack term in the objective for capacity_margin.
58
+ * ``pdGroup_capacity_margin`` — ``(g, d)`` RHS of capacity
59
+ margin constraint.
60
+ * ``pdGroup_penalty_capacity_margin`` — ``(g, d)`` unit penalty.
61
+ * ``pdGroup_inertia_limit`` — ``(g, d)`` RHS of inertia.
62
+ * ``pdGroup_penalty_inertia`` — ``(g, d)`` unit penalty.
63
+ * ``pdGroup_non_synchronous_limit`` — ``(g, d)`` per-group share.
64
+ * ``pdGroup_penalty_non_synchronous`` — ``(g, d)`` unit penalty.
65
+ * ``p_process_sink_inertia_constant`` — ``(p, sink)``.
66
+ * ``p_process_source_inertia_constant`` — ``(p, source)``.
67
+ * ``p_positive_inflow`` — ``(n, d, t)`` exogenous +
68
+ inflow part used by non_sync. Note flextool also has a no-overlap
69
+ ``p_negative_inflow`` (consumed in the same constraint).
70
+ * ``p_negative_inflow`` — ``(n, d, t)``.
71
+ * ``pdtNodeInflow_per_step`` — ``(n, d, t)`` =
72
+ ``pdtNodeInflow / p_step_duration``. Used in capacity_margin RHS.
73
+
74
+ Variables created in ``add_constraints``
75
+ ----------------------------------------
76
+
77
+ * ``vq_capacity_margin[g, d]`` (period-only).
78
+ * ``vq_inertia[g, d, t]`` .
79
+ * ``vq_non_synchronous[g, d, t]`` .
80
+
81
+ Required existing data fields (must be present before ``add_constraints``)
82
+ --------------------------------------------------------------------------
83
+
84
+ * ``v_flow`` (in ``vars``).
85
+ * ``v_online_lin`` / ``v_online_int`` (only if the model has UC; used by
86
+ inertia LHS for ``process_online`` processes and by non-sync's
87
+ min-load section term).
88
+ * ``v_invest_p`` / ``v_divest_p`` (only if the scenario has invest;
89
+ used by capacity_margin LHS for available-capacity-with-invest).
90
+ * ``p_unitsize``, ``p_slope``, ``p_section`` (already on ``d``).
91
+ * ``p_step_duration``, ``p_timestep_weight``, ``p_inflation_op``,
92
+ ``p_period_share`` (already on ``d``).
93
+ * ``p_process_existing_count``, ``edd_invest_set``, ``pd_divest_set``,
94
+ ``edd_divest_active`` (only when invest is active).
95
+ * ``p_inflow`` -OR- direct ``pdtNodeInflow`` (used to derive
96
+ ``pdtNodeInflow_per_step``); we recompute from ``p_inflow *
97
+ p_step_duration`` if the canonical pdtNodeInflow.csv is unavailable.
98
+
99
+ Hooks
100
+ -----
101
+
102
+ * In ``build_flextool``: invoke ``_group_slack.add_constraints(m, d, vars)``
103
+ AFTER existing constraint emission (storage, ramps, profiles), BEFORE
104
+ ``m.set_objective``. Then ``obj = obj + _group_slack.add_objective_terms(m,
105
+ d, vars, op_factor) or 0``.
106
+ * In ``load_flextool``: ``data = data._replace(**_group_slack.load_data(
107
+ inp, sd, dt, nb, pss_eff, pss_noEff, p_unitsize))`` before returning, or
108
+ set the fields directly as part of the ctor call.
109
+
110
+ Sign conventions follow ``audit/constraints_audit.md`` and
111
+ ``audit/objective_audit.md`` term-by-term.
112
+ """
113
+
114
+ from __future__ import annotations
115
+
116
+ from pathlib import Path
117
+ import polars as pl
118
+
119
+ from polar_high import Sum, Where, Param
120
+ from polar_high.engine import Var, Expr
121
+
122
+ from ._emit_provider_io import _provider_key
123
+ from ._axis_enums import align_join_dtypes
124
+
125
+
126
+ def _provider_get(provider, path: "Path") -> "pl.DataFrame | None":
127
+ """Provider-only fetch. Returns ``None`` when the Provider is
128
+ missing or doesn't carry *path*'s canonical key.
129
+ """
130
+ if provider is None:
131
+ return None
132
+ key = _provider_key(path)
133
+ if not provider.has(key):
134
+ return None
135
+ return provider.get(key)
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Sentinel keys (must match the FlexData fields the merge agent will add).
140
+
141
+ _FIELDS: tuple[str, ...] = (
142
+ "groupCapacityMargin", "groupInertia", "groupNonSync",
143
+ "group_node",
144
+ "process_unit",
145
+ "process_sink_inertia", "process_source_inertia",
146
+ "process_sink_nonSync", "process_group_inside_nonSync",
147
+ "p_inv_group_cap", "p_group_capacity_for_scaling",
148
+ "pdGroup_capacity_margin", "pdGroup_penalty_capacity_margin",
149
+ "pdGroup_inertia_limit", "pdGroup_penalty_inertia",
150
+ "pdGroup_non_synchronous_limit", "pdGroup_penalty_non_synchronous",
151
+ "p_process_sink_inertia_constant", "p_process_source_inertia_constant",
152
+ "p_positive_inflow", "p_negative_inflow",
153
+ "pdtNodeInflow_per_step",
154
+ )
155
+
156
+
157
+ def _blank() -> dict:
158
+ """Return a dict-of-Nones for every field this module owns."""
159
+ return {f: None for f in _FIELDS}
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Public predicate
164
+
165
+ def has_feature(d) -> bool:
166
+ """True if *any* of the three group-level slack features is active.
167
+
168
+ A feature is active when its corresponding group-set field has at
169
+ least one row. ``getattr`` with a default of ``None`` keeps this
170
+ callable even if the merge agent is staged before the FlexData
171
+ extension lands."""
172
+ for f in ("groupCapacityMargin", "groupInertia", "groupNonSync"):
173
+ s = getattr(d, f, None)
174
+ if s is not None and s.height > 0:
175
+ return True
176
+ return False
177
+
178
+
179
+ def _has_capacity_margin(d) -> bool:
180
+ s = getattr(d, "groupCapacityMargin", None)
181
+ return s is not None and s.height > 0
182
+
183
+
184
+ def _has_inertia(d) -> bool:
185
+ s = getattr(d, "groupInertia", None)
186
+ return s is not None and s.height > 0
187
+
188
+
189
+ def _has_non_sync(d) -> bool:
190
+ s = getattr(d, "groupNonSync", None)
191
+ return s is not None and s.height > 0
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Data loading helpers
196
+
197
+ def _read_csv_or_none(p: Path, *, provider=None) -> pl.DataFrame | None:
198
+ df = _provider_get(provider, p)
199
+ if df is None or df.height == 0:
200
+ return None
201
+ return df
202
+
203
+
204
+ def _slice_pdgroup(sd: Path, param: str, *, provider=None) -> pl.DataFrame | None:
205
+ """Slice ``solve_data/pdGroup.csv`` (canonical long format
206
+ ``group, param, period, value``) by literal ``param`` string —
207
+ same operation .mod does inline via ``pdGroup[g, '<param>', d]``.
208
+
209
+ Returns ``(g, d, value)`` or ``None`` if the Provider doesn't
210
+ carry the key / the slice is empty / the slice contains only zeros."""
211
+ p = sd / "pdGroup.csv"
212
+ df = _provider_get(provider, p)
213
+ if df is None or df.height == 0:
214
+ return None
215
+ sliced = (df.filter(pl.col("param") == param)
216
+ .rename({"group": "g", "period": "d"})
217
+ .select("g", "d", "value")
218
+ .with_columns(pl.col("value").cast(pl.Float64, strict=False)))
219
+ # Drop zero rows so the constraint set is naturally empty when the
220
+ # feature isn't exercised.
221
+ sliced = sliced.filter(pl.col("value") != 0.0)
222
+ if sliced.height == 0:
223
+ return None
224
+ return sliced
225
+
226
+
227
+ def _slice_pdgroup_topfile(sd: Path, fname: str, value_col: str,
228
+ *, provider=None) -> pl.DataFrame | None:
229
+ """Some pdGroup_*.csv files are written by flextool preprocessing as
230
+ standalone wide-by-group files keyed on ``solve, period, <group1, …>``
231
+ (e.g. ``pdGroup_inertia_limit.csv``). Read either format and return
232
+ a long ``(g, d, value)`` frame with zero rows dropped."""
233
+ p = sd / fname
234
+ df = _provider_get(provider, p)
235
+ if df is None or df.height == 0:
236
+ return None
237
+ if "solve" in df.columns:
238
+ df = df.drop("solve")
239
+ if {"period", value_col}.issubset(df.columns) and df.columns[0] == "group":
240
+ # long form with explicit value column name
241
+ out = (df.rename({"group": "g", "period": "d", value_col: "value"})
242
+ .select("g", "d", "value")
243
+ .with_columns(pl.col("value").cast(pl.Float64, strict=False)))
244
+ elif "period" in df.columns and "group" in df.columns and "value" in df.columns:
245
+ out = (df.rename({"group": "g", "period": "d"})
246
+ .select("g", "d", "value")
247
+ .with_columns(pl.col("value").cast(pl.Float64, strict=False)))
248
+ elif "period" in df.columns:
249
+ # wide-per-group: cols are (period, g1, g2, ...).
250
+ val_cols = [c for c in df.columns if c != "period"]
251
+ if not val_cols:
252
+ return None
253
+ out = (df.unpivot(on=val_cols, index=["period"],
254
+ variable_name="g", value_name="value")
255
+ .rename({"period": "d"})
256
+ .with_columns(pl.col("value").cast(pl.Float64, strict=False))
257
+ .select("g", "d", "value"))
258
+ else:
259
+ return None
260
+ out = out.filter(pl.col("value").is_not_null() & (pl.col("value") != 0.0))
261
+ if out.height == 0:
262
+ return None
263
+ return out
264
+
265
+
266
+ def _read_inertia_constants(inp: Path, *,
267
+ provider=None) -> tuple[pl.DataFrame | None, pl.DataFrame | None]:
268
+ """Parse ``p_process_sink.csv`` and ``p_process_source.csv`` for the
269
+ ``inertia_constant`` parameter.
270
+
271
+ Returns ``(sink_df, source_df)``, each ``(p, side, value)`` or None.
272
+
273
+ Both files are long-format with columns
274
+ ``[process, sink|source, sourceSinkParam, p_process_sink|source]``.
275
+ See ``_read_p_process_side`` in input.py for the canonical shape."""
276
+ def _read(path: Path, side: str) -> pl.DataFrame | None:
277
+ df = _provider_get(provider, path)
278
+ if df is None or df.height == 0:
279
+ return None
280
+ if not {"process", "sourceSinkParam"}.issubset(df.columns):
281
+ return None
282
+ value_col = df.columns[-1]
283
+ out = (df.filter(pl.col("sourceSinkParam") == "inertia_constant")
284
+ .rename({"process": "p", value_col: "value"})
285
+ .select("p", side, "value")
286
+ .with_columns(pl.col("value").cast(pl.Float64, strict=False))
287
+ .filter(pl.col("value") != 0.0))
288
+ return out if out.height > 0 else None
289
+ sink_df = _read(inp / "p_process_sink.csv", "sink")
290
+ src_df = _read(inp / "p_process_source.csv", "source")
291
+ return sink_df, src_df
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # load_data
296
+
297
+ def load_data(inp: Path, sd: Path, dt: pl.DataFrame,
298
+ nb: pl.DataFrame | None,
299
+ pss_eff: pl.DataFrame | None,
300
+ pss_noEff: pl.DataFrame | None,
301
+ p_unitsize: Param | None,
302
+ *, provider=None,
303
+ **_unused) -> dict:
304
+ """Read group-level slack data from ``input/`` + ``solve_data/``.
305
+
306
+ The merge agent calls this from ``load_flextool`` and merges the
307
+ result into the ``FlexData`` constructor.
308
+
309
+ All returned fields are independently optional. Missing CSVs ⇒ None.
310
+ """
311
+ out = _blank()
312
+
313
+ # ── Group sets ────────────────────────────────────────────────────────
314
+ # The .mod derives groupCapacityMargin / groupInertia from non-empty rows
315
+ # in pdGroup_capacity_margin.csv / pdGroup_inertia_limit.csv (param-bearing
316
+ # rows of pdGroup.csv). groupNonSync is hand-curated in
317
+ # input/groupNonSync.csv.
318
+ cap_pd = _slice_pdgroup_topfile(sd, "pdGroup_capacity_margin.csv",
319
+ "capacity_margin", provider=provider)
320
+ if cap_pd is None:
321
+ cap_pd = _slice_pdgroup(sd, "capacity_margin", provider=provider)
322
+ iner_pd = _slice_pdgroup_topfile(sd, "pdGroup_inertia_limit.csv",
323
+ "electricity", # value col is group name
324
+ provider=provider)
325
+ if iner_pd is None:
326
+ iner_pd = _slice_pdgroup(sd, "inertia_limit", provider=provider)
327
+ nsync_pd = _slice_pdgroup(sd, "non_synchronous_limit",
328
+ provider=provider) # only via pdGroup.csv
329
+
330
+ # Set frames keyed by group name. Δ.12-drop: the corresponding
331
+ # ``pdGroup_capacity_margin`` / ``pdGroup_inertia_limit`` /
332
+ # ``pdGroup_non_synchronous_limit`` Params are produced
333
+ # authoritatively by ``apply_direct_params`` (Δ.4b).
334
+ if cap_pd is not None:
335
+ out["groupCapacityMargin"] = cap_pd.select("g").unique()
336
+ if iner_pd is not None:
337
+ out["groupInertia"] = iner_pd.select("g").unique()
338
+
339
+ # groupNonSync: prefer the explicit input file (canonical), fallback to
340
+ # pd_group's non_synchronous_limit slice.
341
+ g_ns_path = inp / "groupNonSync.csv"
342
+ df = _provider_get(provider, g_ns_path)
343
+ if df is not None and df.height > 0:
344
+ # Header column might be "groupNonSync" (legacy) or "group".
345
+ col = df.columns[0]
346
+ out["groupNonSync"] = (df.rename({col: "g"})
347
+ .select("g").unique())
348
+ if out["groupNonSync"] is None and nsync_pd is not None:
349
+ out["groupNonSync"] = nsync_pd.select("g").unique()
350
+
351
+ # Δ.12-drop: ``pdGroup_penalty_capacity_margin`` /
352
+ # ``pdGroup_penalty_inertia`` / ``pdGroup_penalty_non_synchronous``
353
+ # produced authoritatively by ``apply_direct_params`` (Δ.4b). The
354
+ # legacy CSV reads of pdGroup_penalty_*.csv (and the pdGroup.csv
355
+ # ``penalty_*`` slice fallback) are dropped.
356
+
357
+ # ── group_node ────────────────────────────────────────────────────────
358
+ # Canonical preprocessing target: solve_data/group_node.csv.
359
+ # Fallback: input/group__node.csv (raw user input).
360
+ gn = None
361
+ for path in (sd / "group_node.csv", inp / "group__node.csv"):
362
+ df = _provider_get(provider, path)
363
+ if df is None:
364
+ continue
365
+ if df.height > 0:
366
+ # both shapes are (group, node)
367
+ gn = df.rename({"group": "g", "node": "n"}).select("g", "n").unique()
368
+ break
369
+ out["group_node"] = gn
370
+
371
+ # ── process_unit ──────────────────────────────────────────────────────
372
+ # The .mod's `process_unit` set narrows producer/consumer LHS terms in
373
+ # capacityMargin (and friends) to "unit"-typed processes — i.e. it
374
+ # excludes connection processes which would otherwise double-count
375
+ # transmission capacity.
376
+ # Canonical solve_data file has header `process_unit`; some legacy
377
+ # files use `process`. Either works here.
378
+ pu_path = sd / "process_unit.csv"
379
+ df = _provider_get(provider, pu_path)
380
+ if df is not None and df.height > 0:
381
+ col = df.columns[0]
382
+ out["process_unit"] = (df.rename({col: "p"})
383
+ .select("p").unique())
384
+
385
+ # ── group_capacity_for_scaling + inv_group_cap ───────────────────────
386
+ p_gcs = sd / "group_capacity_for_scaling.csv"
387
+ p_inv = sd / "inv_group_cap.csv"
388
+ df = _provider_get(provider, p_gcs)
389
+ if df is not None and df.height > 0:
390
+ df = (df.rename({"group": "g", "period": "d"})
391
+ .with_columns(pl.col("value").cast(pl.Float64, strict=False))
392
+ .select("g", "d", "value")
393
+ .filter(pl.col("value").is_not_null() & (pl.col("value") != 0.0)))
394
+ if df.height > 0:
395
+ out["p_group_capacity_for_scaling"] = Param(("g", "d"), df)
396
+ df = _provider_get(provider, p_inv)
397
+ if df is not None and df.height > 0:
398
+ df = (df.rename({"group": "g", "period": "d"})
399
+ .with_columns(pl.col("value").cast(pl.Float64, strict=False))
400
+ .select("g", "d", "value")
401
+ .filter(pl.col("value").is_not_null()))
402
+ if df.height > 0:
403
+ out["p_inv_group_cap"] = Param(("g", "d"), df)
404
+ # Derive inv_group_cap from group_capacity_for_scaling if missing.
405
+ if (out["p_inv_group_cap"] is None
406
+ and out["p_group_capacity_for_scaling"] is not None):
407
+ gcs = out["p_group_capacity_for_scaling"].frame
408
+ gcs_inv = (gcs.with_columns(value=1.0 / pl.col("value"))
409
+ .select("g", "d", "value"))
410
+ out["p_inv_group_cap"] = Param(("g", "d"), gcs_inv)
411
+ # And vice versa (so each is non-None when the other is provided).
412
+ if (out["p_group_capacity_for_scaling"] is None
413
+ and out["p_inv_group_cap"] is not None):
414
+ ig = out["p_inv_group_cap"].frame
415
+ gcs = (ig.with_columns(value=1.0 / pl.col("value"))
416
+ .select("g", "d", "value"))
417
+ out["p_group_capacity_for_scaling"] = Param(("g", "d"), gcs)
418
+
419
+ # ── inertia_constant index sets ─────────────────────────────
420
+ # Δ.12-drop: ``p_process_sink_inertia_constant`` /
421
+ # ``p_process_source_inertia_constant`` Params are produced
422
+ # authoritatively by ``apply_direct_params`` (Δ.4b). The set frames
423
+ # ``process_sink_inertia`` / ``process_source_inertia`` are kept on
424
+ # the seed path for SIMPLE_PROJECTIONS' fall-through semantics.
425
+ sink_df, src_df = _read_inertia_constants(inp, provider=provider)
426
+ if sink_df is not None:
427
+ out["process_sink_inertia"] = sink_df.select("p", "sink").unique()
428
+ if src_df is not None:
429
+ out["process_source_inertia"] = src_df.select("p", "source").unique()
430
+
431
+ # ── non-sync supporting sets ─────────────────────────────────────────
432
+ p_sink_ns = _read_csv_or_none(sd / "process__sink_nonSync.csv",
433
+ provider=provider)
434
+ if p_sink_ns is not None:
435
+ out["process_sink_nonSync"] = (p_sink_ns
436
+ .rename({"process": "p"})
437
+ .select("p", "sink").unique())
438
+ p_grp_inside = _read_csv_or_none(sd / "process__group_inside_group_nonSync.csv",
439
+ provider=provider)
440
+ if p_grp_inside is not None:
441
+ cols = p_grp_inside.columns
442
+ rn = {}
443
+ if "process" in cols:
444
+ rn["process"] = "p"
445
+ if "group" in cols:
446
+ rn["group"] = "g"
447
+ out["process_group_inside_nonSync"] = (
448
+ p_grp_inside.rename(rn).select("p", "g").unique())
449
+
450
+ # Δ.12-drop: ``p_positive_inflow`` / ``p_negative_inflow`` /
451
+ # ``pdtNodeInflow_per_step`` produced authoritatively by
452
+ # ``apply_derived_c`` (helpers ``p_positive_inflow_from_inflow`` /
453
+ # ``p_negative_inflow_from_inflow`` /
454
+ # ``pdtNodeInflow_per_step_from_inflow``). Seeds dropped.
455
+
456
+ return out
457
+
458
+
459
+ # ---------------------------------------------------------------------------
460
+ # Constraint emitters
461
+
462
+ def add_constraints(m, d, vars: dict) -> None:
463
+ """Emit ``capacityMargin``, ``inertia_constraint``, ``non_sync_constraint``.
464
+
465
+ Each block is independently active depending on whether the
466
+ corresponding group set has any rows. Constraints are no-op when
467
+ inactive, mirroring flextool.mod's data-driven shape.
468
+
469
+ ``vars`` is the dict of decision vars created earlier in
470
+ ``build_flextool``. Slack variables created here are written back
471
+ into ``vars`` under their var names for the objective stage.
472
+ """
473
+ if _has_capacity_margin(d):
474
+ _add_capacity_margin(m, d, vars)
475
+ if _has_inertia(d):
476
+ _add_inertia(m, d, vars)
477
+ if _has_non_sync(d):
478
+ _add_non_sync(m, d, vars)
479
+
480
+
481
+ def _gd_index(d) -> pl.DataFrame:
482
+ """Return distinct (d,) periods present in dt (used as the period axis
483
+ for capacity_margin which is keyed on (g, d), not (g, d, t))."""
484
+ return d.dt.select("d").unique()
485
+
486
+
487
+ # ── capacityMargin ───────────────────────────────────────────────────────
488
+
489
+ def _add_capacity_margin(m, d, vars: dict) -> None:
490
+ """capacityMargin (mod:4093-4154).
491
+
492
+ Indexed on ``g in groupCapacityMargin × (d, t) in dt`` where d is in
493
+ ``period_invest`` (we treat d ∈ dt as the operational set, matching
494
+ the existing pattern for other dispatch constraints).
495
+
496
+ Sense: ``>=``.
497
+
498
+ LHS terms (each scaled by inv_group_cap[g, d]):
499
+ + profile-limited producing units: profile · capacity ·
500
+ (existing + invest − divest), p ∈ process_unit, sink ∈ group_node \\
501
+ nodeState, profile_method ∈ {upper_limit, fixed};
502
+ + capacity-limited producing units (same predicates without profile):
503
+ (existing + invest − divest);
504
+ − consuming units: v_flow * (slope * sink_coef/source_coef) for eff
505
+ + section term for min_load_efficiency online + v_flow for noEff;
506
+ + vq_capacity_margin[g, d] · group_capacity_for_scaling · inv_group_cap
507
+ = vq_capacity_margin[g, d] (the slack scaling cancels).
508
+
509
+ RHS terms (×inv_group_cap):
510
+ + Σ_{(g,n) in group_node, n ∉ nodeState} (− pdtNodeInflow / step_duration)
511
+ + pdGroup[g, 'capacity_margin', d]
512
+
513
+ Notes on simplifications vs the .mod
514
+ * We skip the consuming-units LHS and section-term refinements when
515
+ ``v_flow``, ``p_slope`` or ``p_section`` are not on ``d`` — the
516
+ engine only emits the term in the LP when its data is materialised.
517
+ * The ``inv_group_cap`` row-scaler is applied uniformly to every
518
+ term except the slack (where * group_cap_for_scaling cancels).
519
+ """
520
+ # required pieces
521
+ gcm = d.groupCapacityMargin # (g,)
522
+ pgcm = d.pdGroup_capacity_margin # Param (g, d)
523
+ invc = d.p_inv_group_cap # Param (g, d)
524
+ if gcm is None or pgcm is None or invc is None:
525
+ return
526
+
527
+ # Slack variable vq_capacity_margin[g, d] — exists once per (g, d) where
528
+ # the constraint is emitted. Domain: groupCapacityMargin × period.
529
+ vq_dom = gcm.join(_gd_index(d), how="cross")
530
+ vq_cap = m.add_var("vq_capacity_margin", ("g", "d"), vq_dom, lower=0.0)
531
+ vars["vq_capacity_margin"] = vq_cap
532
+
533
+ # Constraint domain: groupCapacityMargin × dt.
534
+ over = gcm.join(d.dt, how="cross")
535
+
536
+ # Scale everything by inv_group_cap[g, d].
537
+ # The slack term's column-coefficient already multiplies by
538
+ # group_capacity_for_scaling (so * group_cap_for_scaling * inv_group_cap = 1).
539
+ lhs: dict = {}
540
+
541
+ # Slack — coef +1 (after the cancellation noted above).
542
+ lhs["slack"] = vq_cap
543
+
544
+ # Producing-unit available capacity: existing + Σ_d_inv v_invest − Σ_d_div v_divest.
545
+ # The .mod RHS in p_entity_all_existing already includes prior-solve
546
+ # invest carryover; the engine's preprocessing exposes existing/unitsize per
547
+ # (p, d) in p_process_existing_count, in *unit count* — so to get
548
+ # capacity we must multiply by p_entity_unitsize.
549
+ pss = d.process_source_sink
550
+ pss_eff = d.process_source_sink_eff
551
+ pss_noEff = d.process_source_sink_noEff
552
+ proc_unit = getattr(d, "process_unit", None) # mod's process_unit set
553
+ # group_node is required to know which (g, n) pairs are in scope; if
554
+ # absent we still emit the constraint with floor / inflow / slack so
555
+ # the slack is properly priced (no producer/consumer terms then).
556
+ gn_no_state = None
557
+ pss_to_grp = None
558
+ if d.group_node is not None:
559
+ # group_node restricted to (g, n) ∈ groupCapacityMargin × n.
560
+ gn = d.group_node.join(gcm, on="g", how="inner") # (g, n)
561
+ # Producer predicate: sink in group_node and sink not in nodeState.
562
+ gn_no_state = gn
563
+ if d.nodeState is not None and d.nodeState.height > 0:
564
+ gn_no_state = gn.join(d.nodeState, on="n", how="anti")
565
+ if pss is not None and gn_no_state is not None:
566
+ # Restrict producer/consumer LHS to process_unit per the .mod
567
+ # (filters out connection processes which double-count transmission
568
+ # capacity). If process_unit is missing, fall back to all of pss
569
+ # — no worse than the prior behaviour, but log via a soft fall-back.
570
+ pss_unit = pss
571
+ if proc_unit is not None and proc_unit.height > 0:
572
+ pss_unit = pss.join(proc_unit.select("p"), on="p", how="inner")
573
+ # Map sink → n for join. ``sink`` carries the entity-union Enum
574
+ # (wider vocab including non-node entities); ``gn_no_state.n``
575
+ # carries the node-only Enum. Renaming alone doesn't reconcile
576
+ # the dtypes; ``align_join_dtypes`` upcasts to the wider Enum so
577
+ # the join keys match.
578
+ pss_unit_n = pss_unit.rename({"sink": "n"})
579
+ pss_unit_n, gn_no_state_aligned = align_join_dtypes(
580
+ pss_unit_n, gn_no_state, ("n",))
581
+ pss_to_grp = (pss_unit_n
582
+ .join(gn_no_state_aligned, on="n", how="inner")
583
+ .rename({"n": "sink"})) # (p, source, sink, g)
584
+
585
+ # Profile-limited subset (upper_limit or fixed):
586
+ # pss_to_grp ∩ (process_profile_upper ∪ process_profile_fixed)
587
+ has_profile = (
588
+ (d.process_profile_upper is not None and d.process_profile_upper.height > 0) or
589
+ (d.process_profile_fixed is not None and d.process_profile_fixed.height > 0))
590
+ profile_idx = None
591
+ if has_profile and pss_to_grp is not None:
592
+ parts = []
593
+ if d.process_profile_upper is not None and d.process_profile_upper.height > 0:
594
+ parts.append(d.process_profile_upper.select("p", "source", "sink", "f"))
595
+ if d.process_profile_fixed is not None and d.process_profile_fixed.height > 0:
596
+ parts.append(d.process_profile_fixed.select("p", "source", "sink", "f"))
597
+ profile_idx = pl.concat(parts, how="vertical").unique()
598
+ # Restrict to (p, source, sink) in pss_to_grp.
599
+ profile_idx = profile_idx.join(pss_to_grp,
600
+ on=["p", "source", "sink"], how="inner")
601
+
602
+ # Capacity-limited subset = pss_to_grp \ profile_idx (same (p, source, sink)).
603
+ capacity_idx = pss_to_grp
604
+ if pss_to_grp is not None and profile_idx is not None:
605
+ capacity_idx = pss_to_grp.join(
606
+ profile_idx.select("p", "source", "sink").unique(),
607
+ on=["p", "source", "sink"], how="anti")
608
+
609
+ # ── Producer LHS: profile_limited (profile · capacity · inv_group_cap)
610
+ # We express "capacity" = existing_count[p, d] · unitsize[p] +
611
+ # Σ_d_inv v_invest_p[d_inv] · unitsize − Σ_d_div v_divest_p[d_div] · unitsize.
612
+ # But our existing_count is already in unit count (=cap/unitsize),
613
+ # so existing_count · unitsize · profile is a constant Param contribution
614
+ # (RHS-side); the *variable* contributions are the invest / divest
615
+ # times unitsize times profile.
616
+ prof_val = d.p_profile_value # Param (f, d, t)
617
+ exist_cnt = d.p_process_existing_count # Param (p, d)
618
+ p_us = d.p_unitsize # Param (p,)
619
+ # Note: if there are no processes (slack-only fixture) we skip all
620
+ # producer/consumer blocks but still emit the constraint with
621
+ # floor + inflow + slack (the .mod's degenerate case).
622
+ has_process_data = (pss_to_grp is not None
623
+ and exist_cnt is not None and p_us is not None)
624
+
625
+ # Constant (existing_count · unitsize · profile · inv_group_cap) is
626
+ # subtracted from the RHS so the LHS has only variable terms.
627
+ # We handle the profile & capacity legs separately, accumulate constants
628
+ # into a Param `const_subtract` and the per-(g, d, t) RHS.
629
+
630
+ # Helper: invest tightening summed over d_invest, restricted to p in pset.
631
+ has_invest_p = (getattr(d, "pd_invest_set", None) is not None
632
+ and d.pd_invest_set.height > 0)
633
+ has_divest_p = (getattr(d, "pd_divest_set", None) is not None
634
+ and d.pd_divest_set.height > 0)
635
+ v_invest_p = vars.get("v_invest_p")
636
+ v_divest_p = vars.get("v_divest_p")
637
+ edd_inv_set = getattr(d, "edd_invest_set", None)
638
+ edd_div_act = getattr(d, "edd_divest_active", None)
639
+
640
+ def _invest_sum(v_inv: Var, pset_pl: pl.DataFrame, edd: pl.DataFrame,
641
+ rename_e: bool) -> Expr | None:
642
+ """Return Σ_{d_invest} v_invest_p[d_invest] over edd ∩ pset, with
643
+ d_invest contracted, leaving (p, d). ``edd`` is keyed (e or p,
644
+ d_invest, d). rename_e=True ⇒ "e"→"p"."""
645
+ if v_inv is None or edd is None or edd.height == 0:
646
+ return None
647
+ e = edd
648
+ if rename_e and "e" in e.columns:
649
+ e = e.rename({"e": "p"})
650
+ # ``edd``'s renamed-e column carries the entity-union Enum (wider
651
+ # than the ``p`` process Enum); reconcile dtypes before joining
652
+ # against ``pset_pl.p`` (process Enum).
653
+ pset_p = pset_pl.select("p").unique()
654
+ e, pset_p = align_join_dtypes(e, pset_p, ("p",))
655
+ e = e.join(pset_p, on="p", how="inner")
656
+ if e.height == 0:
657
+ return None
658
+ v_at = Var(name=v_inv.name + "__cap_margin_inv",
659
+ dims=("p", "d_invest"),
660
+ frame=v_inv.frame.rename({"d": "d_invest"}),
661
+ lower=v_inv.lower, upper=v_inv.upper)
662
+ return Sum(Where(v_at, e), over=("d_invest",))
663
+
664
+ def _divest_sum(v_div: Var, pset_pl: pl.DataFrame, edd: pl.DataFrame) -> Expr | None:
665
+ if v_div is None or edd is None or edd.height == 0:
666
+ return None
667
+ pset_p = pset_pl.select("p").unique()
668
+ e, pset_p = align_join_dtypes(edd, pset_p, ("p",))
669
+ e = e.join(pset_p, on="p", how="inner")
670
+ if e.height == 0:
671
+ return None
672
+ v_at = Var(name=v_div.name + "__cap_margin_div",
673
+ dims=("p", "d_divest"),
674
+ frame=v_div.frame.rename({"d": "d_divest"}),
675
+ lower=v_div.lower, upper=v_div.upper)
676
+ return Sum(Where(v_at, e), over=("d_divest",))
677
+
678
+ # Constant existing-capacity contribution (subtracted from RHS).
679
+ # Term-by-term so we don't lose nullity: each Param multiplication
680
+ # is well-defined only if all factors are non-None.
681
+ const_terms_add: list = [] # things added on RHS (existing capacity contributions)
682
+
683
+ # — Profile-limited producers (LHS):
684
+ if has_process_data and profile_idx is not None and prof_val is not None:
685
+ # invest variables × profile × unitsize × inv_group_cap
686
+ if has_invest_p:
687
+ inv_sum = _invest_sum(v_invest_p, profile_idx, edd_inv_set, rename_e=True)
688
+ if inv_sum is not None:
689
+ # inv_sum is over (p, d). Where on profile_idx adds
690
+ # (source, sink, f, g) so it can join the dt index.
691
+ term = (Where(inv_sum, profile_idx) * p_us * prof_val * invc)
692
+ lhs["prof_invest"] = Sum(term, over=("p", "source", "sink", "f"))
693
+ if has_divest_p:
694
+ div_sum = _divest_sum(v_divest_p, profile_idx, edd_div_act)
695
+ if div_sum is not None:
696
+ term = -(Where(div_sum, profile_idx) * p_us * prof_val * invc)
697
+ lhs["prof_divest"] = Sum(term, over=("p", "source", "sink", "f"))
698
+ # Constant: existing_count · unitsize · profile · inv_group_cap
699
+ # (= existing[p,d] · profile[f,d,t] · inv_group_cap[g,d]).
700
+ # This becomes an *additive* RHS contribution (we move it from LHS
701
+ # to RHS by sign flip).
702
+ const_terms_add.append(
703
+ ("prof_existing",
704
+ # existing in unit count, * unitsize * profile * inv_group_cap.
705
+ # Sum over (p, source, sink, f) leaves (g, d, t).
706
+ # The Param-only product is realised via Sum-of-1*Param: we use
707
+ # a dummy unit Var? No — instead, build a polars DF of the
708
+ # constant aggregate at constraint emission time, see below.
709
+ profile_idx, prof_val, exist_cnt, p_us, invc))
710
+
711
+ # — Capacity-limited producers (LHS): (no profile factor)
712
+ if has_process_data and capacity_idx is not None and capacity_idx.height > 0:
713
+ if has_invest_p:
714
+ inv_sum = _invest_sum(v_invest_p, capacity_idx, edd_inv_set, rename_e=True)
715
+ if inv_sum is not None:
716
+ term = Where(inv_sum, capacity_idx) * p_us * invc
717
+ lhs["cap_invest"] = Sum(term, over=("p", "source", "sink"))
718
+ if has_divest_p:
719
+ div_sum = _divest_sum(v_divest_p, capacity_idx, edd_div_act)
720
+ if div_sum is not None:
721
+ term = -(Where(div_sum, capacity_idx) * p_us * invc)
722
+ lhs["cap_divest"] = Sum(term, over=("p", "source", "sink"))
723
+ const_terms_add.append(
724
+ ("cap_existing",
725
+ capacity_idx, None, exist_cnt, p_us, invc))
726
+
727
+ # — Consuming units (LHS, negative): for each (p, source, sink) in pss
728
+ # with source ∈ group_node, source ∉ nodeState, p ∈ process_unit.
729
+ # eff: − v_flow * unitsize * slope * sink_coef/source_coef * inv_group_cap.
730
+ # noEff: − v_flow * unitsize * inv_group_cap.
731
+ # min_load_efficiency online section term: − v_online * section * unitsize * inv_group_cap.
732
+ v_flow = vars.get("v_flow")
733
+ v_online_lin = vars.get("v_online_lin")
734
+ v_online_int = vars.get("v_online_int")
735
+ if has_process_data and v_flow is not None:
736
+ gn_src = gn_no_state
737
+ # process_unit filter on the consumer side, mirroring the .mod
738
+ # (the consumer LHS branch also has `p in process_unit`).
739
+ def _filter_unit(df: pl.DataFrame) -> pl.DataFrame:
740
+ if proc_unit is not None and proc_unit.height > 0:
741
+ return df.join(proc_unit.select("p"), on="p", how="inner")
742
+ return df
743
+ # eff consumers
744
+ if pss_eff is not None and pss_eff.height > 0:
745
+ # source carries the entity-union Enum; gn_src.n carries the
746
+ # node-only Enum. align_join_dtypes upcasts to the wider Enum.
747
+ _eff_n = _filter_unit(pss_eff).rename({"source": "n"})
748
+ _eff_n, _gn_src_eff = align_join_dtypes(_eff_n, gn_src, ("n",))
749
+ consuming_eff = (_eff_n
750
+ .join(_gn_src_eff, on="n", how="inner")
751
+ .rename({"n": "source"})) # (p,source,sink,g)
752
+ if consuming_eff.height > 0 and d.p_slope is not None:
753
+ term = -(Where(v_flow, consuming_eff) * p_us * d.p_slope * invc)
754
+ lhs["consume_eff"] = Sum(term, over=("p", "source", "sink"))
755
+ # min_load section term (only if process_min_load_eff has any
756
+ # of these processes).
757
+ if (d.process_min_load_eff is not None
758
+ and d.process_min_load_eff.height > 0
759
+ and d.p_section is not None):
760
+ mle_in_eff = (consuming_eff.join(d.process_min_load_eff,
761
+ on="p", how="inner"))
762
+ if mle_in_eff.height > 0:
763
+ if v_online_lin is not None:
764
+ t_sec = -(Where(v_online_lin, mle_in_eff)
765
+ * d.p_section * p_us * invc)
766
+ lhs["consume_section_lin"] = Sum(
767
+ t_sec, over=("p", "source", "sink"))
768
+ if v_online_int is not None:
769
+ t_sec = -(Where(v_online_int, mle_in_eff)
770
+ * d.p_section * p_us * invc)
771
+ lhs["consume_section_int"] = Sum(
772
+ t_sec, over=("p", "source", "sink"))
773
+ # noEff consumers
774
+ if pss_noEff is not None and pss_noEff.height > 0:
775
+ _noeff_n = _filter_unit(pss_noEff).rename({"source": "n"})
776
+ _noeff_n, _gn_src_noeff = align_join_dtypes(_noeff_n, gn_src, ("n",))
777
+ consuming_noEff = (_noeff_n
778
+ .join(_gn_src_noeff, on="n", how="inner")
779
+ .rename({"n": "source"}))
780
+ if consuming_noEff.height > 0:
781
+ term = -(Where(v_flow, consuming_noEff) * p_us * invc)
782
+ lhs["consume_noEff"] = Sum(term, over=("p", "source", "sink"))
783
+
784
+ # ── RHS terms: existing-capacity LHS contributions (moved from LHS),
785
+ # plus pdGroup_capacity_margin · inv_group_cap, plus
786
+ # −Σ pdtNodeInflow/step_duration · inv_group_cap.
787
+
788
+ # Build the existing-capacity Param aggregates as polars frames so we
789
+ # can put them on the LHS as a Param (constant), but Param needs a
790
+ # ``value`` column. Param goes on the RHS (or LHS) as scalars only;
791
+ # since we want to keep these on the LHS *side* of the inequality
792
+ # alongside the variable terms, we instead subtract them from the RHS.
793
+ # We build (g, d, t) → existing-capacity contribution.
794
+ # However: capacity-limited LHS does *not* depend on t. We broadcast
795
+ # it onto (g, d, t) via a join with d.dt at the end.
796
+ rhs: dict = {}
797
+
798
+ # Compute static existing-capacity contributions per (g, d) or (g, d, t):
799
+ # We'll merge into a single polars DataFrame and add as a Param on RHS
800
+ # with a sign flip (since these are LHS positive contributions):
801
+ # LHS_existing = + cap_existing + prof_existing
802
+ # constraint: LHS_var + LHS_existing >= RHS_terms
803
+ # ⇒ LHS_var >= RHS_terms − LHS_existing
804
+ # On the RHS we add (− LHS_existing).
805
+ existing_dt_frames: list[pl.DataFrame] = [] # (g, d, t, value)
806
+ for entry in const_terms_add:
807
+ kind = entry[0]
808
+ if kind == "prof_existing":
809
+ (_, idx, prof, exist, us, inv) = entry
810
+ # idx = (p, source, sink, f, g)
811
+ base = (idx.join(exist.frame.rename({"value": "exist"}),
812
+ on="p", how="inner")
813
+ .join(us.frame.rename({"value": "us"}),
814
+ on="p", how="inner")
815
+ .join(prof.frame.rename({"value": "prof"}),
816
+ on=["f", "d"], how="inner")
817
+ .join(inv.frame.rename({"value": "inv"}),
818
+ on=["g", "d"], how="inner"))
819
+ # multiply
820
+ agg = (base.with_columns(value=pl.col("exist") * pl.col("us")
821
+ * pl.col("prof") * pl.col("inv"))
822
+ .group_by(["g", "d", "t"]).agg(pl.col("value").sum())
823
+ .select("g", "d", "t", "value"))
824
+ existing_dt_frames.append(agg)
825
+ elif kind == "cap_existing":
826
+ (_, idx, _none, exist, us, inv) = entry
827
+ # idx = (p, source, sink, g) — no t dependence
828
+ base = (idx.join(exist.frame.rename({"value": "exist"}),
829
+ on="p", how="inner")
830
+ .join(us.frame.rename({"value": "us"}),
831
+ on="p", how="inner")
832
+ .join(inv.frame.rename({"value": "inv"}),
833
+ on=["g", "d"], how="inner"))
834
+ agg_gd = (base.with_columns(value=pl.col("exist") * pl.col("us")
835
+ * pl.col("inv"))
836
+ .group_by(["g", "d"]).agg(pl.col("value").sum()))
837
+ agg = agg_gd.join(d.dt, on="d", how="inner").select("g", "d", "t", "value")
838
+ existing_dt_frames.append(agg)
839
+ if existing_dt_frames:
840
+ ex = pl.concat(existing_dt_frames, how="vertical")
841
+ ex = ex.group_by(["g", "d", "t"]).agg(pl.col("value").sum())
842
+ # Negate so it goes to RHS.
843
+ ex = ex.with_columns(value=-pl.col("value"))
844
+ rhs["existing_neg"] = Param(("g", "d", "t"),
845
+ ex.select("g", "d", "t", "value"))
846
+
847
+ # pdGroup[g, 'capacity_margin', d] · inv_group_cap.
848
+ rhs["floor"] = pgcm * invc
849
+
850
+ # − pdtNodeInflow / step_duration · inv_group_cap, summed over n in
851
+ # group_node : n ∉ nodeState.
852
+ inflow_per = getattr(d, "pdtNodeInflow_per_step", None)
853
+ if (inflow_per is not None and gn_no_state is not None
854
+ and gn_no_state.height > 0):
855
+ # Param → polars by joining group_node and inv_group_cap.
856
+ flow_join = (gn_no_state.join(inflow_per.frame.rename({"value": "inflow"}),
857
+ on="n", how="inner")
858
+ .join(invc.frame.rename({"value": "inv"}),
859
+ on=["g", "d"], how="inner"))
860
+ # − inflow * inv per (g, d, t), summed over n.
861
+ rhs_inflow = (flow_join.with_columns(value=-pl.col("inflow") * pl.col("inv"))
862
+ .group_by(["g", "d", "t"]).agg(pl.col("value").sum())
863
+ .select("g", "d", "t", "value"))
864
+ if rhs_inflow.height > 0:
865
+ rhs["inflow_neg"] = Param(("g", "d", "t"), rhs_inflow)
866
+
867
+ m.add_cstr(
868
+ "capacityMargin",
869
+ over = over,
870
+ sense = ">=",
871
+ lhs_terms = lhs,
872
+ rhs_terms = rhs,
873
+ )
874
+
875
+
876
+ # ── inertia_constraint ───────────────────────────────────────────────────
877
+
878
+ def _add_inertia(m, d, vars: dict) -> None:
879
+ """inertia_constraint (mod:3943-3957).
880
+
881
+ Indexed on ``g in groupInertia × (d, t) in dt``. Sense: ``>=``.
882
+
883
+ LHS:
884
+ + Σ_{(p, source, sink) : (p, source) in process_source_inertia,
885
+ (g, source) in group_node}
886
+ · ((v_online if p in process_online) | v_flow else)
887
+ · inertia_constant[p, source]
888
+ · unitsize[p]
889
+ + same for sink-side
890
+ + vq_inertia[g, d, t] * pdGroup[g, 'inertia_limit', d]
891
+
892
+ RHS: pdGroup[g, 'inertia_limit', d].
893
+ """
894
+ if d.groupInertia is None or d.pdGroup_inertia_limit is None:
895
+ return
896
+ if d.group_node is None:
897
+ return
898
+ pss = d.process_source_sink
899
+ if pss is None:
900
+ return
901
+ p_us = d.p_unitsize
902
+ if p_us is None:
903
+ return
904
+
905
+ gi = d.groupInertia
906
+ gn_in_gi = d.group_node.join(gi, on="g", how="inner") # (g, n)
907
+
908
+ # Slack variable.
909
+ vq_dom = gi.join(d.dt, how="cross") # (g, d, t)
910
+ vq_in = m.add_var("vq_inertia", ("g", "d", "t"), vq_dom, lower=0.0)
911
+ vars["vq_inertia"] = vq_in
912
+
913
+ over = vq_dom
914
+
915
+ lhs: dict = {}
916
+ # Slack contribution: vq_inertia[g, d, t] * pdGroup[g, 'inertia_limit', d].
917
+ lhs["slack"] = vq_in * d.pdGroup_inertia_limit
918
+
919
+ v_flow = vars.get("v_flow")
920
+ v_online_lin = vars.get("v_online_lin")
921
+ v_online_int = vars.get("v_online_int")
922
+
923
+ # process_online set used to switch v_online vs v_flow.
924
+ proc_on = d.process_online
925
+ proc_on_lin = d.process_online_linear
926
+ proc_on_int = d.process_online_integer
927
+
928
+ def _inertia_side(side: str, side_idx: pl.DataFrame | None,
929
+ const_param: Param | None) -> None:
930
+ """side ∈ {"source", "sink"}.
931
+
932
+ side_idx = (p, side) where inertia_constant exists.
933
+ const_param = Param(("p", side), value).
934
+ """
935
+ if side_idx is None or side_idx.height == 0 or const_param is None:
936
+ return
937
+ # process__source__sink restricted to the inertia-bearing side.
938
+ # Then filter by (g, side_value) ∈ group_node ∩ groupInertia.
939
+ # Bring g in via join on side_value=node.
940
+ pss_side = pss.join(side_idx, on=["p", side], how="inner")
941
+ # add g: rename side → n for the join. ``pss``'s ``side`` column
942
+ # carries the entity-union Enum (process names + node names);
943
+ # ``gn_in_gi``'s ``n`` column carries the node-only Enum. The
944
+ # raw rename collides those two vocabularies, so reconcile via
945
+ # ``align_join_dtypes`` (preserves the wider Enum when both are
946
+ # Enums, falls back to Utf8 otherwise — per the entity-union
947
+ # axis convention).
948
+ pss_side_renamed = pss_side.rename({side: "n"})
949
+ pss_side_renamed, gn_in_gi_aligned = align_join_dtypes(
950
+ pss_side_renamed, gn_in_gi, ("n",),
951
+ )
952
+ with_g = (pss_side_renamed
953
+ .join(gn_in_gi_aligned, on="n", how="inner")
954
+ .rename({"n": side})) # (p, source, sink, g)
955
+ if with_g.height == 0:
956
+ return
957
+ # Inertia-constant Param has dims (p, side). Multiply by unitsize.
958
+ # Then attach to v_online or v_flow per process.
959
+ coef_psd = const_param * p_us # Param(p, side) -> (p, side)
960
+
961
+ # Online cases: (a) linear UC, (b) integer UC.
962
+ if proc_on_lin is not None and proc_on_lin.height > 0 and v_online_lin is not None:
963
+ with_g_on = with_g.join(proc_on_lin, on="p", how="inner")
964
+ if with_g_on.height > 0:
965
+ # v_online_lin's natural dims are (p, d, t); Where on with_g_on
966
+ # adds (source, sink, g). Then * coef_psd (joined on (p, side)).
967
+ term = Where(v_online_lin, with_g_on) * coef_psd
968
+ lhs[f"online_lin_{side}"] = Sum(term, over=("p", "source", "sink"))
969
+ if proc_on_int is not None and proc_on_int.height > 0 and v_online_int is not None:
970
+ with_g_on = with_g.join(proc_on_int, on="p", how="inner")
971
+ if with_g_on.height > 0:
972
+ term = Where(v_online_int, with_g_on) * coef_psd
973
+ lhs[f"online_int_{side}"] = Sum(term, over=("p", "source", "sink"))
974
+ # Non-online case: v_flow.
975
+ if v_flow is not None:
976
+ with_g_off = with_g
977
+ if proc_on is not None and proc_on.height > 0:
978
+ with_g_off = with_g.join(proc_on, on="p", how="anti")
979
+ if with_g_off.height > 0:
980
+ term = Where(v_flow, with_g_off) * coef_psd
981
+ lhs[f"flow_{side}"] = Sum(term, over=("p", "source", "sink"))
982
+
983
+ _inertia_side("source",
984
+ getattr(d, "process_source_inertia", None),
985
+ getattr(d, "p_process_source_inertia_constant", None))
986
+ _inertia_side("sink",
987
+ getattr(d, "process_sink_inertia", None),
988
+ getattr(d, "p_process_sink_inertia_constant", None))
989
+
990
+ rhs = {"floor": d.pdGroup_inertia_limit}
991
+
992
+ m.add_cstr(
993
+ "inertia_constraint",
994
+ over = over,
995
+ sense = ">=",
996
+ lhs_terms = lhs,
997
+ rhs_terms = rhs,
998
+ )
999
+
1000
+
1001
+ # ── non_sync_constraint ───────────────────────────────────────────────────
1002
+
1003
+ def _add_non_sync(m, d, vars: dict) -> None:
1004
+ """non_sync_constraint (mod:4054-4091).
1005
+
1006
+ Indexed on ``g in groupNonSync × (d, t) in dt``. Sense: ``<=``.
1007
+
1008
+ LHS (every term × inv_group_cap[g, d]):
1009
+ + Σ_{(p,source,sink) : (g, sink) ∈ group_node ∧ (p, sink) ∈
1010
+ process__sink_nonSync ∧ (p, g) ∉
1011
+ process__group_inside_group_nonSync}
1012
+ v_flow[p,source,sink,d,t] * unitsize * step_duration[d,t]
1013
+ + Σ_{(g, n) ∈ group_node} p_positive_inflow[n, d, t]
1014
+ − vq_non_synchronous[g, d, t] * step_duration[d, t]
1015
+
1016
+ RHS:
1017
+ + Σ_outgoing-flow-from-group × pdGroup[g, 'non_synchronous_limit'] × inv_group_cap.
1018
+
1019
+ Outgoing flow has three parts (mirroring the .mod):
1020
+ noEff: v_flow * unitsize * step_duration
1021
+ eff: v_flow * unitsize * slope * sink_coef/source_coef * step_duration
1022
+ eff/min_load_eff section: v_online * unitsize * step_duration * section
1023
+ − exogenous outflow: − Σ_n p_negative_inflow.
1024
+
1025
+ NOTE: We omit the eff sink_coef/source_coef ratio (reflecting the
1026
+ .mod's process_unit-only branch) because our existing constraints
1027
+ already incorporate it via `p_slope` (slope encodes the ratio for
1028
+ indirect/multi-flow). The non_sync constraint in the .mod only
1029
+ treats `process_unit`; for non-unit (connection) processes the ratio
1030
+ is 1 by definition.
1031
+ """
1032
+ if d.groupNonSync is None or d.pdGroup_non_synchronous_limit is None:
1033
+ return
1034
+ if d.group_node is None:
1035
+ return
1036
+ pss = d.process_source_sink
1037
+ if pss is None:
1038
+ return
1039
+ p_us = d.p_unitsize
1040
+ if p_us is None or d.p_step_duration is None:
1041
+ return
1042
+
1043
+ gns = d.groupNonSync
1044
+ invc = d.p_inv_group_cap
1045
+ if invc is None:
1046
+ return
1047
+ gn_in_gns = d.group_node.join(gns, on="g", how="inner") # (g, n)
1048
+ proc_inside = d.process_group_inside_nonSync # (p, g) | None
1049
+ sink_ns = d.process_sink_nonSync # (p, sink) | None
1050
+
1051
+ # Slack variable.
1052
+ vq_dom = gns.join(d.dt, how="cross")
1053
+ vq_ns = m.add_var("vq_non_synchronous", ("g", "d", "t"), vq_dom, lower=0.0)
1054
+ vars["vq_non_synchronous"] = vq_ns
1055
+
1056
+ over = vq_dom
1057
+
1058
+ # ── LHS ──────────────────────────────────────────────────────────────
1059
+ lhs: dict = {}
1060
+ v_flow = vars.get("v_flow")
1061
+
1062
+ # − vq_non_synchronous * step_duration (via inv_group_cap-cancellation,
1063
+ # this becomes − vq * step_duration).
1064
+ lhs["slack_neg"] = -(vq_ns * d.p_step_duration)
1065
+
1066
+ # Incoming non-sync flows.
1067
+ if (sink_ns is not None and sink_ns.height > 0 and v_flow is not None):
1068
+ # (p, source, sink) in pss with (p, sink) ∈ process__sink_nonSync.
1069
+ pss_in = pss.join(sink_ns, on=["p", "sink"], how="inner")
1070
+ # (g, sink) in group_node ∩ groupNonSync (sink → n for the join).
1071
+ pss_in_g = (pss_in.rename({"sink": "n"})
1072
+ .join(gn_in_gns, on="n", how="inner")
1073
+ .rename({"n": "sink"}))
1074
+ # Drop (p, g) ∈ process__group_inside_group_nonSync.
1075
+ if proc_inside is not None and proc_inside.height > 0:
1076
+ pss_in_g = pss_in_g.join(proc_inside, on=["p", "g"], how="anti")
1077
+ if pss_in_g.height > 0:
1078
+ term = (Where(v_flow, pss_in_g) * p_us * d.p_step_duration * invc)
1079
+ lhs["incoming_nonSync"] = Sum(term, over=("p", "source", "sink"))
1080
+
1081
+ # Exogenous positive inflow (assumed non-synchronous in the .mod).
1082
+ p_pos = d.p_positive_inflow
1083
+ if p_pos is not None and gn_in_gns.height > 0:
1084
+ joined = (gn_in_gns.join(p_pos.frame.rename({"value": "v"}),
1085
+ on="n", how="inner")
1086
+ .join(invc.frame.rename({"value": "iv"}),
1087
+ on=["g", "d"], how="inner")
1088
+ .with_columns(value=pl.col("v") * pl.col("iv"))
1089
+ .group_by(["g", "d", "t"])
1090
+ .agg(pl.col("value").sum())
1091
+ .select("g", "d", "t", "value"))
1092
+ if joined.height > 0:
1093
+ lhs["exo_inflow"] = Param(("g", "d", "t"), joined)
1094
+
1095
+ # ── RHS ──────────────────────────────────────────────────────────────
1096
+ pgns = d.pdGroup_non_synchronous_limit # Param (g, d)
1097
+ rhs: dict = {}
1098
+
1099
+ pss_eff = d.process_source_sink_eff
1100
+ pss_noEff = d.process_source_sink_noEff
1101
+ p_slope = d.p_slope
1102
+
1103
+ # Outgoing flow (source ∈ group_node) with the source-not-inside guard.
1104
+ def _filter_out_inside(idx_g: pl.DataFrame) -> pl.DataFrame:
1105
+ if proc_inside is None or proc_inside.height == 0:
1106
+ return idx_g
1107
+ return idx_g.join(proc_inside, on=["p", "g"], how="anti")
1108
+
1109
+ # Per‐tuple coef: unitsize · step_duration · pdGroup_non_synchronous_limit · inv_group_cap.
1110
+ # We keep these chained as Params and let the engine broadcast at term time.
1111
+ out_factor = p_us * d.p_step_duration * pgns * invc
1112
+
1113
+ if pss_noEff is not None and pss_noEff.height > 0 and v_flow is not None:
1114
+ out_idx_noEff = (pss_noEff.rename({"source": "n"})
1115
+ .join(gn_in_gns, on="n", how="inner")
1116
+ .rename({"n": "source"}))
1117
+ out_idx_noEff = _filter_out_inside(out_idx_noEff)
1118
+ if out_idx_noEff.height > 0:
1119
+ term = Where(v_flow, out_idx_noEff) * out_factor
1120
+ rhs["out_noEff"] = Sum(term, over=("p", "source", "sink"))
1121
+ if pss_eff is not None and pss_eff.height > 0 and v_flow is not None:
1122
+ out_idx_eff = (pss_eff.rename({"source": "n"})
1123
+ .join(gn_in_gns, on="n", how="inner")
1124
+ .rename({"n": "source"}))
1125
+ out_idx_eff = _filter_out_inside(out_idx_eff)
1126
+ if out_idx_eff.height > 0:
1127
+ if p_slope is not None:
1128
+ term = Where(v_flow, out_idx_eff) * p_slope * out_factor
1129
+ else:
1130
+ term = Where(v_flow, out_idx_eff) * out_factor
1131
+ rhs["out_eff"] = Sum(term, over=("p", "source", "sink"))
1132
+ # Section term for min_load_eff online.
1133
+ if (d.process_min_load_eff is not None
1134
+ and d.process_min_load_eff.height > 0
1135
+ and d.p_section is not None):
1136
+ mle_idx = out_idx_eff.join(d.process_min_load_eff,
1137
+ on="p", how="inner")
1138
+ if mle_idx.height > 0:
1139
+ v_online_lin = vars.get("v_online_lin")
1140
+ v_online_int = vars.get("v_online_int")
1141
+ if v_online_lin is not None:
1142
+ t_sec = Where(v_online_lin, mle_idx) * d.p_section * out_factor
1143
+ rhs["out_section_lin"] = Sum(t_sec, over=("p", "source", "sink"))
1144
+ if v_online_int is not None:
1145
+ t_sec = Where(v_online_int, mle_idx) * d.p_section * out_factor
1146
+ rhs["out_section_int"] = Sum(t_sec, over=("p", "source", "sink"))
1147
+
1148
+ # Exogenous demand (-p_negative_inflow): pdGroup * inv_group_cap * Σ_n -p_neg.
1149
+ p_neg = d.p_negative_inflow
1150
+ if p_neg is not None and gn_in_gns.height > 0:
1151
+ joined = (gn_in_gns.join(p_neg.frame.rename({"value": "v"}),
1152
+ on="n", how="inner")
1153
+ .join(invc.frame.rename({"value": "iv"}),
1154
+ on=["g", "d"], how="inner")
1155
+ .join(pgns.frame.rename({"value": "lim"}),
1156
+ on=["g", "d"], how="inner")
1157
+ .with_columns(value=-pl.col("v") * pl.col("iv")
1158
+ * pl.col("lim"))
1159
+ .group_by(["g", "d", "t"])
1160
+ .agg(pl.col("value").sum())
1161
+ .select("g", "d", "t", "value"))
1162
+ if joined.height > 0:
1163
+ rhs["exo_demand"] = Param(("g", "d", "t"), joined)
1164
+
1165
+ m.add_cstr(
1166
+ "non_sync_constraint",
1167
+ over = over,
1168
+ sense = "<=",
1169
+ lhs_terms = lhs,
1170
+ rhs_terms = rhs,
1171
+ )
1172
+
1173
+
1174
+ # ---------------------------------------------------------------------------
1175
+ # Objective contribution
1176
+
1177
+ def add_objective_terms(m, d, vars: dict, op_factor) -> "Expr | None":
1178
+ """Build the slack-penalty contribution. Returns ``None`` if no
1179
+ feature is active (caller must guard with ``or 0``).
1180
+
1181
+ Terms (audit/objective_audit.md §9):
1182
+
1183
+ 9.1 Inertia slack:
1184
+ + Σ_{g,d,t} pdt_branch_weight · vq_inertia · pdGroup_inertia_limit
1185
+ · pdGroup_penalty_inertia · step_duration · timestep_weight
1186
+ · inflation_op / period_share
1187
+ The ``op_factor`` encodes ``step_duration · timestep_weight ·
1188
+ inflation_op / period_share``; the model.py caller folds
1189
+ ``pdt_branch_weight`` into ``op_factor`` when stochastics is active
1190
+ (A6 close), so this term inherits the per-(d,t) probability
1191
+ automatically.
1192
+
1193
+ 9.2 Non-sync slack:
1194
+ + Σ_{g,d,t} pdt_branch_weight · vq_non_synchronous · group_capacity_for_scaling
1195
+ · pdGroup_penalty_non_synchronous · step_duration · timestep_weight
1196
+ · inflation_op / period_share
1197
+
1198
+ 9.3 Capacity-margin slack (period-only, NO step_duration / timestep_weight /
1199
+ pdt_branch_weight):
1200
+ + Σ_{g,d} vq_capacity_margin · group_capacity_for_scaling
1201
+ · pdGroup_penalty_capacity_margin · 1000 · inflation_op
1202
+ Asymmetry preserved per .mod — capacity_margin is a planning-level
1203
+ slack and intentionally un-weighted by pdt_branch_weight. See
1204
+ audit/a6_stochastic_audit.md §5.
1205
+
1206
+ The ``× 1000`` converts ``penalty_capacity_margin`` from the
1207
+ template unit ``CUR/kW`` to the objective unit ``CUR/MW``
1208
+ (``vq_capacity_margin`` is in MW). This matches the convention
1209
+ established by
1210
+ :mod:`flextool.process_outputs.calc_slacks` (commit 6a03dd90 —
1211
+ "Cost aggregation semantic fixes"). BUG A4 fix.
1212
+ """
1213
+ pieces: list[Expr] = []
1214
+
1215
+ if "vq_inertia" in vars and d.pdGroup_inertia_limit is not None \
1216
+ and d.pdGroup_penalty_inertia is not None:
1217
+ pieces.append(Sum(
1218
+ vars["vq_inertia"]
1219
+ * d.pdGroup_inertia_limit
1220
+ * d.pdGroup_penalty_inertia
1221
+ * op_factor))
1222
+
1223
+ if "vq_non_synchronous" in vars and d.p_group_capacity_for_scaling is not None \
1224
+ and d.pdGroup_penalty_non_synchronous is not None:
1225
+ pieces.append(Sum(
1226
+ vars["vq_non_synchronous"]
1227
+ * d.p_group_capacity_for_scaling
1228
+ * d.pdGroup_penalty_non_synchronous
1229
+ * op_factor))
1230
+
1231
+ if "vq_capacity_margin" in vars and d.p_group_capacity_for_scaling is not None \
1232
+ and d.pdGroup_penalty_capacity_margin is not None \
1233
+ and d.p_inflation_op is not None:
1234
+ # Period-only term: no step_duration / timestep_weight / period_share.
1235
+ # BUG A4 fix: include the × 1000 unit conversion (CUR/kW → CUR/MW)
1236
+ # that matches ``calc_slacks.costPenalty_capacity_margin_d``'s
1237
+ # ``.mul(1000.0)``. Without it the LP objective under-counts the
1238
+ # capacity-margin slack penalty by 1000× and
1239
+ # ``test_solver_matches_python`` reports a 1.58 M-CUR delta on the
1240
+ # ``capacity_margin`` scenario.
1241
+ pieces.append(Sum(
1242
+ vars["vq_capacity_margin"]
1243
+ * d.p_group_capacity_for_scaling
1244
+ * d.pdGroup_penalty_capacity_margin
1245
+ * d.p_inflation_op
1246
+ * 1000.0))
1247
+
1248
+ if not pieces:
1249
+ return None
1250
+ out = pieces[0]
1251
+ for p in pieces[1:]:
1252
+ out = out + p
1253
+ return out