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