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,1165 @@
1
+ """Cumulative / group-invest / min-max invest constraint pack.
2
+
3
+ This module is one of the ``_*.py`` feature packs that the merge agent
4
+ plugs into ``flextool.model.build_flextool``. It is **self-contained**:
5
+ it imports only from ``polar_high`` and reads the ``FlexData`` (``d``)
6
+ + the ``vars`` dict that the caller supplies — it never touches
7
+ ``flextool/model.py`` or ``flextool/input.py``.
8
+
9
+ Constraints emitted (mod-name → polar_high row name):
10
+
11
+ Per-entity invest / divest, period-scope (mirror of the existing
12
+ ``maxInvest_entity_period`` / ``maxDivest_entity_period`` already in
13
+ model.py — we add the **min** counterparts and the
14
+ ``no_investment``-pin):
15
+
16
+ * ``minInvest_entity_period`` — per (e, d) lower bound
17
+ * ``minDivest_entity_period`` — per (e, d) lower bound
18
+ * ``fix_v_invest_no_investment_eq``— pin v_invest = 0 on
19
+ ``ed_invest_forbidden_no_investment``
20
+
21
+ Per-entity invest / divest, total-scope (sister of the existing
22
+ ``maxInvest_entity_total`` / ``maxDivest_entity_total``):
23
+
24
+ * ``minInvest_entity_total`` — per (e, d) lower bound on
25
+ Σ_d_invest v_invest
26
+ * ``minDivest_entity_total`` — per e lower bound
27
+
28
+ Cumulative (existing + invest − divest) capacity, per-(e, d):
29
+
30
+ * ``maxCumulative_capacity``
31
+ * ``minCumulative_capacity``
32
+
33
+ Group-level invest / divest:
34
+
35
+ * ``maxInvestGroup_entity_period`` / ``minInvestGroup_entity_period``
36
+ * ``maxDivestGroup_entity_period`` / ``minDivestGroup_entity_period``
37
+ * ``maxInvestGroup_entity_total`` / ``minInvestGroup_entity_total``
38
+ * ``maxDivestGroup_entity_total`` / ``minDivestGroup_entity_total``
39
+ * ``maxInvestGroup_entity_cumulative``
40
+ * ``minInvestGroup_entity_cumulative``
41
+
42
+ Group-level cumulative / instant flow bounds:
43
+
44
+ * ``maxCumulative_flow_solve`` / ``minCumulative_flow_solve``
45
+ * ``maxCumulative_flow_period`` / ``minCumulative_flow_period``
46
+ * ``maxInstant_flow`` / ``minInstant_flow``
47
+
48
+ Variables are **not** declared here — the caller's ``v_invest_p``,
49
+ ``v_invest_n``, ``v_divest_p``, ``v_divest_n``, ``v_flow``,
50
+ ``v_online_linear``, ``v_online_integer`` are supplied in ``vars``.
51
+
52
+ Deferred (require feature stack outside this module's scope):
53
+
54
+ * ``p_years_represented_d`` weighting on the cumulative-flow-solve
55
+ LHS (mod uses ``p_rp_cost_weight * p_years_represented_d /
56
+ complete_period_share_of_year`` per (d, t); we use the simpler
57
+ ``step_duration`` integration since current scenarios run with
58
+ ``p_years_represented_d = 1`` and a single representative year).
59
+ * ``p_process_sink_conversion_flow_coeff /
60
+ p_process_source_conversion_flow_coeff`` factor on eff source-flow
61
+ contributions to the cumulative/instant flow LHS — same gap as the
62
+ existing ``nodeBalance_eq``.
63
+ * ``p_entity_previously_invested_capacity`` and ``p_entity_divested``
64
+ history terms on the per-entity-total and group-total constraints
65
+ — only relevant for multi-solve handoffs.
66
+ * ``inv_group_cap`` row scaling on the group-flow constraints.
67
+ * The ``multi_year_wind_growth_cap`` per-period cap (no single mod
68
+ constraint corresponds; appears to be an externally-preprocessed
69
+ per-(e, d) ``ed_invest_max_period`` override that already lives
70
+ on the existing ``maxInvest_entity_period`` constraint — no new
71
+ constraint emitted here).
72
+ """
73
+
74
+ from __future__ import annotations
75
+
76
+ import polars as pl
77
+ from polar_high import Sum, Where, Param
78
+ from polar_high.engine import Var
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Field requirements
83
+
84
+ # Lightweight feature gate: any of these fields, populated and non-empty,
85
+ # turns this pack on. We don't insist on any single one — the pack
86
+ # emits whichever subset of constraints have data backing them.
87
+ _GATE_FIELDS: tuple[str, ...] = (
88
+ # per-entity totals (max already in model.py — min added here)
89
+ "e_invest_min_total", "e_divest_min_total",
90
+ # per-entity period (max already in model.py — min added here)
91
+ "ed_invest_min_period", "ed_divest_min_period",
92
+ # no_investment pin
93
+ "ed_invest_forbidden_no_investment",
94
+ # cumulative capacity
95
+ "ed_invest_cumulative", "ed_cumulative_max_capacity",
96
+ "ed_cumulative_min_capacity",
97
+ # group-invest
98
+ "gd_invest_period", "gd_divest_period",
99
+ "g_invest_total", "g_divest_total", "g_invest_cumulative",
100
+ "group_entity",
101
+ # group-flow
102
+ "p_group_max_cumulative_flow", "p_group_min_cumulative_flow",
103
+ "pd_max_cumulative_flow", "pd_min_cumulative_flow",
104
+ "pdt_max_instant_flow", "pdt_min_instant_flow",
105
+ "group_process_node",
106
+ )
107
+
108
+
109
+ def has_feature(d) -> bool:
110
+ """True iff the data carries any of the cumulative / group-invest /
111
+ min-max-invest fields populated and non-empty."""
112
+ for f in _GATE_FIELDS:
113
+ v = getattr(d, f, None)
114
+ if v is None:
115
+ continue
116
+ # ``_GATE_FIELDS`` mixes raw ``pl.DataFrame`` sets (``.height``)
117
+ # and ``Param`` caps (``.frame.height``); normalise both.
118
+ frame = getattr(v, "frame", v)
119
+ if getattr(frame, "height", 0) > 0:
120
+ return True
121
+ return False
122
+
123
+
124
+ def load_data(*args, **kwargs):
125
+ """No-op. ``flextool/input.py`` is the canonical loader; this pack
126
+ only consumes whatever fields the loader has populated. Kept on the
127
+ module API to satisfy the merge-agent contract."""
128
+ return None
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Helpers
133
+
134
+ def _is_node(d, e_set: pl.DataFrame) -> pl.DataFrame:
135
+ """Return the subset of ``e_set`` whose ``e`` column is a node."""
136
+ if d.nodeState is None or d.nodeState.height == 0:
137
+ return e_set.head(0)
138
+ return e_set.filter(pl.col("e").is_in(d.nodeState["n"].unique()))
139
+
140
+
141
+ def _is_process(d, e_set: pl.DataFrame) -> pl.DataFrame:
142
+ if d.process_source_sink is None or d.process_source_sink.height == 0:
143
+ return e_set.head(0)
144
+ return e_set.filter(pl.col("e").is_in(d.process_source_sink["p"].unique()))
145
+
146
+
147
+ def _us_p(d) -> Param | None:
148
+ """Process-side unitsize (renamed from p → e for symmetric joins)."""
149
+ if d.p_unitsize is None:
150
+ return None
151
+ return Param(("e",), d.p_unitsize.frame.rename({"p": "e"}))
152
+
153
+
154
+ def _us_n(d) -> Param | None:
155
+ """Node-side unitsize (renamed from n → e)."""
156
+ if d.p_state_unitsize is None:
157
+ return None
158
+ return Param(("e",), d.p_state_unitsize.frame.rename({"n": "e"}))
159
+
160
+
161
+ def _v_invest_at(v_invest, side: str):
162
+ """Rename a v_invest variable's ``d`` dim to ``d_invest`` so it can
163
+ participate in Σ_{d_invest} aggregations bound to a constraint
164
+ indexed by an outer ``d``."""
165
+ if v_invest is None:
166
+ return None
167
+ pri = "p" if side == "p" else "n"
168
+ return Var(
169
+ name=v_invest.name + "__cuminv_at",
170
+ dims=(pri, "d_invest"),
171
+ frame=v_invest.frame.rename({"d": "d_invest"}),
172
+ lower=v_invest.lower, upper=v_invest.upper,
173
+ )
174
+
175
+
176
+ def _eff_lhs_term(d, vars):
177
+ """Build the eff-source-side LHS contribution that is shared by the
178
+ cumulative- and instant-flow constraints.
179
+
180
+ Returns an Expr over (g, d, t) (when called inside a flow_period /
181
+ flow_solve constraint we further multiply by step_duration before
182
+ binding). When ``flow_from_n`` is missing or empty the term is
183
+ None. Mirrors the .mod's
184
+
185
+ - sum_{(p,n,sink) in pss_eff} v_flow * unitsize * slope
186
+ - section term (deferred — only relevant when min_load_eff
187
+ entities are inside a group; we conservatively skip).
188
+ """
189
+ v_flow = vars.get("v_flow")
190
+ if v_flow is None or d.process_source_sink_eff is None:
191
+ return None
192
+ if d.flow_from_n is None or d.flow_from_n.height == 0:
193
+ return None
194
+ if d.group_process_node is None or d.group_process_node.height == 0:
195
+ return None
196
+
197
+ # Restrict flow_from_n to eff partition + the (p, n) pairs the group
198
+ # sees. flow_from_n has columns (p, source, sink, n) where n is the
199
+ # source node; group_process_node has (g, p, n).
200
+ pss_eff = d.process_source_sink_eff
201
+ flow_from_n_eff = (d.flow_from_n
202
+ .join(pss_eff, on=["p", "source", "sink"], how="inner")
203
+ .join(d.group_process_node, on=["p", "n"], how="inner"))
204
+ if flow_from_n_eff.height == 0:
205
+ return None
206
+ # eff source-flow: v_flow * unitsize * slope (introduces g via the join)
207
+ return -Sum(
208
+ Where(v_flow * d.p_unitsize * d.p_slope, flow_from_n_eff),
209
+ over=("p", "source", "sink", "n"),
210
+ )
211
+
212
+
213
+ def _noEff_lhs_term(d, vars):
214
+ """Source-noEff contribution: ``- Σ v_flow * unitsize`` over (p,
215
+ source, sink, n) where (p, n, sink) ∈ pss_noEff and (g, p, n) ∈
216
+ group_process_node."""
217
+ v_flow = vars.get("v_flow")
218
+ if v_flow is None or d.process_source_sink_noEff is None:
219
+ return None
220
+ if d.flow_from_n is None or d.flow_from_n.height == 0:
221
+ return None
222
+ if d.group_process_node is None or d.group_process_node.height == 0:
223
+ return None
224
+ flow_from_n_noeff = (d.flow_from_n
225
+ .join(d.process_source_sink_noEff,
226
+ on=["p", "source", "sink"], how="inner")
227
+ .join(d.group_process_node, on=["p", "n"], how="inner"))
228
+ if flow_from_n_noeff.height == 0:
229
+ return None
230
+ return -Sum(
231
+ Where(v_flow * d.p_unitsize, flow_from_n_noeff),
232
+ over=("p", "source", "sink", "n"),
233
+ )
234
+
235
+
236
+ def _sink_lhs_term(d, vars):
237
+ """Sink-flow contribution: ``+ Σ v_flow * unitsize`` over (p,
238
+ source, sink) where (p, source, n=sink) ∈ pss and (g, p, n) ∈
239
+ group_process_node."""
240
+ v_flow = vars.get("v_flow")
241
+ if v_flow is None or d.process_source_sink is None:
242
+ return None
243
+ if d.flow_to_n is None or d.flow_to_n.height == 0:
244
+ return None
245
+ if d.group_process_node is None or d.group_process_node.height == 0:
246
+ return None
247
+ flow_to_n_grp = d.flow_to_n.join(
248
+ d.group_process_node, on=["p", "n"], how="inner")
249
+ if flow_to_n_grp.height == 0:
250
+ return None
251
+ return Sum(
252
+ Where(v_flow * d.p_unitsize, flow_to_n_grp),
253
+ over=("p", "source", "sink", "n"),
254
+ )
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Constraint emission
259
+
260
+ def add_constraints(m, d, vars: dict) -> None:
261
+ """Emit every constraint family this pack handles, gated row-by-row
262
+ on the underlying ``FlexData`` field being populated."""
263
+
264
+ v_invest_p = vars.get("v_invest_p")
265
+ v_invest_n = vars.get("v_invest_n")
266
+ vars.get("v_divest_p")
267
+ vars.get("v_divest_n")
268
+
269
+ has_inv_p = v_invest_p is not None
270
+ has_inv_n = v_invest_n is not None
271
+
272
+ # ─── fix_v_invest_no_investment_eq ──────────────────────────────
273
+ # Pin v_invest[e, d] == 0 on (e, d) ∈ ed_invest_forbidden_no_investment.
274
+ # Mod: flextool.mod ``fix_v_invest_no_investment_eq``.
275
+ forbid = getattr(d, "ed_invest_forbidden_no_investment", None)
276
+ if forbid is not None and forbid.height > 0:
277
+ if has_inv_p:
278
+ # ``forbid["e"]`` is the entity-union Enum; after renaming to
279
+ # ``p`` we must align it with ``v_invest_p``'s narrower
280
+ # process-only ``p`` Enum before joining (Phase 4 Enum
281
+ # cross-vocab guard — never cast back to Utf8).
282
+ from ._axis_enums import align_join_dtypes
283
+ f_p = forbid.rename({"e": "p"})
284
+ inv_p_idx = v_invest_p.frame.select("p", "d")
285
+ f_p, inv_p_idx = align_join_dtypes(f_p, inv_p_idx, ("p", "d"))
286
+ f_p = f_p.join(inv_p_idx, on=["p", "d"], how="inner")
287
+ if f_p.height > 0:
288
+ m.add_cstr(
289
+ "fix_v_invest_no_investment_eq_p",
290
+ over = f_p,
291
+ sense = "==",
292
+ lhs_terms = {"invest": v_invest_p},
293
+ rhs_terms = {"zero": 0.0},
294
+ )
295
+ if has_inv_n:
296
+ from ._axis_enums import align_join_dtypes
297
+ f_n = forbid.rename({"e": "n"})
298
+ inv_n_idx = v_invest_n.frame.select("n", "d")
299
+ f_n, inv_n_idx = align_join_dtypes(f_n, inv_n_idx, ("n", "d"))
300
+ f_n = f_n.join(inv_n_idx, on=["n", "d"], how="inner")
301
+ if f_n.height > 0:
302
+ m.add_cstr(
303
+ "fix_v_invest_no_investment_eq_n",
304
+ over = f_n,
305
+ sense = "==",
306
+ lhs_terms = {"invest": v_invest_n},
307
+ rhs_terms = {"zero": 0.0},
308
+ )
309
+
310
+ # ─── minInvest_entity_period / minDivest_entity_period ───────────
311
+ # Mirror of the existing maxInvest/maxDivest_entity_period in
312
+ # model.py:402-455 but with sense `>=` and a different RHS param.
313
+ _emit_invest_period_minmax(m, d, vars, kind="invest", sense=">=")
314
+ _emit_invest_period_minmax(m, d, vars, kind="divest", sense=">=")
315
+
316
+ # ─── minInvest_entity_total / minDivest_entity_total ─────────────
317
+ # Sister of model.py:459-531 maxInvest/maxDivest_entity_total.
318
+ _emit_invest_total_minmax(m, d, vars, kind="invest", sense=">=")
319
+ _emit_invest_total_minmax(m, d, vars, kind="divest", sense=">=")
320
+
321
+ # ─── maxCumulative_capacity / minCumulative_capacity ─────────────
322
+ # LHS: + p_entity_all_existing[e,d]
323
+ # + Σ_{d_invest} v_invest[e, d_invest] * unitsize
324
+ # - v_divest[e, d] * unitsize (if (e, d) ∈ ed_divest)
325
+ # RHS: ed_cumulative_{max,min}_capacity[e, d]
326
+ _emit_cumulative_capacity(m, d, vars, sense="<=")
327
+ _emit_cumulative_capacity(m, d, vars, sense=">=")
328
+
329
+ # ─── Group invest / divest, period scope ─────────────────────────
330
+ _emit_group_invest_period(m, d, vars, kind="invest", sense="<=")
331
+ _emit_group_invest_period(m, d, vars, kind="invest", sense=">=")
332
+ _emit_group_invest_period(m, d, vars, kind="divest", sense="<=")
333
+ _emit_group_invest_period(m, d, vars, kind="divest", sense=">=")
334
+
335
+ # ─── Group invest / divest, total scope ──────────────────────────
336
+ _emit_group_invest_total(m, d, vars, kind="invest", sense="<=")
337
+ _emit_group_invest_total(m, d, vars, kind="invest", sense=">=")
338
+ _emit_group_invest_total(m, d, vars, kind="divest", sense="<=")
339
+ _emit_group_invest_total(m, d, vars, kind="divest", sense=">=")
340
+
341
+ # ─── Group invest, cumulative (max + min) ────────────────────────
342
+ _emit_group_invest_cumulative(m, d, vars, sense="<=")
343
+ _emit_group_invest_cumulative(m, d, vars, sense=">=")
344
+
345
+ # ─── Group cumulative-flow / instant-flow ────────────────────────
346
+ _emit_cumulative_flow_solve(m, d, vars, sense="<=")
347
+ _emit_cumulative_flow_solve(m, d, vars, sense=">=")
348
+ _emit_cumulative_flow_period(m, d, vars, sense="<=")
349
+ _emit_cumulative_flow_period(m, d, vars, sense=">=")
350
+ _emit_instant_flow(m, d, vars, sense="<=")
351
+ _emit_instant_flow(m, d, vars, sense=">=")
352
+
353
+
354
+ def add_objective_terms(m, d, vars: dict, op_factor) -> None:
355
+ """No objective contribution — every constraint here is a structural
356
+ (in)equality, not a cost term."""
357
+ return None
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Per-entity period min/max
362
+
363
+ def _emit_invest_period_minmax(m, d, vars: dict,
364
+ kind: str, sense: str) -> None:
365
+ """Emit the min variant of {invest|divest}_entity_period."""
366
+ # Pull the right (set, max-param, min-param) trio.
367
+ if kind == "invest":
368
+ idx_set = getattr(d, "ed_invest_period_set", None)
369
+ cap_param = getattr(d, "ed_invest_min_period", None)
370
+ v_p = vars.get("v_invest_p")
371
+ v_n = vars.get("v_invest_n")
372
+ else: # divest
373
+ idx_set = getattr(d, "ed_divest_period_set", None)
374
+ cap_param = getattr(d, "ed_divest_min_period", None)
375
+ v_p = vars.get("v_divest_p")
376
+ v_n = vars.get("v_divest_n")
377
+ if idx_set is None or idx_set.height == 0 or cap_param is None:
378
+ return
379
+ suffix = f"min{kind.capitalize()}_entity_period"
380
+
381
+ # Process side
382
+ if v_p is not None and d.p_unitsize is not None:
383
+ sub = idx_set.rename({"e": "p"}).join(
384
+ v_p.frame.select("p", "d"), on=["p", "d"], how="inner")
385
+ if sub.height > 0:
386
+ cap = Param(("p", "d"), cap_param.frame.rename({"e": "p"}))
387
+ m.add_cstr(
388
+ f"{suffix}_p",
389
+ over = sub,
390
+ sense = sense,
391
+ lhs_terms = {kind: v_p * d.p_unitsize},
392
+ rhs_terms = {"cap": cap},
393
+ )
394
+ # Node side
395
+ if v_n is not None and d.p_state_unitsize is not None:
396
+ sub = idx_set.rename({"e": "n"}).join(
397
+ v_n.frame.select("n", "d"), on=["n", "d"], how="inner")
398
+ if sub.height > 0:
399
+ cap = Param(("n", "d"), cap_param.frame.rename({"e": "n"}))
400
+ us = Param(("n",), d.p_state_unitsize.frame)
401
+ m.add_cstr(
402
+ f"{suffix}_n",
403
+ over = sub,
404
+ sense = sense,
405
+ lhs_terms = {kind: v_n * us},
406
+ rhs_terms = {"cap": cap},
407
+ )
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # Per-entity total min/max
412
+
413
+ def _emit_invest_total_minmax(m, d, vars: dict,
414
+ kind: str, sense: str) -> None:
415
+ """Emit ``minInvest_entity_total`` / ``minDivest_entity_total``.
416
+
417
+ For invest: indexed by (e, d) — Σ_{d_invest in edd_invest} v_invest
418
+ summed; vs ``e_invest_min_total[e]``.
419
+ For divest: indexed by e only — Σ_d v_divest summed; vs
420
+ ``e_divest_min_total[e]``.
421
+ """
422
+ if kind == "invest":
423
+ e_set = getattr(d, "e_invest_total", None)
424
+ cap_param = getattr(d, "e_invest_min_total", None)
425
+ edd_set = getattr(d, "edd_invest_set", None)
426
+ v_p = vars.get("v_invest_p")
427
+ v_n = vars.get("v_invest_n")
428
+ else:
429
+ e_set = getattr(d, "e_divest_total", None)
430
+ cap_param = getattr(d, "e_divest_min_total", None)
431
+ edd_set = None
432
+ v_p = vars.get("v_divest_p")
433
+ v_n = vars.get("v_divest_n")
434
+ if e_set is None or e_set.height == 0 or cap_param is None:
435
+ return
436
+ cstr_name = f"min{kind.capitalize()}_entity_total"
437
+
438
+ # Process side
439
+ if v_p is not None and d.p_unitsize is not None:
440
+ e_p = _is_process(d, e_set).rename({"e": "p"})
441
+ if e_p.height > 0:
442
+ cap_p = Param(("p",), cap_param.frame.rename({"e": "p"}))
443
+ if kind == "invest":
444
+ # over = (e_p × period_invest), as in maxInvest_entity_total.
445
+ # We don't have period_invest as a separate frame, but
446
+ # the canonical way is to use edd_invest's outer d column.
447
+ if edd_set is None:
448
+ return
449
+ edd_p = (edd_set.rename({"e": "p"}).join(
450
+ e_p, on="p", how="inner")
451
+ .filter(pl.col("p").is_in(v_p.frame["p"].unique())))
452
+ if edd_p.height == 0:
453
+ return
454
+ # outer index = unique (p, d) from edd_p
455
+ outer = edd_p.select("p", "d").unique()
456
+ v_inv_at = Var(
457
+ name=v_p.name + "__cuminv_total_at",
458
+ dims=("p", "d_invest"),
459
+ frame=v_p.frame.rename({"d": "d_invest"}),
460
+ lower=v_p.lower, upper=v_p.upper,
461
+ )
462
+ inv_sum = Sum(Where(v_inv_at * d.p_unitsize, edd_p),
463
+ over=("d_invest",))
464
+ m.add_cstr(
465
+ f"{cstr_name}_p",
466
+ over = outer,
467
+ sense = sense,
468
+ lhs_terms = {"invest_total": inv_sum},
469
+ rhs_terms = {"cap": cap_p},
470
+ )
471
+ else: # divest — sum over d
472
+ m.add_cstr(
473
+ f"{cstr_name}_p",
474
+ over = e_p,
475
+ sense = sense,
476
+ lhs_terms = {"divest_total":
477
+ Sum(Where(v_p * d.p_unitsize, e_p), over=("d",))},
478
+ rhs_terms = {"cap": cap_p},
479
+ )
480
+
481
+ # Node side
482
+ if v_n is not None and d.p_state_unitsize is not None:
483
+ e_n = _is_node(d, e_set).rename({"e": "n"})
484
+ if e_n.height > 0:
485
+ cap_n = Param(("n",), cap_param.frame.rename({"e": "n"}))
486
+ us_n = Param(("n",), d.p_state_unitsize.frame)
487
+ if kind == "invest":
488
+ if edd_set is None:
489
+ return
490
+ edd_n = (edd_set.rename({"e": "n"}).join(
491
+ e_n, on="n", how="inner")
492
+ .filter(pl.col("n").is_in(v_n.frame["n"].unique())))
493
+ if edd_n.height == 0:
494
+ return
495
+ outer = edd_n.select("n", "d").unique()
496
+ v_inv_at = Var(
497
+ name=v_n.name + "__cuminv_total_at",
498
+ dims=("n", "d_invest"),
499
+ frame=v_n.frame.rename({"d": "d_invest"}),
500
+ lower=v_n.lower, upper=v_n.upper,
501
+ )
502
+ inv_sum = Sum(Where(v_inv_at * us_n, edd_n),
503
+ over=("d_invest",))
504
+ m.add_cstr(
505
+ f"{cstr_name}_n",
506
+ over = outer,
507
+ sense = sense,
508
+ lhs_terms = {"invest_total": inv_sum},
509
+ rhs_terms = {"cap": cap_n},
510
+ )
511
+ else:
512
+ m.add_cstr(
513
+ f"{cstr_name}_n",
514
+ over = e_n,
515
+ sense = sense,
516
+ lhs_terms = {"divest_total":
517
+ Sum(Where(v_n * us_n, e_n), over=("d",))},
518
+ rhs_terms = {"cap": cap_n},
519
+ )
520
+
521
+
522
+ # ---------------------------------------------------------------------------
523
+ # Cumulative capacity
524
+
525
+ def _emit_cumulative_capacity(m, d, vars: dict, sense: str) -> None:
526
+ """``maxCumulative_capacity`` / ``minCumulative_capacity``.
527
+
528
+ + p_entity_all_existing[e, d]
529
+ + Σ_{d_invest : (e,d_invest,d) in edd_invest} v_invest * unitsize
530
+ - (if (e, d) in ed_divest then v_divest * unitsize)
531
+ <= ed_cumulative_max_capacity[e, d] (sense='<=')
532
+ >= ed_cumulative_min_capacity[e, d] (sense='>=')
533
+ """
534
+ idx = getattr(d, "ed_invest_cumulative", None)
535
+ if idx is None or idx.height == 0:
536
+ return
537
+ cap_field = ("ed_cumulative_max_capacity" if sense == "<="
538
+ else "ed_cumulative_min_capacity")
539
+ cap_param = getattr(d, cap_field, None)
540
+ if cap_param is None:
541
+ return
542
+ name = "maxCumulative_capacity" if sense == "<=" else "minCumulative_capacity"
543
+ existing_param = getattr(d, "p_entity_all_existing", None)
544
+ edd_set = getattr(d, "edd_invest_set", None)
545
+ if edd_set is None:
546
+ return
547
+
548
+ v_inv_p = vars.get("v_invest_p")
549
+ v_inv_n = vars.get("v_invest_n")
550
+ v_div_p = vars.get("v_divest_p")
551
+ v_div_n = vars.get("v_divest_n")
552
+
553
+ # Process side
554
+ if v_inv_p is not None and d.p_unitsize is not None:
555
+ # ``idx``'s ``e`` carries the entity-union Enum (entities cover
556
+ # processes + nodes); ``v_inv_p.frame["p"]`` carries the
557
+ # process-only Enum. Cast the renamed ``p`` column to the
558
+ # narrower process Enum so the ``is_in`` membership test
559
+ # composes against a matching dtype. Per the entity-union axis
560
+ # convention: cast at the boundary, never let polars sort it
561
+ # out via List(Enum).
562
+ idx_p = idx.rename({"e": "p"})
563
+ _p_dtype = v_inv_p.frame.schema["p"]
564
+ if idx_p.schema["p"] != _p_dtype:
565
+ idx_p = idx_p.with_columns(pl.col("p").cast(_p_dtype, strict=False))
566
+ idx_p = idx_p.filter(
567
+ pl.col("p").is_in(v_inv_p.frame["p"].unique()))
568
+ if idx_p.height > 0:
569
+ edd_p = edd_set.rename({"e": "p"})
570
+ # Reconcile the renamed entity-union ``p`` (from ``edd_set``)
571
+ # against ``idx_p``'s narrower process-only ``p`` Enum.
572
+ from ._axis_enums import align_join_dtypes
573
+ edd_p, idx_p_aligned = align_join_dtypes(
574
+ edd_p, idx_p, ("p",),
575
+ )
576
+ edd_p = edd_p.join(idx_p_aligned, on=["p", "d"], how="inner")
577
+ v_inv_at = Var(
578
+ name=v_inv_p.name + "__cumcap_at",
579
+ dims=("p", "d_invest"),
580
+ frame=v_inv_p.frame.rename({"d": "d_invest"}),
581
+ lower=v_inv_p.lower, upper=v_inv_p.upper,
582
+ )
583
+ invest_term = Sum(Where(v_inv_at * d.p_unitsize, edd_p),
584
+ over=("d_invest",))
585
+ lhs: dict = {"invest_cum": invest_term}
586
+ if v_div_p is not None:
587
+ # Only subtract divest where (p, d) ∈ ed_divest_set.
588
+ # Filter to v_divest_p's own frame which is exactly
589
+ # pd_divest_set.
590
+ lhs["divest"] = -Where(v_div_p * d.p_unitsize, idx_p)
591
+ cap_p = Param(("p", "d"), cap_param.frame.rename({"e": "p"}))
592
+ rhs: dict = {"cap": cap_p}
593
+ if existing_param is not None:
594
+ exist_p = Param(("p", "d"),
595
+ existing_param.frame.rename({"e": "p"}))
596
+ # Move existing to the RHS as -existing.
597
+ rhs["minus_existing"] = -exist_p
598
+ m.add_cstr(
599
+ f"{name}_p",
600
+ over = idx_p,
601
+ sense = sense,
602
+ lhs_terms = lhs,
603
+ rhs_terms = rhs,
604
+ )
605
+
606
+ # Node side
607
+ if v_inv_n is not None and d.p_state_unitsize is not None:
608
+ # Mirror of the process-side cast. ``idx``'s ``e`` is the
609
+ # entity-union Enum; ``v_inv_n.frame["n"]`` is the node-only
610
+ # Enum. Cast at the boundary before ``is_in``.
611
+ idx_n = idx.rename({"e": "n"})
612
+ _n_dtype = v_inv_n.frame.schema["n"]
613
+ if idx_n.schema["n"] != _n_dtype:
614
+ idx_n = idx_n.with_columns(pl.col("n").cast(_n_dtype, strict=False))
615
+ idx_n = idx_n.filter(
616
+ pl.col("n").is_in(v_inv_n.frame["n"].unique()))
617
+ if idx_n.height > 0:
618
+ us_n = Param(("n",), d.p_state_unitsize.frame)
619
+ edd_n = edd_set.rename({"e": "n"})
620
+ from ._axis_enums import align_join_dtypes
621
+ edd_n, idx_n_aligned = align_join_dtypes(
622
+ edd_n, idx_n, ("n",),
623
+ )
624
+ edd_n = edd_n.join(idx_n_aligned, on=["n", "d"], how="inner")
625
+ v_inv_at = Var(
626
+ name=v_inv_n.name + "__cumcap_at",
627
+ dims=("n", "d_invest"),
628
+ frame=v_inv_n.frame.rename({"d": "d_invest"}),
629
+ lower=v_inv_n.lower, upper=v_inv_n.upper,
630
+ )
631
+ invest_term = Sum(Where(v_inv_at * us_n, edd_n),
632
+ over=("d_invest",))
633
+ lhs: dict = {"invest_cum": invest_term}
634
+ if v_div_n is not None:
635
+ lhs["divest"] = -Where(v_div_n * us_n, idx_n)
636
+ cap_n = Param(("n", "d"), cap_param.frame.rename({"e": "n"}))
637
+ rhs: dict = {"cap": cap_n}
638
+ if existing_param is not None:
639
+ exist_n = Param(("n", "d"),
640
+ existing_param.frame.rename({"e": "n"}))
641
+ rhs["minus_existing"] = -exist_n
642
+ m.add_cstr(
643
+ f"{name}_n",
644
+ over = idx_n,
645
+ sense = sense,
646
+ lhs_terms = lhs,
647
+ rhs_terms = rhs,
648
+ )
649
+
650
+
651
+ # ---------------------------------------------------------------------------
652
+ # Group invest / divest — period scope
653
+
654
+ def _emit_group_invest_period(m, d, vars: dict,
655
+ kind: str, sense: str) -> None:
656
+ """Σ_{(g,e) in group_entity : (e,d) in ed_{kind}}
657
+ v_{kind}[e, d] * unitsize <sense> pdGroup[g, ?, d]"""
658
+ if kind == "invest":
659
+ gd_idx = getattr(d, "gd_invest_period", None)
660
+ cap_field = ("p_group_invest_max_period" if sense == "<="
661
+ else "p_group_invest_min_period")
662
+ v_p = vars.get("v_invest_p")
663
+ v_n = vars.get("v_invest_n")
664
+ else:
665
+ gd_idx = getattr(d, "gd_divest_period", None)
666
+ cap_field = ("p_group_retire_max_period" if sense == "<="
667
+ else "p_group_retire_min_period")
668
+ v_p = vars.get("v_divest_p")
669
+ v_n = vars.get("v_divest_n")
670
+ cap_param = getattr(d, cap_field, None)
671
+ group_entity = getattr(d, "group_entity", None)
672
+ if (gd_idx is None or gd_idx.height == 0
673
+ or cap_param is None
674
+ or group_entity is None or group_entity.height == 0):
675
+ return
676
+ pre = "max" if sense == "<=" else "min"
677
+ name = f"{pre}{kind.capitalize()}Group_entity_period"
678
+
679
+ # Process branch
680
+ if v_p is not None and d.p_unitsize is not None:
681
+ ge_p = group_entity.rename({"e": "p"}).filter(
682
+ pl.col("p").is_in(v_p.frame["p"].unique()))
683
+ if ge_p.height > 0:
684
+ # join group_entity (g, p) with v_p frame (p, d), filter to
685
+ # gd_idx (g, d)
686
+ joined = (ge_p.join(v_p.frame.select("p", "d"), on="p", how="inner")
687
+ .join(gd_idx, on=["g", "d"], how="inner"))
688
+ if joined.height > 0:
689
+ lhs = Sum(
690
+ Where(v_p * d.p_unitsize, joined.select("g", "p", "d")),
691
+ over=("p",),
692
+ )
693
+ m.add_cstr(
694
+ f"{name}_p",
695
+ over = gd_idx,
696
+ sense = sense,
697
+ lhs_terms = {kind: lhs},
698
+ rhs_terms = {"cap": cap_param},
699
+ )
700
+ # Node branch
701
+ if v_n is not None and d.p_state_unitsize is not None:
702
+ us_n = Param(("n",), d.p_state_unitsize.frame)
703
+ ge_n = group_entity.rename({"e": "n"}).filter(
704
+ pl.col("n").is_in(v_n.frame["n"].unique()))
705
+ if ge_n.height > 0:
706
+ joined = (ge_n.join(v_n.frame.select("n", "d"), on="n", how="inner")
707
+ .join(gd_idx, on=["g", "d"], how="inner"))
708
+ if joined.height > 0:
709
+ lhs = Sum(
710
+ Where(v_n * us_n, joined.select("g", "n", "d")),
711
+ over=("n",),
712
+ )
713
+ m.add_cstr(
714
+ f"{name}_n",
715
+ over = gd_idx,
716
+ sense = sense,
717
+ lhs_terms = {kind: lhs},
718
+ rhs_terms = {"cap": cap_param},
719
+ )
720
+
721
+
722
+ # ---------------------------------------------------------------------------
723
+ # Group invest / divest — total scope
724
+
725
+ def _emit_group_invest_total(m, d, vars: dict,
726
+ kind: str, sense: str) -> None:
727
+ """Mod:
728
+
729
+ maxInvestGroup_entity_total {g in g_invest_total, d in period_invest} :
730
+ + Σ_{(g,e), d_inv : (e,d_inv,d) in edd_invest}
731
+ v_invest[e, d_inv] * unitsize
732
+ + Σ_{(g,e)} p_entity_previously_invested_capacity[e, d] (deferred)
733
+ <= p_group[g, 'invest_max_total'] (scalar over g)
734
+
735
+ For divest the index is just g (no per-d outer) and LHS sums over d.
736
+ """
737
+ group_entity = getattr(d, "group_entity", None)
738
+ if group_entity is None or group_entity.height == 0:
739
+ return
740
+ if kind == "invest":
741
+ g_set = getattr(d, "g_invest_total", None)
742
+ cap_field = ("p_group_invest_max_total" if sense == "<="
743
+ else "p_group_invest_min_total")
744
+ edd_set = getattr(d, "edd_invest_set", None)
745
+ v_p = vars.get("v_invest_p")
746
+ v_n = vars.get("v_invest_n")
747
+ else:
748
+ g_set = getattr(d, "g_divest_total", None)
749
+ cap_field = ("p_group_retire_max_total" if sense == "<="
750
+ else "p_group_retire_min_total")
751
+ edd_set = None
752
+ v_p = vars.get("v_divest_p")
753
+ v_n = vars.get("v_divest_n")
754
+ cap_param = getattr(d, cap_field, None)
755
+ if g_set is None or g_set.height == 0 or cap_param is None:
756
+ return
757
+ pre = "max" if sense == "<=" else "min"
758
+ name = f"{pre}{kind.capitalize()}Group_entity_total"
759
+
760
+ if kind == "invest":
761
+ # outer = (g × d) — use periods where ANY (e, d_inv, d) ∈ edd_invest
762
+ if edd_set is None:
763
+ return
764
+ # outer (g, d): for each g, every d that appears in edd for some
765
+ # (g, e) in group_entity.
766
+ ge = group_entity.rename({"e": "_e"}).filter(
767
+ pl.col("g").is_in(g_set["g"].unique()))
768
+ # Build outer (g, d) frame from edd_set ∩ group_entity
769
+ edd_for_g = (ge.join(edd_set.rename({"e": "_e"}),
770
+ on="_e", how="inner")
771
+ .select("g", "d").unique())
772
+ if edd_for_g.height == 0:
773
+ return
774
+
775
+ # Process branch
776
+ if v_p is not None and d.p_unitsize is not None:
777
+ # Phase 4.8h: cross-Enum is_in (e-axis vs p-axis vocab); cast
778
+ # at the boundary, never let polars sort it out via List(Enum).
779
+ _p_dtype = v_p.frame.schema["p"]
780
+ ge_p = group_entity.rename({"e": "p"})
781
+ if ge_p.schema["p"] != _p_dtype:
782
+ ge_p = ge_p.with_columns(pl.col("p").cast(_p_dtype, strict=False))
783
+ ge_p = ge_p.filter(pl.col("p").is_in(v_p.frame["p"].unique()))
784
+ edd_p = edd_set.rename({"e": "p"})
785
+ if edd_p.schema["p"] != _p_dtype:
786
+ edd_p = edd_p.with_columns(pl.col("p").cast(_p_dtype, strict=False))
787
+ edd_p = edd_p.filter(pl.col("p").is_in(v_p.frame["p"].unique()))
788
+ joined = ge_p.join(edd_p, on="p", how="inner")
789
+ if joined.height > 0:
790
+ v_inv_at = Var(
791
+ name=v_p.name + "__grp_total_at",
792
+ dims=("p", "d_invest"),
793
+ frame=v_p.frame.rename({"d": "d_invest"}),
794
+ lower=v_p.lower, upper=v_p.upper,
795
+ )
796
+ inv_sum = Sum(
797
+ Where(v_inv_at * d.p_unitsize, joined),
798
+ over=("p", "d_invest"),
799
+ )
800
+ m.add_cstr(
801
+ f"{name}_p",
802
+ over = edd_for_g,
803
+ sense = sense,
804
+ lhs_terms = {"invest_grp": inv_sum},
805
+ rhs_terms = {"cap": cap_param},
806
+ )
807
+ # Node branch
808
+ if v_n is not None and d.p_state_unitsize is not None:
809
+ us_n = Param(("n",), d.p_state_unitsize.frame)
810
+ # Phase 4.8h: cross-Enum is_in (e-axis vs n-axis vocab); cast
811
+ # at the boundary, never let polars sort it out via List(Enum).
812
+ _n_dtype = v_n.frame.schema["n"]
813
+ ge_n = group_entity.rename({"e": "n"})
814
+ if ge_n.schema["n"] != _n_dtype:
815
+ ge_n = ge_n.with_columns(pl.col("n").cast(_n_dtype, strict=False))
816
+ ge_n = ge_n.filter(pl.col("n").is_in(v_n.frame["n"].unique()))
817
+ edd_n = edd_set.rename({"e": "n"})
818
+ if edd_n.schema["n"] != _n_dtype:
819
+ edd_n = edd_n.with_columns(pl.col("n").cast(_n_dtype, strict=False))
820
+ edd_n = edd_n.filter(pl.col("n").is_in(v_n.frame["n"].unique()))
821
+ joined = ge_n.join(edd_n, on="n", how="inner")
822
+ if joined.height > 0:
823
+ v_inv_at = Var(
824
+ name=v_n.name + "__grp_total_at",
825
+ dims=("n", "d_invest"),
826
+ frame=v_n.frame.rename({"d": "d_invest"}),
827
+ lower=v_n.lower, upper=v_n.upper,
828
+ )
829
+ inv_sum = Sum(
830
+ Where(v_inv_at * us_n, joined),
831
+ over=("n", "d_invest"),
832
+ )
833
+ m.add_cstr(
834
+ f"{name}_n",
835
+ over = edd_for_g,
836
+ sense = sense,
837
+ lhs_terms = {"invest_grp": inv_sum},
838
+ rhs_terms = {"cap": cap_param},
839
+ )
840
+ else:
841
+ # divest: outer = g, sum over (e, d) ∈ ed_divest where (g, e) ∈ group_entity
842
+ outer = g_set.select("g")
843
+ # Process branch
844
+ if v_p is not None and d.p_unitsize is not None:
845
+ # Phase 4.8h: cross-Enum is_in (e-axis vs p-axis vocab); cast
846
+ # at the boundary, never let polars sort it out via List(Enum).
847
+ _p_dtype = v_p.frame.schema["p"]
848
+ ge_p = group_entity.rename({"e": "p"})
849
+ if ge_p.schema["p"] != _p_dtype:
850
+ ge_p = ge_p.with_columns(pl.col("p").cast(_p_dtype, strict=False))
851
+ ge_p = ge_p.filter(
852
+ pl.col("p").is_in(v_p.frame["p"].unique())) \
853
+ .filter(pl.col("g").is_in(g_set["g"].unique()))
854
+ if ge_p.height > 0:
855
+ lhs = Sum(
856
+ Where(v_p * d.p_unitsize, ge_p),
857
+ over=("p", "d"),
858
+ )
859
+ m.add_cstr(
860
+ f"{name}_p",
861
+ over = outer,
862
+ sense = sense,
863
+ lhs_terms = {"divest_grp": lhs},
864
+ rhs_terms = {"cap": cap_param},
865
+ )
866
+ # Node branch
867
+ if v_n is not None and d.p_state_unitsize is not None:
868
+ us_n = Param(("n",), d.p_state_unitsize.frame)
869
+ # Phase 4.8h: cross-Enum is_in (e-axis vs n-axis vocab); cast
870
+ # at the boundary, never let polars sort it out via List(Enum).
871
+ _n_dtype = v_n.frame.schema["n"]
872
+ ge_n = group_entity.rename({"e": "n"})
873
+ if ge_n.schema["n"] != _n_dtype:
874
+ ge_n = ge_n.with_columns(pl.col("n").cast(_n_dtype, strict=False))
875
+ ge_n = ge_n.filter(
876
+ pl.col("n").is_in(v_n.frame["n"].unique())) \
877
+ .filter(pl.col("g").is_in(g_set["g"].unique()))
878
+ if ge_n.height > 0:
879
+ lhs = Sum(
880
+ Where(v_n * us_n, ge_n),
881
+ over=("n", "d"),
882
+ )
883
+ m.add_cstr(
884
+ f"{name}_n",
885
+ over = outer,
886
+ sense = sense,
887
+ lhs_terms = {"divest_grp": lhs},
888
+ rhs_terms = {"cap": cap_param},
889
+ )
890
+
891
+
892
+ # ---------------------------------------------------------------------------
893
+ # Group invest cumulative
894
+
895
+ def _emit_group_invest_cumulative(m, d, vars: dict, sense: str) -> None:
896
+ """maxInvestGroup_entity_cumulative / min variant.
897
+
898
+ LHS:
899
+ + Σ_{(g,e), d_inv : (e,d_inv,d) in edd_invest} v_invest * unitsize
900
+ + Σ_{(g,e)} p_entity_previously_invested_capacity[e, d] (deferred)
901
+ + Σ_{(g,e)} p_entity_all_existing[e, d]
902
+ [- divest terms for the min variant — deferred per audit note]
903
+ RHS:
904
+ + p_group[g, 'invest_max_cumulative'] (or min_cumulative)
905
+ """
906
+ group_entity = getattr(d, "group_entity", None)
907
+ g_set = getattr(d, "g_invest_cumulative", None)
908
+ edd_set = getattr(d, "edd_invest_set", None)
909
+ if (group_entity is None or group_entity.height == 0
910
+ or g_set is None or g_set.height == 0
911
+ or edd_set is None):
912
+ return
913
+ cap_field = ("p_group_invest_max_cumulative" if sense == "<="
914
+ else "p_group_invest_min_cumulative")
915
+ cap_param = getattr(d, cap_field, None)
916
+ if cap_param is None:
917
+ return
918
+ pre = "max" if sense == "<=" else "min"
919
+ name = f"{pre}InvestGroup_entity_cumulative"
920
+
921
+ # Build outer (g, d): every (g, d) where the group has a member with
922
+ # an edd_invest row at that d.
923
+ ge = group_entity.rename({"e": "_e"}).filter(
924
+ pl.col("g").is_in(g_set["g"].unique()))
925
+ outer = (ge.join(edd_set.rename({"e": "_e"}), on="_e", how="inner")
926
+ .select("g", "d").unique())
927
+ if outer.height == 0:
928
+ return
929
+
930
+ # LHS pieces: invest sum (process + node) and existing sum.
931
+ v_inv_p = vars.get("v_invest_p")
932
+ v_inv_n = vars.get("v_invest_n")
933
+ existing = getattr(d, "p_entity_all_existing", None)
934
+
935
+ lhs_terms: dict = {}
936
+ if v_inv_p is not None and d.p_unitsize is not None:
937
+ # Phase 4.8h: cross-Enum is_in (e-axis vs p-axis vocab); cast
938
+ # at the boundary, never let polars sort it out via List(Enum).
939
+ _p_dtype = v_inv_p.frame.schema["p"]
940
+ ge_p = group_entity.rename({"e": "p"})
941
+ if ge_p.schema["p"] != _p_dtype:
942
+ ge_p = ge_p.with_columns(pl.col("p").cast(_p_dtype, strict=False))
943
+ ge_p = ge_p.filter(
944
+ pl.col("p").is_in(v_inv_p.frame["p"].unique())).filter(
945
+ pl.col("g").is_in(g_set["g"].unique()))
946
+ edd_p = edd_set.rename({"e": "p"})
947
+ if edd_p.schema["p"] != _p_dtype:
948
+ edd_p = edd_p.with_columns(pl.col("p").cast(_p_dtype, strict=False))
949
+ edd_p = edd_p.filter(
950
+ pl.col("p").is_in(v_inv_p.frame["p"].unique()))
951
+ joined = ge_p.join(edd_p, on="p", how="inner")
952
+ if joined.height > 0:
953
+ v_inv_at = Var(
954
+ name=v_inv_p.name + "__grp_cum_at",
955
+ dims=("p", "d_invest"),
956
+ frame=v_inv_p.frame.rename({"d": "d_invest"}),
957
+ lower=v_inv_p.lower, upper=v_inv_p.upper,
958
+ )
959
+ lhs_terms["invest_p"] = Sum(
960
+ Where(v_inv_at * d.p_unitsize, joined),
961
+ over=("p", "d_invest"),
962
+ )
963
+ if v_inv_n is not None and d.p_state_unitsize is not None:
964
+ us_n = Param(("n",), d.p_state_unitsize.frame)
965
+ # Phase 4.8h: cross-Enum is_in (e-axis vs n-axis vocab); cast
966
+ # at the boundary, never let polars sort it out via List(Enum).
967
+ _n_dtype = v_inv_n.frame.schema["n"]
968
+ ge_n = group_entity.rename({"e": "n"})
969
+ if ge_n.schema["n"] != _n_dtype:
970
+ ge_n = ge_n.with_columns(pl.col("n").cast(_n_dtype, strict=False))
971
+ ge_n = ge_n.filter(
972
+ pl.col("n").is_in(v_inv_n.frame["n"].unique())).filter(
973
+ pl.col("g").is_in(g_set["g"].unique()))
974
+ edd_n = edd_set.rename({"e": "n"})
975
+ if edd_n.schema["n"] != _n_dtype:
976
+ edd_n = edd_n.with_columns(pl.col("n").cast(_n_dtype, strict=False))
977
+ edd_n = edd_n.filter(
978
+ pl.col("n").is_in(v_inv_n.frame["n"].unique()))
979
+ joined = ge_n.join(edd_n, on="n", how="inner")
980
+ if joined.height > 0:
981
+ v_inv_at = Var(
982
+ name=v_inv_n.name + "__grp_cum_at",
983
+ dims=("n", "d_invest"),
984
+ frame=v_inv_n.frame.rename({"d": "d_invest"}),
985
+ lower=v_inv_n.lower, upper=v_inv_n.upper,
986
+ )
987
+ lhs_terms["invest_n"] = Sum(
988
+ Where(v_inv_at * us_n, joined),
989
+ over=("n", "d_invest"),
990
+ )
991
+
992
+ rhs_terms: dict = {"cap": cap_param}
993
+ # existing(g, d) is a known constant — push to RHS with sign flip.
994
+ if existing is not None:
995
+ # existing has dims (e, d); join to group_entity, sum over e
996
+ ex_frame = (existing.frame
997
+ .join(group_entity, on="e", how="inner")
998
+ .group_by(["g", "d"]).agg(pl.col("value").sum())
999
+ .filter(pl.col("g").is_in(g_set["g"].unique())))
1000
+ if ex_frame.height > 0:
1001
+ ex_param = Param(("g", "d"), ex_frame.select("g", "d", "value"))
1002
+ rhs_terms["minus_existing"] = -ex_param
1003
+
1004
+ if lhs_terms:
1005
+ m.add_cstr(
1006
+ name,
1007
+ over = outer,
1008
+ sense = sense,
1009
+ lhs_terms = lhs_terms,
1010
+ rhs_terms = rhs_terms,
1011
+ )
1012
+
1013
+
1014
+ # ---------------------------------------------------------------------------
1015
+ # Group cumulative-flow (whole solve)
1016
+
1017
+ def _flow_lhs(d, vars):
1018
+ """Build the (sink + eff_source + noEff_source) LHS for cumulative
1019
+ / instant flow constraints, returning a single Expr or None.
1020
+
1021
+ Open dims: (g, d, t) when the v_flow Var has dims (p, source, sink,
1022
+ d, t). Caller multiplies by step_duration & sums over (d, t) for
1023
+ cumulative variants, or just binds to the (g, d, t) frame for
1024
+ instant.
1025
+ """
1026
+ parts = []
1027
+ for fn in (_sink_lhs_term, _eff_lhs_term, _noEff_lhs_term):
1028
+ term = fn(d, vars)
1029
+ if term is not None:
1030
+ parts.append(term)
1031
+ if not parts:
1032
+ return None
1033
+ out = parts[0]
1034
+ for p in parts[1:]:
1035
+ out = out + p
1036
+ return out
1037
+
1038
+
1039
+ def _emit_cumulative_flow_solve(m, d, vars: dict, sense: str) -> None:
1040
+ """Single-row constraint per group g in g_max_cumulative_flow_solve
1041
+ (resp. g_min_cumulative_flow_solve). LHS is integrated over the
1042
+ whole horizon (Σ_{(d,t)} step_duration · flow_term)."""
1043
+ cap_field = ("p_group_max_cumulative_flow" if sense == "<="
1044
+ else "p_group_min_cumulative_flow")
1045
+ cap_param = getattr(d, cap_field, None)
1046
+ if cap_param is None or cap_param.frame.height == 0:
1047
+ return
1048
+ flow_lhs = _flow_lhs(d, vars)
1049
+ if flow_lhs is None:
1050
+ return
1051
+ pre = "max" if sense == "<=" else "min"
1052
+ name = f"{pre}Cumulative_flow_solve"
1053
+
1054
+ # Outer index: g only.
1055
+ outer = cap_param.frame.select("g").unique()
1056
+ # LHS: Σ_{(d,t)} flow_lhs * step_duration → collapses (d, t)
1057
+ # RHS: p_group[g, ?] * hours_in_solve.
1058
+ # hours_in_solve = Σ_{(d,t)} step_duration[d, t] — a scalar but
1059
+ # easier to compute from the frame here.
1060
+ hours_in_solve = float(d.p_step_duration.frame["value"].sum())
1061
+ # Cap_param has dims (g,); multiply by hours_in_solve scalar.
1062
+ rhs_param = Param(("g",), cap_param.frame.with_columns(
1063
+ value=pl.col("value") * hours_in_solve))
1064
+ lhs_int = Sum(flow_lhs * d.p_step_duration, over=("d", "t"))
1065
+ m.add_cstr(
1066
+ name,
1067
+ over = outer,
1068
+ sense = sense,
1069
+ lhs_terms = {"flow_int": lhs_int},
1070
+ rhs_terms = {"cap": rhs_param},
1071
+ )
1072
+
1073
+
1074
+ def _emit_cumulative_flow_period(m, d, vars: dict, sense: str) -> None:
1075
+ cap_field = ("pd_max_cumulative_flow" if sense == "<="
1076
+ else "pd_min_cumulative_flow")
1077
+ cap_param = getattr(d, cap_field, None)
1078
+ if cap_param is None or cap_param.frame.height == 0:
1079
+ return
1080
+ flow_lhs = _flow_lhs(d, vars)
1081
+ if flow_lhs is None:
1082
+ return
1083
+ pre = "max" if sense == "<=" else "min"
1084
+ name = f"{pre}Cumulative_flow_period"
1085
+
1086
+ # Outer index: (g, d) where pd_*_cumulative_flow is set.
1087
+ outer = cap_param.frame.select("g", "d").unique()
1088
+ # hours_in_period[d] = Σ_t step_duration[d, t]
1089
+ hip = (d.p_step_duration.frame
1090
+ .group_by("d").agg(pl.col("value").sum())
1091
+ .rename({"value": "hours_in_period"}))
1092
+ rhs_frame = (cap_param.frame.join(hip, on="d", how="inner")
1093
+ .with_columns(value=pl.col("value")
1094
+ * pl.col("hours_in_period"))
1095
+ .select("g", "d", "value"))
1096
+ rhs_param = Param(("g", "d"), rhs_frame)
1097
+ # LHS: integrate over t only — Σ_t flow_lhs * step_duration.
1098
+ # Sum collapses 't' but keeps 'd'.
1099
+ lhs_int = Sum(flow_lhs * d.p_step_duration, over=("t",))
1100
+ m.add_cstr(
1101
+ name,
1102
+ over = outer,
1103
+ sense = sense,
1104
+ lhs_terms = {"flow_int": lhs_int},
1105
+ rhs_terms = {"cap": rhs_param},
1106
+ )
1107
+
1108
+
1109
+ def _instant_flow_support(cap_frame: pl.DataFrame,
1110
+ dt: pl.DataFrame) -> pl.DataFrame:
1111
+ """Broadcast a resolved instant-flow cap to its full ``(g, d, t)``
1112
+ constraint support against the active ``(d, t)`` grid.
1113
+
1114
+ The cap is produced by ``pdt_*_instant_flow_from_source`` via
1115
+ ``resolve_param_shape`` / ``broadcast_to_period_time``, so it is
1116
+ keyed by whatever axes were authored — scalar → ``(g,)``, period map
1117
+ → ``(g, d)``, time map → ``(g, t)``, period+time → ``(g, d, t)``.
1118
+ The missing axes are filled from ``dt`` (the active ``(d, t)`` grid)
1119
+ so the obligation binds at every active timestep.
1120
+
1121
+ Deriving the support from the cap keeps it in lock-step with the RHS
1122
+ and replaces the old separate raw-source projection, which detected
1123
+ the axis by column name — silently dropping Spine's silent-default
1124
+ ``"x"`` index_name and constant maps, and crashing on pure period
1125
+ maps (no ``t`` column to select).
1126
+ """
1127
+ has_d = "d" in cap_frame.columns
1128
+ has_t = "t" in cap_frame.columns
1129
+ if has_d and has_t: # MAP_PERIOD_TIME
1130
+ over = cap_frame.select("g", "d", "t")
1131
+ elif has_d: # MAP_PERIOD
1132
+ over = (cap_frame.select("g", "d").unique()
1133
+ .join(dt, on="d", how="inner"))
1134
+ elif has_t: # MAP_TIME
1135
+ over = (cap_frame.select("g", "t").unique()
1136
+ .join(dt, on="t", how="inner"))
1137
+ else: # SCALAR — broadcast over grid
1138
+ over = cap_frame.select("g").unique().join(dt, how="cross")
1139
+ return over.select("g", "d", "t").unique()
1140
+
1141
+
1142
+ def _emit_instant_flow(m, d, vars: dict, sense: str) -> None:
1143
+ cap_field = ("pdt_max_instant_flow" if sense == "<="
1144
+ else "pdt_min_instant_flow")
1145
+ cap_param = getattr(d, cap_field, None)
1146
+ if cap_param is None or cap_param.frame.height == 0:
1147
+ return
1148
+ flow_lhs = _flow_lhs(d, vars)
1149
+ if flow_lhs is None:
1150
+ return
1151
+
1152
+ dt = d.p_step_duration.frame.select("d", "t").unique()
1153
+ over = _instant_flow_support(cap_param.frame, dt)
1154
+ if over.height == 0:
1155
+ return
1156
+
1157
+ pre = "max" if sense == "<=" else "min"
1158
+ name = f"{pre}Instant_flow"
1159
+ m.add_cstr(
1160
+ name,
1161
+ over = over,
1162
+ sense = sense,
1163
+ lhs_terms = {"flow": flow_lhs},
1164
+ rhs_terms = {"cap": cap_param},
1165
+ )