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.
- flextool/__init__.py +41 -0
- flextool/_mem_sampler.py +193 -0
- flextool/_resources.py +43 -0
- flextool/calibrate/__init__.py +51 -0
- flextool/calibrate/__main__.py +11 -0
- flextool/calibrate/_cli.py +316 -0
- flextool/calibrate/_db_alt.py +166 -0
- flextool/calibrate/_final_outputs.py +110 -0
- flextool/calibrate/_guard.py +151 -0
- flextool/calibrate/_loop.py +558 -0
- flextool/calibrate/_readers.py +223 -0
- flextool/calibrate/_report.py +263 -0
- flextool/calibrate/_sizing.py +699 -0
- flextool/calibrate/_solve.py +134 -0
- flextool/calibrate/_solve_status.py +495 -0
- flextool/cli/__init__.py +9 -0
- flextool/cli/_console.py +51 -0
- flextool/cli/_timing.py +147 -0
- flextool/cli/cmd_execute_flextool_workflow.py +187 -0
- flextool/cli/cmd_export_to_tabular.py +56 -0
- flextool/cli/cmd_import_sensitivities.py +75 -0
- flextool/cli/cmd_migrate_database.py +13 -0
- flextool/cli/cmd_open_results_db.py +269 -0
- flextool/cli/cmd_read_matpower.py +66 -0
- flextool/cli/cmd_read_old_flextool.py +63 -0
- flextool/cli/cmd_read_self_describing_tabular_input.py +50 -0
- flextool/cli/cmd_read_tabular_input.py +81 -0
- flextool/cli/cmd_run_flextool.py +1095 -0
- flextool/cli/cmd_scenario_results.py +284 -0
- flextool/cli/cmd_solve_mps.py +169 -0
- flextool/cli/cmd_update_flextool.py +17 -0
- flextool/cli/cmd_write_outputs.py +125 -0
- flextool/common_utils/__init__.py +1 -0
- flextool/common_utils/plot_mem_shape.py +77 -0
- flextool/common_utils/precision.py +451 -0
- flextool/decomposition/__init__.py +0 -0
- flextool/decomposition/region_decomposition.py +128 -0
- flextool/decomposition/region_filter.py +1261 -0
- flextool/engine_polars/__init__.py +110 -0
- flextool/engine_polars/_axis_enums.py +742 -0
- flextool/engine_polars/_benders.py +3462 -0
- flextool/engine_polars/_block_layout.py +1479 -0
- flextool/engine_polars/_blocks.py +1515 -0
- flextool/engine_polars/_commodity_ladder.py +660 -0
- flextool/engine_polars/_cumulative_invest.py +1165 -0
- flextool/engine_polars/_db_loader.py +153 -0
- flextool/engine_polars/_db_reader.py +127 -0
- flextool/engine_polars/_dc_power_flow.py +445 -0
- flextool/engine_polars/_delay.py +442 -0
- flextool/engine_polars/_derived_arithmetic.py +432 -0
- flextool/engine_polars/_derived_block.py +990 -0
- flextool/engine_polars/_derived_branch.py +769 -0
- flextool/engine_polars/_derived_existing.py +1353 -0
- flextool/engine_polars/_derived_npv.py +1297 -0
- flextool/engine_polars/_derived_params.py +9850 -0
- flextool/engine_polars/_derived_profile.py +881 -0
- flextool/engine_polars/_derived_walks.py +276 -0
- flextool/engine_polars/_determinism.py +70 -0
- flextool/engine_polars/_direct_params.py +2186 -0
- flextool/engine_polars/_dump_csvs.py +1009 -0
- flextool/engine_polars/_emit_arc_unions.py +1631 -0
- flextool/engine_polars/_emit_calc_params.py +729 -0
- flextool/engine_polars/_emit_chain_params.py +709 -0
- flextool/engine_polars/_emit_co2_accumulators.py +400 -0
- flextool/engine_polars/_emit_dispatchers.py +690 -0
- flextool/engine_polars/_emit_energy_margin.py +125 -0
- flextool/engine_polars/_emit_energy_margin_adder.py +290 -0
- flextool/engine_polars/_emit_entity_annual.py +428 -0
- flextool/engine_polars/_emit_inflow_scaling.py +1420 -0
- flextool/engine_polars/_emit_leaf_sets.py +550 -0
- flextool/engine_polars/_emit_lp_scaling.py +665 -0
- flextool/engine_polars/_emit_mid_sets.py +859 -0
- flextool/engine_polars/_emit_pdt_params.py +759 -0
- flextool/engine_polars/_emit_per_solve.py +774 -0
- flextool/engine_polars/_emit_period_calc.py +504 -0
- flextool/engine_polars/_emit_period_params.py +2398 -0
- flextool/engine_polars/_emit_provider_io.py +141 -0
- flextool/engine_polars/_emit_reserve.py +574 -0
- flextool/engine_polars/_emit_solve_time.py +311 -0
- flextool/engine_polars/_emit_solve_writers.py +1249 -0
- flextool/engine_polars/_flex_data_accumulator.py +388 -0
- flextool/engine_polars/_flex_data_provider.py +478 -0
- flextool/engine_polars/_group_slack.py +1253 -0
- flextool/engine_polars/_inmemory_reader.py +140 -0
- flextool/engine_polars/_input_source.py +336 -0
- flextool/engine_polars/_invest_seeds.py +191 -0
- flextool/engine_polars/_native_input_writer.py +100 -0
- flextool/engine_polars/_native_run_model.py +1348 -0
- flextool/engine_polars/_orchestration.py +4314 -0
- flextool/engine_polars/_output_writer.py +439 -0
- flextool/engine_polars/_param_shapes.py +1595 -0
- flextool/engine_polars/_parquet_bundle.py +723 -0
- flextool/engine_polars/_pdt_join.py +167 -0
- flextool/engine_polars/_pdt_lookup.py +547 -0
- flextool/engine_polars/_per_solve_sets.py +335 -0
- flextool/engine_polars/_projection_params.py +2056 -0
- flextool/engine_polars/_provider_keys.py +173 -0
- flextool/engine_polars/_provider_translators.py +225 -0
- flextool/engine_polars/_recursive_solve.py +703 -0
- flextool/engine_polars/_region_filter.py +2508 -0
- flextool/engine_polars/_reserve.py +649 -0
- flextool/engine_polars/_solve_acceptance.py +331 -0
- flextool/engine_polars/_solve_config.py +1001 -0
- flextool/engine_polars/_solve_context.py +885 -0
- flextool/engine_polars/_solve_handoff.py +164 -0
- flextool/engine_polars/_solve_state.py +232 -0
- flextool/engine_polars/_solver_base.py +36 -0
- flextool/engine_polars/_solver_dispatch.py +511 -0
- flextool/engine_polars/_spinedb_reader.py +1165 -0
- flextool/engine_polars/_stochastic.py +593 -0
- flextool/engine_polars/_subprocess_solve.py +1838 -0
- flextool/engine_polars/_timeline.py +1416 -0
- flextool/engine_polars/_vectorize.py +438 -0
- flextool/engine_polars/_warm.py +858 -0
- flextool/engine_polars/autoscale/__init__.py +107 -0
- flextool/engine_polars/autoscale/_config.py +218 -0
- flextool/engine_polars/autoscale/_layer2.py +1253 -0
- flextool/engine_polars/autoscale/_layer2_types.py +584 -0
- flextool/engine_polars/autoscale/_quantity_types.py +621 -0
- flextool/engine_polars/autoscale/_report.py +336 -0
- flextool/engine_polars/chain.py +259 -0
- flextool/engine_polars/input.py +6638 -0
- flextool/engine_polars/model.py +4754 -0
- flextool/env_check.py +388 -0
- flextool/export_to_tabular/__init__.py +5 -0
- flextool/export_to_tabular/db_reader.py +224 -0
- flextool/export_to_tabular/excel_writer.py +3559 -0
- flextool/export_to_tabular/export_settings.yaml +377 -0
- flextool/export_to_tabular/export_to_excel.py +227 -0
- flextool/export_to_tabular/formatting.py +543 -0
- flextool/export_to_tabular/sheet_config.py +876 -0
- flextool/gui/__init__.py +0 -0
- flextool/gui/__main__.py +118 -0
- flextool/gui/calibrate_commands.py +184 -0
- flextool/gui/calibrate_jobs.py +424 -0
- flextool/gui/check_tree.py +142 -0
- flextool/gui/cli_format.py +83 -0
- flextool/gui/config_parser.py +68 -0
- flextool/gui/data_models.py +362 -0
- flextool/gui/db_editor_integration.py +202 -0
- flextool/gui/db_version_check.py +269 -0
- flextool/gui/dialogs/__init__.py +0 -0
- flextool/gui/dialogs/add_dialog.py +1098 -0
- flextool/gui/dialogs/calibrate_dialog.py +1259 -0
- flextool/gui/dialogs/file_picker.py +473 -0
- flextool/gui/dialogs/group_picker.py +299 -0
- flextool/gui/dialogs/migration_consent_dialog.py +106 -0
- flextool/gui/dialogs/migration_progress_dialog.py +237 -0
- flextool/gui/dialogs/plot_dialog.py +459 -0
- flextool/gui/dialogs/plot_settings_picker.py +2184 -0
- flextool/gui/dialogs/project_dialog.py +426 -0
- flextool/gui/dialogs/update_dialog.py +212 -0
- flextool/gui/downsampling.py +88 -0
- flextool/gui/error_handling.py +50 -0
- flextool/gui/execution_manager.py +1715 -0
- flextool/gui/execution_window.py +1377 -0
- flextool/gui/hover_tooltip.py +111 -0
- flextool/gui/input_sources.py +730 -0
- flextool/gui/main_window.py +6181 -0
- flextool/gui/network_graph.py +215 -0
- flextool/gui/output_actions.py +393 -0
- flextool/gui/output_log_window.py +159 -0
- flextool/gui/platform_utils.py +421 -0
- flextool/gui/plot_cache.py +88 -0
- flextool/gui/plot_canvas.py +543 -0
- flextool/gui/plot_config_reader.py +272 -0
- flextool/gui/project_utils.py +100 -0
- flextool/gui/result_viewer.py +4394 -0
- flextool/gui/scenario_key.py +162 -0
- flextool/gui/scenario_lists.py +516 -0
- flextool/gui/settings_io.py +360 -0
- flextool/gui/solve_reader.py +103 -0
- flextool/gui/tree_reorder.py +88 -0
- flextool/gui/ui_metrics.py +420 -0
- flextool/input_derivation/__init__.py +281 -0
- flextool/input_derivation/_commodity_ladder.py +375 -0
- flextool/input_derivation/_commodity_ladder_sets.py +70 -0
- flextool/input_derivation/_dc_power_flow.py +377 -0
- flextool/input_derivation/_method_constants.py +77 -0
- flextool/input_derivation/_process_method.py +258 -0
- flextool/input_derivation/_specs.py +1026 -0
- flextool/input_derivation/_validators.py +321 -0
- flextool/lean_parquet.py +159 -0
- flextool/model_builder/__init__.py +5 -0
- flextool/model_builder/build_model.py +589 -0
- flextool/model_builder/encoding.py +67 -0
- flextool/model_builder/names.py +34 -0
- flextool/model_builder/profiles.py +129 -0
- flextool/plot_outputs/__init__.py +14 -0
- flextool/plot_outputs/axis_helpers.py +355 -0
- flextool/plot_outputs/color_template.py +888 -0
- flextool/plot_outputs/config.py +171 -0
- flextool/plot_outputs/format_helpers.py +345 -0
- flextool/plot_outputs/legend_helpers.py +143 -0
- flextool/plot_outputs/orchestrator.py +1141 -0
- flextool/plot_outputs/perf.py +37 -0
- flextool/plot_outputs/plan.py +1787 -0
- flextool/plot_outputs/plot_bars.py +1510 -0
- flextool/plot_outputs/plot_bars_detail.py +753 -0
- flextool/plot_outputs/plot_lines.py +951 -0
- flextool/plot_outputs/shared_manifest.py +564 -0
- flextool/plot_outputs/subplot_helpers.py +137 -0
- flextool/process_inputs/__init__.py +188 -0
- flextool/process_inputs/import_old_excel_input.json +4159 -0
- flextool/process_inputs/read_matpower.py +451 -0
- flextool/process_inputs/read_old_flextool.py +1288 -0
- flextool/process_inputs/read_self_describing_excel.py +1423 -0
- flextool/process_inputs/read_tabular_with_specification.py +1114 -0
- flextool/process_inputs/write_old_flextool_to_db.py +3077 -0
- flextool/process_inputs/write_self_describing_to_db.py +977 -0
- flextool/process_inputs/write_to_input_db.py +269 -0
- flextool/process_outputs/__init__.py +7 -0
- flextool/process_outputs/_annualize.py +55 -0
- flextool/process_outputs/_inmemory_helpers.py +292 -0
- flextool/process_outputs/_output_meta.py +672 -0
- flextool/process_outputs/calc_capacity_flows.py +107 -0
- flextool/process_outputs/calc_connections.py +136 -0
- flextool/process_outputs/calc_costs.py +260 -0
- flextool/process_outputs/calc_group_flows.py +192 -0
- flextool/process_outputs/calc_slacks.py +103 -0
- flextool/process_outputs/calc_storage_vre.py +160 -0
- flextool/process_outputs/drop_levels.py +208 -0
- flextool/process_outputs/handoff_writers.py +1315 -0
- flextool/process_outputs/out_ancillary.py +544 -0
- flextool/process_outputs/out_capacity.py +179 -0
- flextool/process_outputs/out_costs.py +334 -0
- flextool/process_outputs/out_flowgroup.py +189 -0
- flextool/process_outputs/out_flows.py +301 -0
- flextool/process_outputs/out_group.py +475 -0
- flextool/process_outputs/out_node.py +190 -0
- flextool/process_outputs/persist_realized_slice.py +601 -0
- flextool/process_outputs/process_results.py +24 -0
- flextool/process_outputs/read_highs_solution.py +2256 -0
- flextool/process_outputs/read_parameters.py +1799 -0
- flextool/process_outputs/read_sets.py +1095 -0
- flextool/process_outputs/read_variables.py +553 -0
- flextool/process_outputs/solve_order.py +81 -0
- flextool/process_outputs/spinedb_replay.py +412 -0
- flextool/process_outputs/union_realized_slice.py +224 -0
- flextool/process_outputs/write_outputs.py +1286 -0
- flextool/process_outputs/write_spinedb.py +1267 -0
- flextool/representative_periods/__init__.py +5 -0
- flextool/representative_periods/clustering.py +165 -0
- flextool/representative_periods/force_include.py +563 -0
- flextool/representative_periods/netload.py +365 -0
- flextool/representative_periods/netload_inputs.py +345 -0
- flextool/representative_periods/netload_iterate.py +722 -0
- flextool/representative_periods/preprocess.py +948 -0
- flextool/representative_periods/scenario_stack.py +195 -0
- flextool/representative_periods/weights.py +124 -0
- flextool/scenario_comparison/__init__.py +13 -0
- flextool/scenario_comparison/config_builder.py +158 -0
- flextool/scenario_comparison/constants.py +20 -0
- flextool/scenario_comparison/data_models.py +222 -0
- flextool/scenario_comparison/db_reader.py +399 -0
- flextool/scenario_comparison/dispatch_data.py +1002 -0
- flextool/scenario_comparison/dispatch_mappings.py +205 -0
- flextool/scenario_comparison/dispatch_plots.py +691 -0
- flextool/scenario_comparison/input_entity_colors.py +319 -0
- flextool/scenario_comparison/orchestrator.py +453 -0
- flextool/scenario_comparison/plan_union.py +244 -0
- flextool/scenario_comparison/plot_settings_seed.py +205 -0
- flextool/schemas/AXIS_CONTRACT.md +71 -0
- flextool/schemas/canonical_databases/howto_aggregate_output.json +6225 -0
- flextool/schemas/canonical_databases/howto_connections.json +5606 -0
- flextool/schemas/canonical_databases/howto_demand.json +5518 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir.json +6239 -0
- flextool/schemas/canonical_databases/howto_hydro_reservoir_with_pump.json +5933 -0
- flextool/schemas/canonical_databases/howto_non_sync_and_curtailment.json +5794 -0
- flextool/schemas/canonical_databases/howto_ramp_and_start_up.json +5707 -0
- flextool/schemas/canonical_databases/howto_stochastics.json +6032 -0
- flextool/schemas/canonical_databases/templates_examples.json +13532 -0
- flextool/schemas/canonical_databases/templates_time_settings_only.json +5340 -0
- flextool/schemas/comparison_settings_template.json +197 -0
- flextool/schemas/default_plot_settings.yaml +260 -0
- flextool/schemas/default_plots.yaml +2293 -0
- flextool/schemas/flextool_axis_contract.json +303 -0
- flextool/schemas/flextool_axis_contract.schema.json +247 -0
- flextool/schemas/old_flextool_import_template.json +4443 -0
- flextool/schemas/output_info_template.json +48 -0
- flextool/schemas/output_settings_template.json +256 -0
- flextool/schemas/pre_v26/flextool_template_constant_default.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_default_optional_output.json +2152 -0
- flextool/schemas/pre_v26/flextool_template_default_value.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_drop_down.json +2080 -0
- flextool/schemas/pre_v26/flextool_template_lifetime_method.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_optional_outputs.json +2094 -0
- flextool/schemas/pre_v26/flextool_template_output_node_flows.json +2105 -0
- flextool/schemas/pre_v26/flextool_template_results_master.json +493 -0
- flextool/schemas/pre_v26/flextool_template_rolling_start_remove.json +2087 -0
- flextool/schemas/pre_v26/flextool_template_rolling_window.json +2059 -0
- flextool/schemas/pre_v26/flextool_template_storage_binding_defaults.json +46 -0
- flextool/schemas/pre_v26/flextool_template_v2.json +1990 -0
- flextool/schemas/pre_v26/flextool_template_v25.json +3864 -0
- flextool/schemas/spinedb_results_schema.json +581 -0
- flextool/schemas/spinedb_schema.json +4636 -0
- flextool/solver_config/copt.opt.template +18 -0
- flextool/solver_config/cplex.opt.template +25 -0
- flextool/solver_config/gurobi.opt.template +18 -0
- flextool/solver_config/highs.opt.template +18 -0
- flextool/solver_config/xpress.opt.template +26 -0
- flextool/spinedb_backend/__init__.py +26 -0
- flextool/spinedb_backend/_axis_enums.py +1119 -0
- flextool/spinedb_backend/_backend.py +1139 -0
- flextool/update_flextool/__init__.py +12 -0
- flextool/update_flextool/canonical_databases.py +251 -0
- flextool/update_flextool/db_migration.py +7108 -0
- flextool/update_flextool/ensure_settings_db.py +138 -0
- flextool/update_flextool/export_database.py +103 -0
- flextool/update_flextool/extend_tests_fixture.py +772 -0
- flextool/update_flextool/generate_canonical.py +274 -0
- flextool/update_flextool/initialize_database.py +42 -0
- flextool/update_flextool/install_info.py +225 -0
- flextool/update_flextool/self_update.py +464 -0
- flextool/update_flextool/sync_master_json_template.py +125 -0
- flextool/update_flextool/test_fixtures.py +187 -0
- flextool-4.0.0.dist-info/METADATA +217 -0
- flextool-4.0.0.dist-info/RECORD +322 -0
- flextool-4.0.0.dist-info/WHEEL +5 -0
- flextool-4.0.0.dist-info/entry_points.txt +17 -0
- flextool-4.0.0.dist-info/licenses/LICENSE.txt +19 -0
- 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
|
+
)
|