From 03df8101fa7929b3a91aef150c8e056edec081e8 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Sun, 28 Jun 2026 23:02:04 -0400 Subject: [PATCH] Port the contingency-event framework into POM Bring the build-time event (contingency-outage) framework over from PowerSimulations.jl. EventModel{<:PSY.Contingency}/EventKey and the four AbstractEventCondition types are PowerSystems-specific, so they live here in POM (reversing the earlier descope-to-IOM note). - core/event_model.jl, core/event_keys.jl: EventModel/EventKey (<: the IOM AbstractEventModel/AbstractEventKey abstracts), conditions, and a set_event_model!(device_model, event_model) convenience that derives the key. - common_models/contingency{,_arguments,_constraints}.jl: real add_event_arguments!/add_event_constraints!, the EventParameter parameter path, offset->balance add_to_expression!, the reactive outage constraint, and get_max_active_power (an IOM extension stub). - feedforward_interface.jl event stubs are now documented fallbacks. - test/test_events.jl: build/solve-level coverage (thermal/renewable/load x CopperPlate/DCP/ACP) plus a behavioral AvailableStatusParameter->0 forces dispatch to zero test; mock_construct_device!(...; add_event_model=true) wired. Depends on the DeviceModel.events field added in IOM ac/sienna1-port; the [sources] IOM pin is pointed at that branch until it merges to main. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/pom_port_plan.md | 21 +- Project.toml | 2 +- src/PowerOperationsModels.jl | 37 ++- src/common_models/contingency.jl | 65 ++++ src/common_models/contingency_arguments.jl | 326 +++++++++++++++++++ src/common_models/contingency_constraints.jl | 288 ++++++++++++++++ src/core/event_keys.jl | 21 ++ src/core/event_model.jl | 130 ++++++++ src/core/feedforward_interface.jl | 7 +- test/Project.toml | 2 +- test/test_events.jl | 156 +++++++++ test/test_utils/mock_operation_models.jl | 4 +- 12 files changed, 1045 insertions(+), 14 deletions(-) create mode 100644 src/common_models/contingency.jl create mode 100644 src/common_models/contingency_arguments.jl create mode 100644 src/common_models/contingency_constraints.jl create mode 100644 src/core/event_keys.jl create mode 100644 src/core/event_model.jl create mode 100644 test/test_events.jl diff --git a/.claude/pom_port_plan.md b/.claude/pom_port_plan.md index 5401c4be..f384d672 100644 --- a/.claude/pom_port_plan.md +++ b/.claude/pom_port_plan.md @@ -36,13 +36,24 @@ would double-scale; only the test is worth adding). VOM-normalization test, curtailment-incentive (#1614) test, services slack/feedforward/GroupReserve/ AGC/hydro variants, PowerModels-nonlinear & whitebox-reduction network testsets. -**Workstream C event framework:** 🟦 **descoped → IOM.** `EventModel`/`AbstractEventCondition`/ -`FixedForcedOutage`-projection/template-integration is generic lifecycle+template machinery (same -layer as `DeviceModel`/`ServiceModel`/`ProblemTemplate`), POM already owns its share (event -parameter/constraint types + per-formulation `add_event_*!` hooks), and it isn't on PSI `main`. -`test_events.jl` is consequently blocked → not on this branch. Track in `iom_port_plan.md`. +**Workstream C event framework:** ✅ **build-time framework now in POM** (decision reversed: the +earlier "descope → IOM" was wrong — `EventModel{<:PSY.Contingency,…}`/`EventKey` are +PowerSystems-specific, so they belong in POM). Ported `EventModel` + the 4 `AbstractEventCondition` +types, `EventKey`, the real `add_event_arguments!`/`add_event_constraints!` builders, the +`EventParameter` parameter path, and offset→balance `add_to_expression!`. Storage hook: a generic +`events::Dict{AbstractEventKey,AbstractEventModel}` field + `get_events`/`set_event_model!` added to +**IOM**'s `DeviceModel` (mirrors `feedforwards`/`outages`); `get_max_active_power` IOM stub now +implemented in POM. New `test/test_events.jl` is **build/solve-level** (PSI's is Simulation-driven and +does not port): thermal/renewable/load × CopperPlate/DCP/ACP, plus a behavioral `AvailableStatusParameter→0` +forces-dispatch-to-zero test. **Simulation-time event evaluation** (`simulation_events.jl`, +`recorder_events.jl`) and PSI's `test_events.jl` remain out of scope (no Simulation in POM). MBC tranche-count & concavity validation: ⏭️ already present (not blockers). +> Dev note: the `DeviceModel.events` field lives in IOM branch `ac/sienna1-port` (commit `d1e189a`, +> on top of the dual-mip-rounding commits). POM's IOM `[sources]` pin (both `Project.toml` and +> `test/Project.toml`) is set to `rev = "ac/sienna1-port"` until that IOM change merges to `main`; +> revert to `rev = "main"` then. + **Extra src fixes (beyond the PR list) to enable TransmissionInterface test coverage:** - ✅ `core/problem_template.jl`: uncommented `_modify_device_model!` no-ops for `ServiceModel{TransmissionInterface, ConstantMaxInterfaceFlow/VariableMaxInterfaceFlow}` (the diff --git a/Project.toml b/Project.toml index fa73c032..e75df210 100644 --- a/Project.toml +++ b/Project.toml @@ -27,7 +27,7 @@ PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" [sources] InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} -InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} +InfrastructureOptimizationModels = {rev = "ac/sienna1-port", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} [extensions] diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 41bdde24..5455b341 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -151,7 +151,16 @@ import InfrastructureOptimizationModels: start_up_cost, _get_initial_condition_type, set_ic_quantity!, - update_container_parameter_values! + update_container_parameter_values!, + # Event/contingency model storage (POM adds the convenience method that derives the key) + set_event_model!, + get_events, + # Outage-constraint RHS bound (IOM declares the stub, POM supplies the value) + get_max_active_power, + # Key/condition accessors POM extends for EventKey / EventModel conditions + get_entry_type, + get_component_type, + get_value # Market bid cost: import IOM functions that POM extends with device-specific methods import InfrastructureOptimizationModels: @@ -235,6 +244,8 @@ include("core/expressions.jl") include("core/constraints.jl") include("core/auxiliary_variables.jl") include("core/parameters.jl") +include("core/event_keys.jl") +include("core/event_model.jl") include("core/formulations.jl") include("core/bilinear_configs.jl") include("core/network_formulations.jl") @@ -251,6 +262,10 @@ include("common_models/add_to_expression.jl") include("common_models/objective_function.jl") # add_param_container.jl: moved into IOM include("common_models/add_parameters.jl") +# Contingency-event builders (override the no-op event stubs in feedforward_interface.jl) +include("common_models/contingency.jl") +include("common_models/contingency_arguments.jl") +include("common_models/contingency_constraints.jl") include("common_models/make_system_expressions.jl") include("common_models/reserve_range_constraints.jl") include("common_models/quadratic_converter_loss.jl") @@ -391,6 +406,26 @@ export DeviceModel export ServiceModel export OptimizationContainer +# Event / contingency models +export EventModel +export EventKey +export AbstractEventCondition +export ContinuousCondition +export PresetTimeCondition +export StateVariableValueCondition +export DiscreteEventCondition +export set_event_model! +export get_events +# Event parameters +export AvailableStatusParameter +export AvailableStatusChangeCountdownParameter +export ActivePowerOffsetParameter +export ReactivePowerOffsetParameter +# Event constraints +export ActivePowerOutageConstraint +export ReactivePowerOutageConstraint +export ActivePowerPumpOutageConstraint + # Initial Conditions Quantities export DevicePower export DeviceStatus diff --git a/src/common_models/contingency.jl b/src/common_models/contingency.jl new file mode 100644 index 00000000..4afa6777 --- /dev/null +++ b/src/common_models/contingency.jl @@ -0,0 +1,65 @@ +# Build-time contingency-event helpers: parameter defaults and the EventParameter +# parameter-container path. Ported from PowerSimulations.jl `src/contingency_model/`, +# adapted to POM's type-based accessor conventions and IOM-qualified container internals. + +# The EventParameter outage constraint (IOM `range_constraint.jl`) bounds dispatch by +# `get_max_active_power(device) * AvailableStatusParameter`. POM supplies the value. +get_max_active_power(d::PSY.Device) = PSY.get_max_active_power(d, PSY.SU) + +#! format: off +# These values could change depending on the event modeling choices. +get_parameter_multiplier(::EventParameter, ::PSY.Device, ::EventModel) = 1.0 +get_initial_parameter_value(::ActivePowerOffsetParameter, ::PSY.Device, ::EventModel) = 0.0 +get_initial_parameter_value(::ReactivePowerOffsetParameter, ::PSY.Device, ::EventModel) = 0.0 +get_initial_parameter_value(::AvailableStatusChangeCountdownParameter, ::PSY.Device, ::EventModel) = 0.0 +get_initial_parameter_value(::AvailableStatusParameter, ::PSY.Device, ::EventModel) = 1.0 +#! format: on + +function _add_parameters!( + container::OptimizationContainer, + ::Type{T}, + devices::Vector{U}, + device_model::DeviceModel{U, W}, + event_model::EventModel{V, X}, +) where { + T <: EventParameter, + U <: PSY.Component, + V <: PSY.Contingency, + W <: AbstractDeviceFormulation, + X <: AbstractEventCondition, +} + @debug "adding" T U V _group = IOM.LOG_GROUP_OPTIMIZATION_CONTAINER + time_steps = get_time_steps(container) + names = PSY.get_name.(devices) + # The contingency type is build-time metadata only; the optimization container keys + # the parameter by the device type for both the `ParameterKey` and `affected_devices`. + parameter_container = add_param_container!(container, T, U, U, names, time_steps) + + jump_model = get_jump_model(container) + parent_mult = IOM.get_multiplier_array_data(parameter_container) + parent_param = IOM.get_parameter_array_data(parameter_container) + + for (i, d) in enumerate(devices) + ini_val = get_initial_parameter_value(T(), d, event_model) + IOM._set_multiplier_at!( + parent_mult, + get_parameter_multiplier(T(), d, event_model), + i, + ) + for t in time_steps + IOM._set_parameter_at!(parent_param, jump_model, ini_val, i, t) + end + end + return +end + +function add_parameters!( + container::OptimizationContainer, + ::Type{T}, + devices::Vector{U}, + device_model::DeviceModel{U, W}, + event_model::EventModel, +) where {T <: EventParameter, U <: PSY.Component, W <: AbstractDeviceFormulation} + _add_parameters!(container, T, devices, device_model, event_model) + return +end diff --git a/src/common_models/contingency_arguments.jl b/src/common_models/contingency_arguments.jl new file mode 100644 index 00000000..6ffff5bb --- /dev/null +++ b/src/common_models/contingency_arguments.jl @@ -0,0 +1,326 @@ +# Event (contingency) arguments: add event parameters during ArgumentConstructStage. +# Ported from PowerSimulations.jl `src/contingency_model/contingency_arguments.jl`, +# adapted to POM's type-based accessors. These override the no-op fallbacks in +# `core/feedforward_interface.jl` for device/formulation/network combinations that model +# contingency events; iterating `get_events(device_model)` is a no-op when none are set. + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, +} where {U <: PSY.StaticInjection} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + for p_type in [AvailableStatusChangeCountdownParameter, AvailableStatusParameter] + add_parameters!( + container, + p_type, + devices_with_attributes, + device_model, + event_model, + ) + end + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{<:PM.AbstractActivePowerModel}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: Union{StaticPowerLoad, PowerLoadDispatch, PowerLoadInterruption}, +} where {U <: PSY.PowerLoad} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + for p_type in [AvailableStatusChangeCountdownParameter, AvailableStatusParameter] + add_parameters!( + container, + p_type, + devices_with_attributes, + device_model, + event_model, + ) + end + add_parameters!( + container, + ActivePowerOffsetParameter, + devices_with_attributes, + device_model, + event_model, + ) + add_to_expression!( + container, + ActivePowerBalance, + ActivePowerOffsetParameter, + devices_with_attributes, + device_model, + network_model, + ) + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{<:PM.AbstractPowerModel}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: Union{StaticPowerLoad, PowerLoadDispatch, PowerLoadInterruption}, +} where {U <: PSY.PowerLoad} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + for p_type in [AvailableStatusChangeCountdownParameter, AvailableStatusParameter] + add_parameters!( + container, + p_type, + devices_with_attributes, + device_model, + event_model, + ) + end + add_parameters!( + container, + ActivePowerOffsetParameter, + devices_with_attributes, + device_model, + event_model, + ) + add_to_expression!( + container, + ActivePowerBalance, + ActivePowerOffsetParameter, + devices_with_attributes, + device_model, + network_model, + ) + add_parameters!( + container, + ReactivePowerOffsetParameter, + devices_with_attributes, + device_model, + event_model, + ) + add_to_expression!( + container, + ReactivePowerBalance, + ReactivePowerOffsetParameter, + devices_with_attributes, + device_model, + network_model, + ) + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, FixedOutput}, + network_model::NetworkModel{<:PM.AbstractActivePowerModel}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, +} where {U <: PSY.StaticInjection} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + for p_type in [AvailableStatusChangeCountdownParameter, AvailableStatusParameter] + add_parameters!( + container, + p_type, + devices_with_attributes, + device_model, + event_model, + ) + end + add_parameters!( + container, + ActivePowerOffsetParameter, + devices_with_attributes, + device_model, + event_model, + ) + add_to_expression!( + container, + ActivePowerBalance, + ActivePowerOffsetParameter, + devices_with_attributes, + device_model, + network_model, + ) + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, FixedOutput}, + network_model::NetworkModel{<:PM.AbstractPowerModel}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, +} where {U <: PSY.StaticInjection} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + for p_type in [AvailableStatusChangeCountdownParameter, AvailableStatusParameter] + add_parameters!( + container, + p_type, + devices_with_attributes, + device_model, + event_model, + ) + end + add_parameters!( + container, + ActivePowerOffsetParameter, + devices_with_attributes, + device_model, + event_model, + ) + add_to_expression!( + container, + ActivePowerBalance, + ActivePowerOffsetParameter, + devices_with_attributes, + device_model, + network_model, + ) + add_parameters!( + container, + ReactivePowerOffsetParameter, + devices_with_attributes, + device_model, + event_model, + ) + add_to_expression!( + container, + ReactivePowerBalance, + ReactivePowerOffsetParameter, + devices_with_attributes, + device_model, + network_model, + ) + end + return +end + +################################################################################# +# add_to_expression! for EventParameter offsets → SystemBalanceExpressions +################################################################################# + +# Nodal injection (PTDF/DCP/ACP and other AbstractPowerModel network models). +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + model::DeviceModel{V, W}, + network_model::NetworkModel{X}, +) where { + T <: SystemBalanceExpressions, + U <: EventParameter, + V <: PSY.Device, + W <: AbstractDeviceFormulation, + X <: PM.AbstractPowerModel, +} + param_array = get_parameter_array(container, U, V) + multiplier = get_parameter_multiplier_array(container, U, V) + network_reduction = get_network_reduction(network_model) + for d in devices, t in get_time_steps(container) + bus_no = PNM.get_mapped_bus_number(network_reduction, PSY.get_bus(d)) + name = PSY.get_name(d) + add_proportional_to_jump_expression!( + get_expression(container, T, PSY.ACBus)[bus_no, t], + param_array[name, t], + multiplier[name, t], + ) + end + return +end + +# CopperPlate: inject at the reference bus of the device's subnetwork. +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + model::DeviceModel{V, W}, + network_model::NetworkModel{CopperPlatePowerModel}, +) where { + T <: SystemBalanceExpressions, + U <: EventParameter, + V <: PSY.Device, + W <: AbstractDeviceFormulation, +} + param_array = get_parameter_array(container, U, V) + multiplier = get_parameter_multiplier_array(container, U, V) + expression = get_expression(container, T, PSY.System) + for d in devices + ref_bus = get_reference_bus(network_model, PSY.get_bus(d)) + name = PSY.get_name(d) + for t in get_time_steps(container) + add_proportional_to_jump_expression!( + expression[ref_bus, t], + param_array[name, t], + multiplier[name, t], + ) + end + end + return +end + +# Area balance: inject into the device's area. +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + model::DeviceModel{V, W}, + network_model::NetworkModel{AreaBalancePowerModel}, +) where { + T <: SystemBalanceExpressions, + U <: EventParameter, + V <: PSY.Device, + W <: AbstractDeviceFormulation, +} + param_array = get_parameter_array(container, U, V) + multiplier = get_parameter_multiplier_array(container, U, V) + for d in devices, t in get_time_steps(container) + area_name = PSY.get_name(PSY.get_area(PSY.get_bus(d))) + name = PSY.get_name(d) + add_proportional_to_jump_expression!( + get_expression(container, T, PSY.Area)[area_name, t], + param_array[name, t], + multiplier[name, t], + ) + end + return +end diff --git a/src/common_models/contingency_constraints.jl b/src/common_models/contingency_constraints.jl new file mode 100644 index 00000000..095b16e0 --- /dev/null +++ b/src/common_models/contingency_constraints.jl @@ -0,0 +1,288 @@ +# Event (contingency) constraints: add outage constraints during ModelConstructStage. +# Ported from PowerSimulations.jl `src/contingency_model/contingency_constraints.jl`, +# adapted to POM's type-based accessors and the psy6 explicit-units convention. The active +# outage bound reuses IOM's `EventParameter` `add_parameterized_upper_bound_range_constraints` +# path (RHS = `get_max_active_power(device) * AvailableStatusParameter`). + +function add_event_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{W}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, + W <: PM.AbstractActivePowerModel, +} where {U <: PSY.ThermalGen} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + add_parameterized_upper_bound_range_constraints( + container, + ActivePowerOutageConstraint, + ActivePowerRangeExpressionUB, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_event_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{W}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, + W <: PM.AbstractPowerModel, +} where {U <: PSY.ThermalGen} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + add_parameterized_upper_bound_range_constraints( + container, + ActivePowerOutageConstraint, + ActivePowerRangeExpressionUB, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + add_reactive_power_contingency_constraint( + container, + ReactivePowerOutageConstraint, + ReactivePowerVariable, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_event_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{W}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, + W <: PM.AbstractActivePowerModel, +} where {U <: PSY.RenewableGen} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + lhs_type = + if has_service_model(device_model) + ActivePowerRangeExpressionUB + else + ActivePowerVariable + end + add_parameterized_upper_bound_range_constraints( + container, + ActivePowerOutageConstraint, + lhs_type, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_event_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{W}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, + W <: PM.AbstractPowerModel, +} where {U <: PSY.RenewableGen} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + lhs_type = + if has_service_model(device_model) + ActivePowerRangeExpressionUB + else + ActivePowerVariable + end + add_parameterized_upper_bound_range_constraints( + container, + ActivePowerOutageConstraint, + lhs_type, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + add_reactive_power_contingency_constraint( + container, + ReactivePowerOutageConstraint, + ReactivePowerVariable, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_event_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{W}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, + W <: PM.AbstractActivePowerModel, +} where {U <: PSY.ElectricLoad} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + add_parameterized_upper_bound_range_constraints( + container, + ActivePowerOutageConstraint, + ActivePowerVariable, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_event_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{W}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, + W <: PM.AbstractPowerModel, +} where {U <: PSY.ElectricLoad} + for (key, event_model) in get_events(device_model) + event_type = get_entry_type(key) + devices_with_attributes = + [d for d in devices if PSY.has_supplemental_attributes(d, event_type)] + isempty(devices_with_attributes) && + error("no devices found with a supplemental attribute for event $event_type") + add_parameterized_upper_bound_range_constraints( + container, + ActivePowerOutageConstraint, + ActivePowerVariable, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + add_reactive_power_contingency_constraint( + container, + ReactivePowerOutageConstraint, + ReactivePowerVariable, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_reactive_power_contingency_constraint( + container::OptimizationContainer, + ::Type{ReactivePowerOutageConstraint}, + ::Type{ReactivePowerVariable}, + ::Type{AvailableStatusParameter}, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + model::DeviceModel{V, W}, + ::Type{X}, +) where { + V <: PSY.Component, + W <: AbstractDeviceFormulation, + X <: PM.AbstractPowerModel, +} + array_reactive = get_variable(container, ReactivePowerVariable, V) + _add_reactive_power_contingency_constraint_impl!( + container, + ReactivePowerOutageConstraint, + array_reactive, + AvailableStatusParameter, + devices, + model, + ) + return +end + +function _add_reactive_power_contingency_constraint_impl!( + container::OptimizationContainer, + ::Type{ReactivePowerOutageConstraint}, + array_reactive, + ::Type{AvailableStatusParameter}, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + model::DeviceModel{V, W}, +) where { + V <: PSY.Component, + W <: AbstractDeviceFormulation, +} + time_steps = get_time_steps(container) + names = PSY.get_name.(devices) + constraint_container = + add_constraints_container!( + container, + ReactivePowerOutageConstraint, + V, + names, + time_steps; + meta = "ub", + ) + + param_array = get_parameter_array(container, AvailableStatusParameter, V) + jump_model = get_jump_model(container) + for device in devices, t in time_steps + name = PSY.get_name(device) + ub = _get_reactive_power_upper_bound(device) + constraint_container[name, t] = JuMP.@constraint( + jump_model, + (array_reactive[name, t])^2 <= (ub * param_array[name, t]) + ) + end + return +end + +function _get_reactive_power_upper_bound(device::PSY.StaticInjection) + limits = PSY.get_reactive_power_limits(device, PSY.SU) + return max(limits.max^2, limits.min^2) +end + +function _get_reactive_power_upper_bound(device::PSY.ElectricLoad) + return PSY.get_max_reactive_power(device, PSY.SU)^2 +end diff --git a/src/core/event_keys.jl b/src/core/event_keys.jl new file mode 100644 index 00000000..0f56bd40 --- /dev/null +++ b/src/core/event_keys.jl @@ -0,0 +1,21 @@ +struct EventKey{T <: PSY.Contingency, U <: Union{PSY.Component, PSY.System}} <: + IOM.AbstractEventKey + meta::String +end + +function EventKey( + ::Type{T}, + ::Type{U}, +) where {T <: PSY.Contingency, U <: Union{PSY.Component, PSY.System}} + if isabstracttype(U) + error("Type $U can't be abstract") + end + return EventKey{T, U}("") +end + +get_entry_type( + ::EventKey{T, U}, +) where {T <: PSY.Contingency, U <: Union{PSY.Component, PSY.System}} = T +get_component_type( + ::EventKey{T, U}, +) where {T <: PSY.Contingency, U <: Union{PSY.Component, PSY.System}} = U diff --git a/src/core/event_model.jl b/src/core/event_model.jl new file mode 100644 index 00000000..acaa4833 --- /dev/null +++ b/src/core/event_model.jl @@ -0,0 +1,130 @@ +abstract type AbstractEventCondition end + +""" + ContinuousCondition() + +Establishes an event condition that is triggered at all timesteps. +""" +struct ContinuousCondition <: AbstractEventCondition end + +""" + PresetTimeCondition(time_stamps::Vector{Dates.DateTime}) + +Establishes an event condition that is triggered at pre-determined times. + +# Arguments + - `time_stamps::Vector{Dates.DateTime}`: times when event is triggered +""" +struct PresetTimeCondition <: AbstractEventCondition + time_stamps::Vector{Dates.DateTime} +end + +get_time_stamps(c::PresetTimeCondition) = c.time_stamps + +""" + StateVariableValueCondition( + variable_type::Type{<:VariableType} + device_type::Type{<:PSY.Device} + device_name::String + value::Float64 + ) + +Establishes an event condition that is triggered if a variable of type `variable_type` for a device of type +`device_type` and name `device_name` is equal to `value`. + +# Arguments + - `variable_type::Type{<:VariableType}`: variable to be monitored + - `device_type::Type{<:PSY.Device}`: device type to be monitored + - `device_name::String`: name of monitored device + - `value::Float64`: value to compare to in p.u. +""" +struct StateVariableValueCondition <: AbstractEventCondition + variable_type::VariableType + device_type::Type{<:PSY.Device} + device_name::String + value::Float64 +end + +get_variable_type(c::StateVariableValueCondition) = c.variable_type +get_device_type(c::StateVariableValueCondition) = c.device_type +get_device_name(c::StateVariableValueCondition) = c.device_name +get_value(c::StateVariableValueCondition) = c.value + +""" + DiscreteEventCondition(condition_function::Function) + +Establishes an event condition that is triggered when a user defined function evaluates to true. +The function should take the simulation state as its only argument and return true when the event should be triggered and false otherwise. + +# Arguments + - `condition_function::Function`: user defined function to determine if event is triggered. +""" +struct DiscreteEventCondition <: AbstractEventCondition + condition_function::Function +end + +get_condition_function(c::DiscreteEventCondition) = c.condition_function + +mutable struct EventModel{D <: PSY.Contingency, B <: AbstractEventCondition} <: + IOM.AbstractEventModel + condition::B + timeseries_mapping::Dict{Symbol, Union{String, Nothing}} + attribute_device_map::Dict{Symbol, Dict{Base.UUID, Dict{DataType, Set{String}}}} + attributes::Dict{String, Any} + + function EventModel( + contingency_type::Type{D}, + condition::B; + timeseries_mapping = get_empty_timeseries_mapping(contingency_type), + attributes = Dict{String, Any}(), + ) where {D <: PSY.Contingency, B <: AbstractEventCondition} + new{D, B}( + condition, + timeseries_mapping, + Dict{Symbol, Dict{Base.UUID, Dict{DataType, Set{String}}}}(), + attributes, + ) + end +end + +function get_empty_timeseries_mapping( + ::Type{PSY.FixedForcedOutage}, +) + return Dict{Symbol, Union{String, Nothing}}( + :outage_status => nothing, + ) +end + +function get_empty_timeseries_mapping( + ::Type{PSY.GeometricDistributionForcedOutage}, +) + return Dict{Symbol, Union{String, Nothing}}( + :mean_time_to_recovery => nothing, + :outage_transition_probability => nothing, + ) +end + +get_event_type( + ::EventModel{D, B}, +) where {D <: PSY.Contingency, B <: AbstractEventCondition} = D + +get_event_condition( + e::EventModel{D, B}, +) where {D <: PSY.Contingency, B <: AbstractEventCondition} = e.condition + +get_attribute_device_map(e::EventModel) = e.attribute_device_map + +""" + set_event_model!(model::DeviceModel, event_model::EventModel) + +Attach `event_model` to `model`, deriving the `EventKey` from the event's contingency type +and the device model's component type. +""" +function set_event_model!( + model::DeviceModel{D, B}, + event_model::EventModel, +) where {D <: PSY.Component, B <: AbstractDeviceFormulation} + key = EventKey(get_event_type(event_model), D) + IOM.set_event_model!(model, key, event_model) + return +end diff --git a/src/core/feedforward_interface.jl b/src/core/feedforward_interface.jl index 866a02e9..259aa2f3 100644 --- a/src/core/feedforward_interface.jl +++ b/src/core/feedforward_interface.jl @@ -46,7 +46,10 @@ function add_feedforward_constraints!( return end -# ---- Event arguments (ArgumentConstructStage) ---- +# ---- Event arguments / constraints (ArgumentConstructStage / ModelConstructStage) ---- +# Generic no-op fallbacks. Device/formulation/network combinations that model contingency +# events override these with more-specific methods in `common_models/contingency_*.jl`; +# devices without events fall through here (iterating `get_events` would be a no-op anyway). function add_event_arguments!( ::OptimizationContainer, @@ -57,8 +60,6 @@ function add_event_arguments!( return end -# ---- Event constraints (ModelConstructStage) ---- - function add_event_constraints!( ::OptimizationContainer, ::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, diff --git a/test/Project.toml b/test/Project.toml index f5c826e8..b05f3025 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -34,7 +34,7 @@ TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [sources] -InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} +InfrastructureOptimizationModels = {rev = "ac/sienna1-port", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} PowerFlows = {rev = "ac/fix-rebase", url = "https://github.com/Sienna-Platform/PowerFlows.jl"} diff --git a/test/test_events.jl b/test/test_events.jl new file mode 100644 index 00000000..af85b421 --- /dev/null +++ b/test/test_events.jl @@ -0,0 +1,156 @@ +# Build/solve-level tests for the contingency-event framework. PSI's `test_events.jl` is +# Simulation-driven (multi-stage UC/ED/Emulator) and does not port to POM, which has no +# Simulation. Here we attach an `EventModel` to a `DeviceModel`, build a single +# `DecisionModel`, and assert the event parameters/constraints are present and the model +# solves; the behavioral testset drives `AvailableStatusParameter → 0` (recurrent build) +# and confirms the outage constraint forces dispatch to zero. + +# Attach a FixedForcedOutage supplemental attribute to every component of type `T` so the +# event builders find devices with an attached contingency. +function _attach_fixed_forced_outage!(sys, ::Type{T}) where {T <: PSY.Component} + for d in PSY.get_components(T, sys) + PSY.add_supplemental_attribute!( + sys, + d, + PSY.FixedForcedOutage(; outage_status = 0.0), + ) + end + return +end + +_has_param(model, P, D) = + haskey(IOM.get_parameters(model), IOM.ParameterKey(P, D)) +_has_constraint(model, C, D, meta) = + haskey(IOM.get_constraints(model), IOM.ConstraintKey(C, D, meta)) + +@testset "Events: ThermalStandard outage on CopperPlate builds and solves" begin + sys = PSB.build_system(PSITestSystems, "c_sys5") + _attach_fixed_forced_outage!(sys, ThermalStandard) + + template = get_thermal_dispatch_template_network(NetworkModel(CopperPlatePowerModel)) + dm = DeviceModel(ThermalStandard, ThermalBasicDispatch) + set_event_model!(dm, EventModel(PSY.FixedForcedOutage, ContinuousCondition())) + set_device_model!(template, dm) + + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + c = IOM.get_optimization_container(model) + @test _has_param(c, AvailableStatusParameter, ThermalStandard) + @test _has_param(c, AvailableStatusChangeCountdownParameter, ThermalStandard) + @test _has_constraint(c, ActivePowerOutageConstraint, ThermalStandard, "ub") + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +end + +@testset "Events: ThermalStandard outage on DCP (nodal) builds and solves" begin + sys = PSB.build_system(PSITestSystems, "c_sys5") + _attach_fixed_forced_outage!(sys, ThermalStandard) + + template = get_thermal_dispatch_template_network(NetworkModel(DCPPowerModel)) + dm = DeviceModel(ThermalStandard, ThermalBasicDispatch) + set_event_model!(dm, EventModel(PSY.FixedForcedOutage, ContinuousCondition())) + set_device_model!(template, dm) + + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + c = IOM.get_optimization_container(model) + @test _has_param(c, AvailableStatusParameter, ThermalStandard) + @test _has_constraint(c, ActivePowerOutageConstraint, ThermalStandard, "ub") + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +end + +@testset "Events: RenewableDispatch outage on CopperPlate builds and solves" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_re") + _attach_fixed_forced_outage!(sys, RenewableDispatch) + + template = PowerOperationsProblemTemplate(CopperPlatePowerModel) + set_device_model!(template, PowerLoad, StaticPowerLoad) + set_device_model!(template, ThermalStandard, ThermalBasicDispatch) + dm = DeviceModel(RenewableDispatch, RenewableFullDispatch) + set_event_model!(dm, EventModel(PSY.FixedForcedOutage, ContinuousCondition())) + set_device_model!(template, dm) + + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + c = IOM.get_optimization_container(model) + @test _has_param(c, AvailableStatusParameter, RenewableDispatch) + @test _has_constraint(c, ActivePowerOutageConstraint, RenewableDispatch, "ub") + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +end + +@testset "Events: PowerLoad active-power offset on CopperPlate builds and solves" begin + sys = PSB.build_system(PSITestSystems, "c_sys5") + _attach_fixed_forced_outage!(sys, PowerLoad) + + template = PowerOperationsProblemTemplate(CopperPlatePowerModel) + set_device_model!(template, ThermalStandard, ThermalBasicDispatch) + dm = DeviceModel(PowerLoad, StaticPowerLoad) + set_event_model!(dm, EventModel(PSY.FixedForcedOutage, ContinuousCondition())) + set_device_model!(template, dm) + + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + c = IOM.get_optimization_container(model) + @test _has_param(c, AvailableStatusParameter, PowerLoad) + @test _has_param(c, ActivePowerOffsetParameter, PowerLoad) + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +end + +@testset "Events: ThermalStandard reactive outage constraint under ACP" begin + sys = PSB.build_system(PSITestSystems, "c_sys5") + _attach_fixed_forced_outage!(sys, ThermalStandard) + + dm = DeviceModel(ThermalStandard, ThermalBasicDispatch) + model = DecisionModel(MockOperationProblem, ACPPowerModel, sys) + mock_construct_device!( + model, + dm; + built_for_recurrent_solves = true, + add_event_model = true, + ) + c = IOM.get_optimization_container(model) + @test _has_param(c, AvailableStatusParameter, ThermalStandard) + @test _has_constraint(c, ActivePowerOutageConstraint, ThermalStandard, "ub") + @test _has_constraint(c, ReactivePowerOutageConstraint, ThermalStandard, "ub") +end + +@testset "Events: AvailableStatusParameter=0 forces dispatch to zero" begin + sys = PSB.build_system(PSITestSystems, "c_sys5") + g = PSY.get_component(ThermalStandard, sys, "Solitude") + PSY.add_supplemental_attribute!(sys, g, PSY.FixedForcedOutage(; outage_status = 0.0)) + + dm = DeviceModel(ThermalStandard, ThermalBasicDispatch) + model = + DecisionModel(MockOperationProblem, DCPPowerModel, sys; optimizer = HiGHS_optimizer) + mock_construct_device!( + model, + dm; + built_for_recurrent_solves = true, + add_event_model = true, + ) + c = IOM.get_optimization_container(model) + status = IOM.get_parameter_array(c, AvailableStatusParameter, ThermalStandard) + p = IOM.get_variable(c, ActivePowerVariable, ThermalStandard) + @test eltype(status) <: JuMP.VariableRef + + jm = IOM.get_jump_model(model) + time_steps = axes(p)[2] + JuMP.@objective(jm, MOI.MAX_SENSE, sum(p["Solitude", t] for t in time_steps)) + + # Available (status defaults to 1): dispatch can reach the device maximum. + JuMP.optimize!(jm) + pmax = PSY.get_active_power_limits(g, PSY.SU).max + @test JuMP.value(p["Solitude", first(time_steps)]) ≈ pmax atol = 1e-4 + + # Outaged (status forced to 0): the outage constraint pins dispatch to zero. + for t in time_steps + JuMP.fix(status["Solitude", t], 0.0; force = true) + end + JuMP.optimize!(jm) + for t in time_steps + @test JuMP.value(p["Solitude", t]) ≈ 0.0 atol = 1e-4 + end +end diff --git a/test/test_utils/mock_operation_models.jl b/test/test_utils/mock_operation_models.jl index 97ccdc30..0fdfabe5 100644 --- a/test/test_utils/mock_operation_models.jl +++ b/test/test_utils/mock_operation_models.jl @@ -120,9 +120,7 @@ function mock_construct_device!( add_event_model = false, ) if add_event_model - error( - "Event models are not supported in InfrastructureOptimizationModels. Use PowerSimulations for event modeling.", - ) + set_event_model!(model, EventModel(PSY.FixedForcedOutage, ContinuousCondition())) end set_device_model!(problem.template, model) template = IOM.get_template(problem)