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,660 @@
1
+ """Commodity price ladder — tier-based stepwise pricing.
2
+
3
+ Mirrors the .mod's ``commodity_with_ladder`` family of constraints
4
+ (flextool.mod:3641-3745) and the per-tier objective term
5
+ (flextool.mod:2017-2028).
6
+
7
+ Concept
8
+ -------
9
+ For commodities listed in ``commodity_with_ladder`` (price_method =
10
+ ``price_ladder_annual`` / ``price_ladder_cumulative``), the legacy
11
+ single-price commodity term is replaced by:
12
+
13
+ * ``v_trade[c, n, d, i] >= 0``: per-tier MWh purchased.
14
+ * **Balance**: Σ_i v_trade[c,n,d,i] · unitsize == period-aggregate
15
+ commodity flow through (c, n) (the same buy − sell expression that
16
+ the legacy ``pdtCommodity`` price term applies).
17
+ * **Per-tier cap** (annual):
18
+ Σ_n v_trade[c,n,d,i] · unitsize <= p_ladder_ann_quantity[c,i,d]
19
+ · f_d_k[d] − p_ladder_cum_realized_mwh[c,i,d]
20
+ (only when quantity < 1e29 sentinel).
21
+ * **Per-tier cap** (cumulative):
22
+ Σ_{n,d} v_trade[c,n,d,i] · unitsize
23
+ <= p_ladder_cum_quantity[c,i] · Σ_d f_d_k[d]
24
+ − Σ_d p_ladder_cum_realized_mwh[c,i,d]
25
+ (one constraint per (c, i) ∈ ci_ladder_cumulative).
26
+ * **Objective term**:
27
+ + Σ price[c,i] · v_trade[c,n,d,i] · unitsize
28
+ · inflation_op[d] / period_share[d]
29
+
30
+ For single-solve fixtures, ``f_d_k[d] = 1.0`` for every realized
31
+ period and ``p_ladder_cum_realized_mwh = 0``, so the caps reduce to
32
+ their pre-refactor form:
33
+
34
+ annual_single: Σ_n v_trade · unitsize <= quantity[c,i,d]
35
+ cumulative_single: Σ_{n,d} v_trade · unitsize <= quantity[c,i] · |period_in_use|
36
+
37
+ The infinite-tier sentinel (1e30, written by flextool's input writer
38
+ for ``+Inf`` quantity) is filtered out — those tiers carry no cap,
39
+ matching the .mod's ``< 1e29`` predicate.
40
+
41
+ Inputs
42
+ ------
43
+ * ``input/commodity_ladder_annual.csv`` (commodity, period, tier, price, quantity)
44
+ * ``input/commodity_ladder_cumulative.csv`` (commodity, tier, price, quantity)
45
+ * ``input/p_commodity_unitsize.csv`` (commodity, p_commodity_unitsize)
46
+ * ``solve_data/commodity_with_ladder.csv`` (commodity)
47
+ * ``solve_data/commodity_with_ladder_annual.csv`` (commodity)
48
+ * ``solve_data/commodity_with_ladder_cumulative.csv`` (commodity)
49
+ * ``solve_data/cnd_ladder_set.csv`` (commodity, node, period)
50
+ * ``solve_data/cndi_ladder_set.csv`` (commodity, node, period, tier)
51
+ * ``solve_data/cndi_ladder_ann_set.csv`` (commodity, node, period, tier)
52
+ * ``solve_data/cndi_ladder_cum_set.csv`` (commodity, node, period, tier)
53
+ * ``solve_data/ci_ladder_cumulative.csv`` (commodity, tier)
54
+ * ``solve_data/commodity__tier_ann.csv`` (commodity, tier)
55
+ * ``solve_data/f_d_k.csv`` (period, value)
56
+ * ``solve_data/ladder_cum_realized_mwh.csv`` (commodity, tier, period, p_ladder_cum_realized_mwh)
57
+ """
58
+ from __future__ import annotations
59
+
60
+ from pathlib import Path
61
+ from typing import TYPE_CHECKING
62
+
63
+ import polars as pl
64
+
65
+ from polar_high import Param, Sum, Where
66
+
67
+
68
+ if TYPE_CHECKING:
69
+ pass
70
+
71
+
72
+ # .mod uses 1e30 as +Infinity sentinel; the constraint filter is
73
+ # ``quantity < 1e29``. Re-using the same threshold keeps fixtures and
74
+ # flextool source-of-truth aligned bit-for-bit.
75
+ _INF_SENTINEL = 1e29
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Feature detection
80
+
81
+ def has_feature(d) -> bool:
82
+ """True iff at least one commodity uses a ladder price method."""
83
+ cwl = getattr(d, "commodity_with_ladder", None)
84
+ return cwl is not None and cwl.height > 0
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Data loading
89
+
90
+ def _read_single_col(
91
+ path: Path,
92
+ col_in: str,
93
+ col_out: str,
94
+ *,
95
+ provider: "object | None" = None,
96
+ provider_key: str | None = None,
97
+ ) -> pl.DataFrame | None:
98
+ df = _provider_get(provider, provider_key, path)
99
+ if df is None or df.height == 0:
100
+ return None
101
+ if col_in in df.columns and col_in != col_out:
102
+ df = df.rename({col_in: col_out})
103
+ return df.select(col_out)
104
+
105
+
106
+ def _read_long_csv(
107
+ path: Path,
108
+ rename: dict[str, str],
109
+ *,
110
+ provider: "object | None" = None,
111
+ provider_key: str | None = None,
112
+ ) -> pl.DataFrame | None:
113
+ df = _provider_get(provider, provider_key, path)
114
+ if df is None or df.height == 0:
115
+ return None
116
+ cols_present = {c: r for c, r in rename.items() if c in df.columns}
117
+ if cols_present:
118
+ df = df.rename(cols_present)
119
+ return df
120
+
121
+
122
+ def _provider_get(
123
+ provider: "object | None",
124
+ key: str | None,
125
+ path: Path, # noqa: ARG001 — kept for API; disk arm removed in Step 2.5
126
+ ) -> "pl.DataFrame | None":
127
+ """Resolve *key* via the Provider. Returns ``None`` when the
128
+ Provider doesn't carry *key* or the frame is empty. The
129
+ disk-fallback arm was removed in Step 2.5 — cascade modules
130
+ consume frames exclusively from the Provider.
131
+ """
132
+ if provider is None or key is None or not provider.has(key):
133
+ return None
134
+ df = provider.get(key)
135
+ return df if df is not None and df.height > 0 else None
136
+
137
+
138
+ def load_data(
139
+ inp_dir: str | Path,
140
+ sd_dir: str | Path,
141
+ *,
142
+ provider: "object | None" = None,
143
+ ) -> dict:
144
+ """Load ladder frames via the Provider (disk-fallback for
145
+ off-cascade fixture loaders).
146
+
147
+ Returns a dict with keys matching ``FlexData`` field names. Values
148
+ are ``None`` (or empty frames) when the feature is inactive (every
149
+ fixture without ``price_method = price_ladder_*`` has header-only
150
+ frames, which return None here).
151
+
152
+ Step 2.5-F Phases D + E
153
+ -----------------------
154
+
155
+ ``input/commodity_ladder_cumulative`` and
156
+ ``input/commodity_ladder_annual`` are produced by
157
+ :func:`flextool.input_derivation._commodity_ladder.derive_commodity_ladder_cumulative`
158
+ / ``derive_commodity_ladder_annual`` and placed on the
159
+ cascade-input Provider; in-cascade the Provider is the
160
+ authoritative source. The ``solve_data/commodity_with_ladder*``
161
+ frames are produced by
162
+ :mod:`flextool.input_derivation._commodity_ladder_sets`.
163
+ """
164
+ inp = Path(inp_dir)
165
+ sd = Path(sd_dir)
166
+
167
+ blank = dict(
168
+ commodity_with_ladder=None,
169
+ commodity_with_ladder_annual=None,
170
+ commodity_with_ladder_cumulative=None,
171
+ cnd_ladder=None,
172
+ cndi_ladder=None,
173
+ cndi_ladder_ann=None,
174
+ cndi_ladder_cum=None,
175
+ ci_ladder_cumulative=None,
176
+ commodity__tier_ann=None,
177
+ commodity__tier_cum=None,
178
+ p_ladder_ann_price=None,
179
+ p_ladder_ann_quantity=None,
180
+ p_ladder_cum_price=None,
181
+ p_ladder_cum_quantity=None,
182
+ p_commodity_unitsize=None,
183
+ p_f_d_k=None,
184
+ p_ladder_cum_realized_mwh=None,
185
+ )
186
+
187
+ # Provider key layout for ladder per-solve frames:
188
+ # * ``solve_data/commodity_with_ladder*`` — emitted by
189
+ # :func:`flextool.input_derivation._commodity_ladder_sets.derive_commodity_ladder_sets`
190
+ # into the cascade-input Provider under the parent-qualified key.
191
+ # * ``solve_data/cnd_ladder_set.csv`` & ``solve_data/cndi_ladder_*_set.csv``
192
+ # — emitted by :func:`_emit_per_solve.emit_per_solve_sets` under both
193
+ # the bare ``basename.csv`` and ``solve_data/basename.csv`` keys.
194
+ # * ``solve_data/ci_ladder_cumulative.csv`` — emitted by
195
+ # :func:`_emit_mid_sets.emit_ci_ladder_cumulative`.
196
+ # * ``solve_data/commodity__tier_ann.csv`` — emitted by
197
+ # :func:`_emit_leaf_sets`.
198
+ # In cascade mode (``csv_dump=False``) these files never reach disk; the
199
+ # Provider is the authoritative source. The disk arm survives for
200
+ # off-cascade fixture loaders that seed the workdir without a Provider.
201
+ cwl = _read_single_col(
202
+ sd / "commodity_with_ladder.csv", "commodity", "c",
203
+ provider=provider, provider_key="solve_data/commodity_with_ladder",
204
+ )
205
+ if cwl is None:
206
+ return blank
207
+
208
+ cwla = _read_single_col(
209
+ sd / "commodity_with_ladder_annual.csv", "commodity", "c",
210
+ provider=provider,
211
+ provider_key="solve_data/commodity_with_ladder_annual",
212
+ )
213
+ cwlc = _read_single_col(
214
+ sd / "commodity_with_ladder_cumulative.csv", "commodity", "c",
215
+ provider=provider,
216
+ provider_key="solve_data/commodity_with_ladder_cumulative",
217
+ )
218
+
219
+ cnd = _read_long_csv(
220
+ sd / "cnd_ladder_set.csv",
221
+ {"commodity": "c", "node": "n", "period": "d"},
222
+ provider=provider, provider_key="solve_data/cnd_ladder_set.csv",
223
+ )
224
+ if cnd is not None:
225
+ cnd = cnd.select("c", "n", "d")
226
+
227
+ def _read_cndi(path: Path, key: str) -> pl.DataFrame | None:
228
+ df = _read_long_csv(
229
+ path,
230
+ {"commodity": "c", "node": "n", "period": "d", "tier": "i"},
231
+ provider=provider, provider_key=key,
232
+ )
233
+ if df is None:
234
+ return None
235
+ # Tier is integer-as-string in the CSV; keep it as string for
236
+ # consistent join keys (Param tables also keep it as Utf8).
237
+ return df.with_columns(pl.col("i").cast(pl.Utf8)).select("c", "n", "d", "i")
238
+
239
+ cndi = _read_cndi(
240
+ sd / "cndi_ladder_set.csv", "solve_data/cndi_ladder_set.csv")
241
+ cndi_ann = _read_cndi(
242
+ sd / "cndi_ladder_ann_set.csv", "solve_data/cndi_ladder_ann_set.csv")
243
+ cndi_cum = _read_cndi(
244
+ sd / "cndi_ladder_cum_set.csv", "solve_data/cndi_ladder_cum_set.csv")
245
+
246
+ ci_cum = _read_long_csv(
247
+ sd / "ci_ladder_cumulative.csv",
248
+ {"commodity": "c", "tier": "i"},
249
+ provider=provider, provider_key="solve_data/ci_ladder_cumulative.csv",
250
+ )
251
+ if ci_cum is not None:
252
+ ci_cum = ci_cum.with_columns(pl.col("i").cast(pl.Utf8)).select("c", "i")
253
+
254
+ ct_ann = _read_long_csv(
255
+ sd / "commodity__tier_ann.csv",
256
+ {"commodity": "c", "tier": "i"},
257
+ provider=provider, provider_key="solve_data/commodity__tier_ann.csv",
258
+ )
259
+ if ct_ann is not None:
260
+ ct_ann = ct_ann.with_columns(pl.col("i").cast(pl.Utf8)).select("c", "i")
261
+ # commodity__tier_cum mirrors the input CSV's (commodity, tier) projection.
262
+ ct_cum = None
263
+ cum_inp = _provider_get(
264
+ provider, "input/commodity_ladder_cumulative",
265
+ inp / "commodity_ladder_cumulative.csv",
266
+ )
267
+ if cum_inp is not None:
268
+ ct_cum = (cum_inp.rename({"commodity": "c", "tier": "i"})
269
+ .with_columns(pl.col("i").cast(pl.Utf8))
270
+ .select("c", "i").unique())
271
+
272
+ # ── Annual price/quantity Params (c, i, d) ─────────────────────────
273
+ p_ann_price = None
274
+ p_ann_quantity = None
275
+ ann = _provider_get(
276
+ provider, "input/commodity_ladder_annual",
277
+ inp / "commodity_ladder_annual.csv",
278
+ )
279
+ if ann is not None:
280
+ ann = (ann.rename({"commodity": "c", "tier": "i", "period": "d"})
281
+ .with_columns(pl.col("i").cast(pl.Utf8))
282
+ .select("c", "i", "d", "price", "quantity"))
283
+ # The CSV's "1e30" sentinel is written as a Float64 by polars.
284
+ p_ann_price = Param(
285
+ ("c", "i", "d"),
286
+ ann.select("c", "i", "d",
287
+ value=pl.col("price").cast(pl.Float64)),
288
+ )
289
+ p_ann_quantity = Param(
290
+ ("c", "i", "d"),
291
+ ann.select("c", "i", "d",
292
+ value=pl.col("quantity").cast(pl.Float64)),
293
+ )
294
+
295
+ # ── Cumulative price/quantity Params (c, i) ─────────────────────────
296
+ p_cum_price = None
297
+ p_cum_quantity = None
298
+ cum = _provider_get(
299
+ provider, "input/commodity_ladder_cumulative",
300
+ inp / "commodity_ladder_cumulative.csv",
301
+ )
302
+ if cum is not None:
303
+ cum = (cum.rename({"commodity": "c", "tier": "i"})
304
+ .with_columns(pl.col("i").cast(pl.Utf8))
305
+ .select("c", "i", "price", "quantity"))
306
+ p_cum_price = Param(
307
+ ("c", "i"),
308
+ cum.select("c", "i", value=pl.col("price").cast(pl.Float64)),
309
+ )
310
+ p_cum_quantity = Param(
311
+ ("c", "i"),
312
+ cum.select("c", "i",
313
+ value=pl.col("quantity").cast(pl.Float64)),
314
+ )
315
+
316
+ # Δ.12-drop: ``p_commodity_unitsize`` produced authoritatively by
317
+ # ``apply_direct_params.p_commodity_unitsize_from_source`` (Δ.4b).
318
+ # Seed dropped. The default-1.0 fallback in
319
+ # ``_commodity_unitsize_param`` covers the case when the override
320
+ # leaves the field None (unset feature).
321
+ p_unitsize = None
322
+
323
+ # ``p_f_d_k`` and ``p_ladder_cum_realized_mwh`` are produced by
324
+ # ``apply_derived_g`` BUT the 7 mismatch fixtures
325
+ # (``work_commodity_ladder_*`` etc.) skip auto-resolution and rely
326
+ # on the seed. Keep CSV reads.
327
+ # TODO(Δ.12c+): retire when ``_find_scenario`` covers underscore-
328
+ # variant fixtures or all fixtures explicitly pass db_reader=.
329
+ p_f_d_k = None
330
+ fdk = _provider_get(
331
+ provider, "solve_data/f_d_k.csv", sd / "f_d_k.csv",
332
+ )
333
+ if fdk is not None and fdk.height > 0:
334
+ # Provider-captured frame stores ``value`` as Utf8 (see
335
+ # ``_emit_period_calc._keyed_frame``); cast to Float64 to match
336
+ # the disk arm's ``pl.read_csv`` numeric inference.
337
+ fdk = (fdk.rename({"period": "d"})
338
+ .with_columns(pl.col("value").cast(pl.Float64))
339
+ .select("d", "value"))
340
+ p_f_d_k = Param(("d",), fdk)
341
+
342
+ # Phase 4.1a / 4.2-1g — the cross-roll cumulative-realized MWh
343
+ # accumulator rides on ``handoff/cumulative_commodity`` (written by
344
+ # the iteration-start translator from the prior solve's SolveHandoff).
345
+ # Empty/missing handoff (first solve or no prior carrier) leaves
346
+ # ``p_realized`` ``None`` so the mod default of 0 applies. The
347
+ # legacy ``solve_data/ladder_cum_realized_mwh`` CSV fallback was
348
+ # dropped in 4.2-1g — the translator pipeline is the only source.
349
+ from flextool.engine_polars import _provider_keys as K
350
+ from flextool.engine_polars._provider_translators import read_handoff_frame
351
+ p_realized = None
352
+ rel = read_handoff_frame(provider, K.HANDOFF_CUMULATIVE_COMMODITY)
353
+ if rel is not None and rel.height > 0:
354
+ value_col = "p_ladder_cum_realized_mwh" if "p_ladder_cum_realized_mwh" in rel.columns else "value"
355
+ rel = (rel.rename({"commodity": "c", "tier": "i", "period": "d",
356
+ value_col: "value"})
357
+ .with_columns(
358
+ pl.col("i").cast(pl.Utf8),
359
+ pl.col("value").cast(pl.Float64),
360
+ )
361
+ .select("c", "i", "d", "value"))
362
+ p_realized = Param(("c", "i", "d"), rel)
363
+
364
+ return dict(
365
+ commodity_with_ladder=cwl,
366
+ commodity_with_ladder_annual=cwla,
367
+ commodity_with_ladder_cumulative=cwlc,
368
+ cnd_ladder=cnd,
369
+ cndi_ladder=cndi,
370
+ cndi_ladder_ann=cndi_ann,
371
+ cndi_ladder_cum=cndi_cum,
372
+ ci_ladder_cumulative=ci_cum,
373
+ commodity__tier_ann=ct_ann,
374
+ commodity__tier_cum=ct_cum,
375
+ p_ladder_ann_price=p_ann_price,
376
+ p_ladder_ann_quantity=p_ann_quantity,
377
+ p_ladder_cum_price=p_cum_price,
378
+ p_ladder_cum_quantity=p_cum_quantity,
379
+ p_commodity_unitsize=p_unitsize,
380
+ p_f_d_k=p_f_d_k,
381
+ p_ladder_cum_realized_mwh=p_realized,
382
+ )
383
+
384
+
385
+ # ---------------------------------------------------------------------------
386
+ # Variable + constraint emission
387
+
388
+ def add_variables(m, d) -> dict:
389
+ """Declare ``v_trade[c, n, d, i]`` over ``cndi_ladder``.
390
+
391
+ Index domain is the union of the cumulative-tier and annual-tier
392
+ sets (``cndi_ladder_cum_set.csv`` ∪ ``cndi_ladder_ann_set.csv``).
393
+ """
394
+ if not has_feature(d):
395
+ return {}
396
+ cndi = getattr(d, "cndi_ladder", None)
397
+ if cndi is None or cndi.height == 0:
398
+ return {}
399
+ v_trade = m.add_var("v_trade", ("c", "n", "d", "i"), cndi, lower=0.0)
400
+ return {"v_trade": v_trade}
401
+
402
+
403
+ def _commodity_unitsize_param(d) -> "Param":
404
+ """Return the ``p_commodity_unitsize`` Param, defaulting to 1.0 for
405
+ every commodity in ``commodity_with_ladder``.
406
+
407
+ The .mod declares ``p_commodity_unitsize default 1.0`` so even an
408
+ empty CSV must yield a unit-mass coefficient.
409
+ """
410
+ p = getattr(d, "p_commodity_unitsize", None)
411
+ if p is not None:
412
+ return p
413
+ cwl = d.commodity_with_ladder
414
+ return Param(
415
+ ("c",),
416
+ cwl.select("c").unique().with_columns(value=pl.lit(1.0)),
417
+ )
418
+
419
+
420
+ def add_constraints(
421
+ m, d, vars: dict, *,
422
+ v_flow=None,
423
+ p_unitsize=None,
424
+ p_slope=None,
425
+ p_step_duration=None,
426
+ p_timestep_weight=None,
427
+ flow_from_commodity_eff=None,
428
+ flow_from_commodity_noEff=None,
429
+ flow_to_commodity=None,
430
+ ) -> None:
431
+ """Emit balance constraint + per-tier cap constraints.
432
+
433
+ Parameters
434
+ ----------
435
+ v_flow : Var
436
+ The model's process flow variable, ``v_flow[p, source, sink, d, t]``.
437
+ p_unitsize : Param
438
+ Process unitsize ``p_entity_unitsize[p]``.
439
+ p_slope : Param
440
+ Process efficiency slope ``pdtProcess_slope[p, d, t]``.
441
+ p_step_duration, p_timestep_weight : Param
442
+ Time-weight params.
443
+ flow_from_commodity_eff / flow_from_commodity_noEff : pl.DataFrame
444
+ Buy-side index frames ``(p, source, sink, c)``. ``source``
445
+ is the commodity node (n).
446
+ flow_to_commodity : pl.DataFrame
447
+ Sell-side index frame ``(p, source, sink, c)``. ``sink`` is
448
+ the commodity node (n).
449
+ """
450
+ if not has_feature(d):
451
+ return
452
+ v_trade = vars.get("v_trade")
453
+ if v_trade is None:
454
+ return
455
+
456
+ p_unit_c = _commodity_unitsize_param(d)
457
+ cnd = d.cnd_ladder
458
+
459
+ # ── 1. commodity_ladder_balance: Σ_i v_trade · unitsize == flow ────
460
+ # LHS: Σ_i v_trade[c, n, d, i] * p_commodity_unitsize[c]
461
+ lhs_sum = Sum(v_trade * p_unit_c, over=("i",))
462
+
463
+ # RHS: period-aggregate commodity flow through (c, n) — sums over
464
+ # processes and timesteps. Mirrors the .mod's
465
+ # commodity_ladder_balance RHS (flextool.mod:3651-3670).
466
+ #
467
+ # The RHS is built by taking the legacy commodity-price flow
468
+ # expression and dropping the price coefficient. Here we have
469
+ # three index frames:
470
+ # * flow_from_commodity_eff (p, source=n, sink, c) — buy via efficient process
471
+ # * flow_from_commodity_noEff (p, source=n, sink, c) — buy via no-eff process
472
+ # * flow_to_commodity (p, source, sink=n, c) — sell into priced node
473
+ #
474
+ # Each contributes weighted v_flow. Result is summed over
475
+ # (p, source, sink, t) to give a (c, n, d) aggregate.
476
+ rhs_terms: list = []
477
+ weight = (p_step_duration * p_timestep_weight) if p_step_duration is not None else None
478
+
479
+ if (flow_from_commodity_eff is not None
480
+ and flow_from_commodity_eff.height > 0
481
+ and v_flow is not None and p_unitsize is not None
482
+ and p_slope is not None):
483
+ # source-as-n rename so the sum-over (p, source, sink) collapses
484
+ # into the (c, n, d) index; n equals source for buy flows.
485
+ idx_eff = (flow_from_commodity_eff
486
+ .with_columns(n=pl.col("source"))
487
+ .select("p", "source", "sink", "c", "n"))
488
+ term = Where(v_flow * p_unitsize * p_slope, idx_eff)
489
+ if weight is not None:
490
+ term = term * weight
491
+ rhs_terms.append(("buy_eff",
492
+ Sum(term, over=("p", "source", "sink", "t"))))
493
+
494
+ if (flow_from_commodity_noEff is not None
495
+ and flow_from_commodity_noEff.height > 0
496
+ and v_flow is not None and p_unitsize is not None):
497
+ idx_noEff = (flow_from_commodity_noEff
498
+ .with_columns(n=pl.col("source"))
499
+ .select("p", "source", "sink", "c", "n"))
500
+ term = Where(v_flow * p_unitsize, idx_noEff)
501
+ if weight is not None:
502
+ term = term * weight
503
+ rhs_terms.append(("buy_noEff",
504
+ Sum(term, over=("p", "source", "sink", "t"))))
505
+
506
+ if (flow_to_commodity is not None
507
+ and flow_to_commodity.height > 0
508
+ and v_flow is not None and p_unitsize is not None):
509
+ idx_sell = (flow_to_commodity
510
+ .with_columns(n=pl.col("sink"))
511
+ .select("p", "source", "sink", "c", "n"))
512
+ term = Where(v_flow * p_unitsize, idx_sell)
513
+ if weight is not None:
514
+ term = term * weight
515
+ rhs_terms.append(("sell",
516
+ -Sum(term, over=("p", "source", "sink", "t"))))
517
+
518
+ rhs_dict = dict(rhs_terms) if rhs_terms else {}
519
+
520
+ m.add_cstr(
521
+ "commodity_ladder_balance",
522
+ over = cnd,
523
+ sense = "==",
524
+ lhs_terms = {"trade": lhs_sum},
525
+ rhs_terms = rhs_dict,
526
+ )
527
+
528
+ # ── 2. ladder_tier_cap_annual_roll ─────────────────────────────────
529
+ # Σ_n v_trade[c, n, d, i] · unitsize <= quantity[c, i, d] · f_d_k[d]
530
+ # − realized[c, i, d]
531
+ # Active only when quantity < 1e29 (drop infinite-tier rows).
532
+ cndi_ann = d.cndi_ladder_ann
533
+ if (cndi_ann is not None and cndi_ann.height > 0
534
+ and d.p_ladder_ann_quantity is not None):
535
+ # Restrict cndi_ann to (c, i, d) tuples where quantity < 1e29.
536
+ ann_q = d.p_ladder_ann_quantity.frame
537
+ ann_q_finite = ann_q.filter(pl.col("value") < _INF_SENTINEL).select("c", "i", "d")
538
+ cndi_ann_finite = cndi_ann.join(
539
+ ann_q_finite, on=["c", "i", "d"], how="inner")
540
+ if cndi_ann_finite.height > 0:
541
+ cap_idx = cndi_ann_finite.select("c", "i", "d").unique()
542
+ # LHS: Σ_n v_trade · unitsize over the finite-cap rows.
543
+ lhs_cap_ann = Sum(
544
+ Where(v_trade * p_unit_c, cndi_ann_finite),
545
+ over=("n",),
546
+ )
547
+ # RHS: quantity · f_d_k − realized. When f_d_k is missing
548
+ # (single-solve fixtures may omit it for non-realized periods)
549
+ # we treat it as 1.0. The realized accumulator is
550
+ # likewise 0 by default.
551
+ rhs_cap_ann_terms: dict = {}
552
+ if d.p_f_d_k is not None:
553
+ rhs_cap_ann_terms["cap"] = (
554
+ d.p_ladder_ann_quantity * d.p_f_d_k
555
+ )
556
+ else:
557
+ rhs_cap_ann_terms["cap"] = d.p_ladder_ann_quantity
558
+ if d.p_ladder_cum_realized_mwh is not None:
559
+ rhs_cap_ann_terms["realized"] = -d.p_ladder_cum_realized_mwh
560
+ m.add_cstr(
561
+ "ladder_tier_cap_annual_roll",
562
+ over = cap_idx,
563
+ sense = "<=",
564
+ lhs_terms = {"trade": lhs_cap_ann},
565
+ rhs_terms = rhs_cap_ann_terms,
566
+ )
567
+
568
+ # ── 3. ladder_tier_cap_cumulative_roll ─────────────────────────────
569
+ # Σ_{n, d} v_trade[c, n, d, i] · unitsize
570
+ # <= quantity[c, i] · Σ_d f_d_k[d] − Σ_d realized[c, i, d]
571
+ # One constraint per (c, i) ∈ ci_ladder_cumulative with quantity < 1e29.
572
+ ci_cum = d.ci_ladder_cumulative
573
+ if (ci_cum is not None and ci_cum.height > 0
574
+ and d.p_ladder_cum_quantity is not None
575
+ and d.cndi_ladder_cum is not None
576
+ and d.cndi_ladder_cum.height > 0):
577
+ cum_q = d.p_ladder_cum_quantity.frame
578
+ cum_q_finite = cum_q.filter(pl.col("value") < _INF_SENTINEL).select("c", "i")
579
+ ci_cum_finite = ci_cum.join(cum_q_finite, on=["c", "i"], how="inner")
580
+ if ci_cum_finite.height > 0:
581
+ # LHS: Σ_{n, d} v_trade · unitsize over the cumulative-tier
582
+ # rows. Index by (c, i).
583
+ cndi_cum_finite = (d.cndi_ladder_cum
584
+ .join(ci_cum_finite, on=["c", "i"], how="inner"))
585
+ lhs_cap_cum = Sum(
586
+ Where(v_trade * p_unit_c, cndi_cum_finite),
587
+ over=("n", "d"),
588
+ )
589
+ # RHS: quantity · Σ_d f_d_k − Σ_d realized.
590
+ # Σ_d f_d_k is a scalar per the .mod's "sum {d in
591
+ # period_in_use} f_d_k[d]" convention. Compute it eagerly
592
+ # from the f_d_k Param.
593
+ sum_f_d_k = 1.0
594
+ if d.p_f_d_k is not None:
595
+ sum_f_d_k = float(d.p_f_d_k.frame["value"].sum())
596
+ rhs_cap_cum_terms: dict = {
597
+ "cap": d.p_ladder_cum_quantity * sum_f_d_k,
598
+ }
599
+ if d.p_ladder_cum_realized_mwh is not None:
600
+ # Σ_d realized — collapse the (c, i, d) Param to (c, i).
601
+ realized_ci = (d.p_ladder_cum_realized_mwh.frame
602
+ .group_by(["c", "i"]).agg(pl.col("value").sum())
603
+ .select("c", "i", "value"))
604
+ rhs_cap_cum_terms["realized"] = -Param(
605
+ ("c", "i"), realized_ci)
606
+ m.add_cstr(
607
+ "ladder_tier_cap_cumulative_roll",
608
+ over = ci_cum_finite,
609
+ sense = "<=",
610
+ lhs_terms = {"trade": lhs_cap_cum},
611
+ rhs_terms = rhs_cap_cum_terms,
612
+ )
613
+
614
+
615
+ def add_objective_terms(m, d, vars: dict, *,
616
+ p_unitsize_c=None,
617
+ p_inflation_op=None,
618
+ p_period_share=None):
619
+ """Return the ladder per-tier price contribution to the objective.
620
+
621
+ Mirrors .mod:2017-2028:
622
+
623
+ + Σ_{(c,n,d,i) ∈ cndi_ladder_cum} cum_price[c,i] · v_trade
624
+ · unitsize · inflation_op[d] / period_share[d]
625
+ + Σ_{(c,n,d,i) ∈ cndi_ladder_ann} ann_price[c,i,d] · v_trade
626
+ · unitsize · inflation_op[d] / period_share[d]
627
+ """
628
+ if not has_feature(d):
629
+ return None
630
+ v_trade = vars.get("v_trade")
631
+ if v_trade is None:
632
+ return None
633
+ if p_unitsize_c is None:
634
+ p_unitsize_c = _commodity_unitsize_param(d)
635
+ annualization = None
636
+ if p_inflation_op is not None and p_period_share is not None:
637
+ annualization = p_inflation_op / p_period_share
638
+
639
+ obj = None
640
+
641
+ if (d.cndi_ladder_cum is not None and d.cndi_ladder_cum.height > 0
642
+ and d.p_ladder_cum_price is not None):
643
+ term = Where(v_trade * p_unitsize_c, d.cndi_ladder_cum) \
644
+ * d.p_ladder_cum_price
645
+ if annualization is not None:
646
+ term = term * annualization
647
+ obj = Sum(term)
648
+
649
+ if (d.cndi_ladder_ann is not None and d.cndi_ladder_ann.height > 0
650
+ and d.p_ladder_ann_price is not None):
651
+ term = Where(v_trade * p_unitsize_c, d.cndi_ladder_ann) \
652
+ * d.p_ladder_ann_price
653
+ if annualization is not None:
654
+ term = term * annualization
655
+ if obj is None:
656
+ obj = Sum(term)
657
+ else:
658
+ obj = obj + Sum(term)
659
+
660
+ return obj