From c3a9577ce7c0cd5cd4b31ca7789f11eb361fb8fa Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 15:40:46 -0700 Subject: [PATCH 01/19] add event container types (EventKey, EventModel, conditions) --- src/PowerOperationsModels.jl | 17 ++++ src/event_models/event_model.jl | 135 ++++++++++++++++++++++++++++++++ test/test_events.jl | 21 +++++ 3 files changed, 173 insertions(+) create mode 100644 src/event_models/event_model.jl create mode 100644 test/test_events.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 84baf5f9..147621da 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -247,6 +247,8 @@ include("core/problem_template.jl") include("core/feedforward_interface.jl") include("core/initial_conditions.jl") +include("event_models/event_model.jl") + # Common models - expression infrastructure # Expression container creation (add_expressions!) and helpers include("common_models/add_expressions.jl") @@ -405,6 +407,21 @@ export DeviceModel export ServiceModel export OptimizationContainer +# Event Model Container Types +export EventModel +export EventKey +export AbstractEventCondition +export ContinuousCondition +export PresetTimeCondition +export StateVariableValueCondition +export DiscreteEventCondition +export get_time_stamps +export get_empty_timeseries_mapping +export get_event_type +export get_event_condition +export get_attribute_device_map +export set_event_model! + # Initial Conditions Quantities export DevicePower export DeviceStatus diff --git a/src/event_models/event_model.jl b/src/event_models/event_model.jl new file mode 100644 index 00000000..91515c8b --- /dev/null +++ b/src/event_models/event_model.jl @@ -0,0 +1,135 @@ +""" + EventKey(::Type{T}, ::Type{U}) + +Key identifying an event of contingency type `T` applied to devices of concrete type `U`. +Used as the key of the `DeviceModel.events` dict. Errors if `U` is abstract. +""" +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 + +IOM.get_entry_type( + ::EventKey{T, U}, +) where {T <: PSY.Contingency, U <: Union{PSY.Component, PSY.System}} = T +IOM.get_component_type( + ::EventKey{T, U}, +) where {T <: PSY.Contingency, U <: Union{PSY.Component, PSY.System}} = U + +""" +Abstract type for the condition that triggers an event. POM stores conditions as data; +evaluating them requires a simulation runtime and happens outside this package. +""" +abstract type AbstractEventCondition end + +""" + ContinuousCondition() + +Event condition that is triggered at all timesteps. +""" +struct ContinuousCondition <: AbstractEventCondition end + +""" + PresetTimeCondition(time_stamps::Vector{Dates.DateTime}) + +Event condition that is triggered at pre-determined times. +""" +struct PresetTimeCondition <: AbstractEventCondition + time_stamps::Vector{Dates.DateTime} +end + +get_time_stamps(c::PresetTimeCondition) = c.time_stamps + +""" + StateVariableValueCondition(variable_type, device_type, device_name, value) + +Event condition triggered when the monitored variable equals `value` (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) + +Event condition driven by a user-defined function evaluated by the simulation runtime. +""" +struct DiscreteEventCondition <: AbstractEventCondition + condition_function::Function +end + +get_condition_function(c::DiscreteEventCondition) = c.condition_function + +""" + EventModel(contingency_type, condition; timeseries_mapping, attributes) + +Container binding a `PSY.Contingency` supplemental-attribute type to a trigger condition +and time-series mapping. Attach to a template with +`set_event_model!(template, event_model)`; build-time discovery populates +`attribute_device_map` (outage attribute UUID → device type → device names) and +distributes the event to the matching `DeviceModel`s. +""" +mutable struct EventModel{D <: PSY.Contingency, B <: AbstractEventCondition} <: + IOM.AbstractEventModel + condition::B + timeseries_mapping::Dict{Symbol, Union{String, Nothing}} + attribute_device_map::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{Base.UUID, Dict{DataType, Set{String}}}(), + attributes, + ) + end +end + +""" +Reserved time-series mapping keys for a contingency type. `:outage_status` is required +for `PSY.FixedForcedOutage`. +""" +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 diff --git a/test/test_events.jl b/test/test_events.jl new file mode 100644 index 00000000..30428d25 --- /dev/null +++ b/test/test_events.jl @@ -0,0 +1,21 @@ +@testset "EventKey and EventModel construction" begin + key = EventKey(PSY.FixedForcedOutage, PSY.ThermalStandard) + @test IOM.get_entry_type(key) == PSY.FixedForcedOutage + @test IOM.get_component_type(key) == PSY.ThermalStandard + # Abstract component types are rejected + @test_throws ErrorException EventKey(PSY.FixedForcedOutage, PSY.ThermalGen) + + em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + @test get_event_type(em) == PSY.FixedForcedOutage + @test get_event_condition(em) isa ContinuousCondition + @test em.timeseries_mapping == + Dict{Symbol, Union{String, Nothing}}(:outage_status => nothing) + @test isempty(get_attribute_device_map(em)) + + em_geo = EventModel(PSY.GeometricDistributionForcedOutage, ContinuousCondition()) + @test Set(keys(em_geo.timeseries_mapping)) == + Set([:mean_time_to_recovery, :outage_transition_probability]) + + pc = PresetTimeCondition([Dates.DateTime("2024-01-01T05:00:00")]) + @test get_time_stamps(pc) == [Dates.DateTime("2024-01-01T05:00:00")] +end From e800f3191f12ddbf2bcf070c19f5fbf14d78ea49 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 15:46:11 -0700 Subject: [PATCH 02/19] add docstrings to exported event accessor functions --- src/event_models/event_model.jl | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/event_models/event_model.jl b/src/event_models/event_model.jl index 91515c8b..37441927 100644 --- a/src/event_models/event_model.jl +++ b/src/event_models/event_model.jl @@ -48,6 +48,9 @@ struct PresetTimeCondition <: AbstractEventCondition time_stamps::Vector{Dates.DateTime} end +""" +Return the time stamps at which `c` is triggered. +""" get_time_stamps(c::PresetTimeCondition) = c.time_stamps """ @@ -124,12 +127,22 @@ function get_empty_timeseries_mapping(::Type{PSY.GeometricDistributionForcedOuta ) end +""" +Return the `PSY.Contingency` subtype that `e` models. +""" get_event_type( ::EventModel{D, B}, ) where {D <: PSY.Contingency, B <: AbstractEventCondition} = D +""" +Return the trigger condition attached to `e`. +""" get_event_condition( e::EventModel{D, B}, ) where {D <: PSY.Contingency, B <: AbstractEventCondition} = e.condition +""" +Return `e`'s outage attribute UUID → device type → device names map, populated by +build-time discovery. +""" get_attribute_device_map(e::EventModel) = e.attribute_device_map From faf34af92f177428271f7a77f4ac54b365d1078f Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 15:52:33 -0700 Subject: [PATCH 03/19] add event device-support and parameter traits Adds supports_events(::Type{<:PSY.Component}) plus get_initial_parameter_value/get_parameter_multiplier methods for the event parameter types, enabling outage-event discovery and defaults. --- src/PowerOperationsModels.jl | 6 ++++++ src/event_models/event_traits.jl | 19 +++++++++++++++++++ test/test_events.jl | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 src/event_models/event_traits.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 147621da..53f2c814 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -248,6 +248,7 @@ include("core/feedforward_interface.jl") include("core/initial_conditions.jl") include("event_models/event_model.jl") +include("event_models/event_traits.jl") # Common models - expression infrastructure # Expression container creation (add_expressions!) and helpers @@ -421,6 +422,11 @@ export get_event_type export get_event_condition export get_attribute_device_map export set_event_model! +export supports_events +export AvailableStatusParameter +export ActivePowerOffsetParameter +export ReactivePowerOffsetParameter +export AvailableStatusChangeCountdownParameter # Initial Conditions Quantities export DevicePower diff --git a/src/event_models/event_traits.jl b/src/event_models/event_traits.jl new file mode 100644 index 00000000..55f3df0d --- /dev/null +++ b/src/event_models/event_traits.jl @@ -0,0 +1,19 @@ +#! format: off +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 + +""" +Whether devices of this type support outage events (`EventModel`). This is a device-type +capability trait for time-series outage events — distinct from `supports_outages`, the +formulation trait for security-constrained (MODF) branch contingencies. +""" +supports_events(::Type{T}) where {T <: PSY.Component} = false +supports_events(::Type{T}) where {T <: PSY.ThermalStandard} = true +supports_events(::Type{T}) where {T <: PSY.RenewableGen} = true +supports_events(::Type{T}) where {T <: PSY.ElectricLoad} = true +supports_events(::Type{T}) where {T <: PSY.Storage} = true +supports_events(::Type{T}) where {T <: PSY.HydroGen} = true +#! format: on diff --git a/test/test_events.jl b/test/test_events.jl index 30428d25..fdc067bd 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -19,3 +19,24 @@ pc = PresetTimeCondition([Dates.DateTime("2024-01-01T05:00:00")]) @test get_time_stamps(pc) == [Dates.DateTime("2024-01-01T05:00:00")] end + +@testset "Event traits" begin + @test POM.supports_events(PSY.ThermalStandard) + @test POM.supports_events(PSY.RenewableDispatch) + @test POM.supports_events(PSY.PowerLoad) + @test POM.supports_events(PSY.HydroDispatch) + @test POM.supports_events(PSY.EnergyReservoirStorage) + @test !POM.supports_events(PSY.Source) + + em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + d = PSY.ThermalStandard(nothing) + @test POM.get_initial_parameter_value(AvailableStatusParameter(), d, em) == 1.0 + @test POM.get_initial_parameter_value( + AvailableStatusChangeCountdownParameter(), + d, + em, + ) == 0.0 + @test POM.get_initial_parameter_value(ActivePowerOffsetParameter(), d, em) == 0.0 + @test POM.get_initial_parameter_value(ReactivePowerOffsetParameter(), d, em) == 0.0 + @test POM.get_parameter_multiplier(AvailableStatusParameter(), d, em) == 1.0 +end From 24b1dcae761a57d63177b232e6dc76c0bee7ee52 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 16:02:03 -0700 Subject: [PATCH 04/19] Attach outage-event models to problem templates Add PowerOperationsProblemTemplate.events, set_event_model!/get_event_models so a template can hold EventModels ahead of build-time distribution to DeviceModels. --- src/PowerOperationsModels.jl | 1 + src/core/problem_template.jl | 25 +++++++++++++++++++++++++ test/test_events.jl | 11 +++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 53f2c814..a935629f 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -422,6 +422,7 @@ export get_event_type export get_event_condition export get_attribute_device_map export set_event_model! +export get_event_models export supports_events export AvailableStatusParameter export ActivePowerOffsetParameter diff --git a/src/core/problem_template.jl b/src/core/problem_template.jl index df9602bc..db9a76c3 100644 --- a/src/core/problem_template.jl +++ b/src/core/problem_template.jl @@ -16,6 +16,7 @@ mutable struct PowerOperationsProblemTemplate <: IOM.AbstractProblemTemplate devices::DevicesModelContainer branches::BranchModelContainer services::ServicesModelContainer + events::Vector{IOM.AbstractEventModel} function PowerOperationsProblemTemplate( network::NetworkModel{T}, ) where {T <: AbstractNetworkModel} @@ -24,6 +25,7 @@ mutable struct PowerOperationsProblemTemplate <: IOM.AbstractProblemTemplate DevicesModelContainer(), BranchModelContainer(), ServicesModelContainer(), + Vector{IOM.AbstractEventModel}(), ) end end @@ -54,6 +56,11 @@ get_network_formulation(template::PowerOperationsProblemTemplate) = get_hvdc_network_model(template::PowerOperationsProblemTemplate) = template.network_model.hvdc_network_model +""" +Return the outage-event models attached to `template` via `set_event_model!`. +""" +get_event_models(template::PowerOperationsProblemTemplate) = template.events + # Returns `Vector{Type}`, not `Vector{DataType}`: a service component type can be a # UnionAll (e.g. PSY6 parameterized `ReserveDemandCurve{ReserveUp}` on a unit-system # type, leaving a trailing free parameter), which is not a `DataType`. @@ -162,6 +169,24 @@ function set_device_model!( return end +""" + set_event_model!(template::PowerOperationsProblemTemplate, event_model) + +Attach an outage-event model to the template. At build time the event is validated, +its `attribute_device_map` is populated from the system's supplemental attributes, and +it is distributed to every matching `DeviceModel`. +""" +function IOM.set_event_model!( + template::PowerOperationsProblemTemplate, + event_model::IOM.AbstractEventModel, +) + if any(e -> e === event_model, template.events) + error("This event model is already attached to the template") + end + push!(template.events, event_model) + return +end + """ Sets the service model in a template using a name and the service type and formulation. Builds a default ServiceModel with use_service_name set to true. diff --git a/test/test_events.jl b/test/test_events.jl index fdc067bd..87d4c98b 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -40,3 +40,14 @@ end @test POM.get_initial_parameter_value(ReactivePowerOffsetParameter(), d, em) == 0.0 @test POM.get_parameter_multiplier(AvailableStatusParameter(), d, em) == 1.0 end + +@testset "Template-level event attachment" begin + template = PowerOperationsProblemTemplate(CopperPlateNetworkModel) + @test isempty(get_event_models(template)) + em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + set_event_model!(template, em) + @test length(get_event_models(template)) == 1 + @test get_event_models(template)[1] === em + # Same event model instance can't be attached twice + @test_throws ErrorException set_event_model!(template, em) +end From 0bd7d2c58792bb45aee58f611ab044aeb14829fe Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 16:31:02 -0700 Subject: [PATCH 05/19] Add build-time discovery and validation for outage-event models Populate EventModel.attribute_device_map from the system's supplemental attributes during template validation, validate each event's time-series mapping, and distribute the event to every DeviceModel whose device type carries the attribute. Preserve event-model identity across the template deep copy performed at DecisionModel construction (mirroring the existing PNM-matrix-sharing trick) so callers can inspect discovery results on the same EventModel object they attached to the template. Also fix a name collision from the earlier event-model work: POM's get_value(::StateVariableValueCondition) was defined without qualifying IOM.get_value, which created a separate local generic function that shadowed IOM's get_value(::InitialCondition) for every other unqualified caller in the package (storage, hybrid, thermal generation, AGC), silently breaking all initial-conditions-consuming builds. --- src/core/problem_template.jl | 21 ++++++ src/event_models/event_model.jl | 7 +- src/operation/template_validation.jl | 102 +++++++++++++++++++++++++++ test/includes.jl | 1 + test/test_events.jl | 102 +++++++++++++++++++++++++++ test/test_utils/events_test_utils.jl | 25 +++++++ 6 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 test/test_utils/events_test_utils.jl diff --git a/src/core/problem_template.jl b/src/core/problem_template.jl index db9a76c3..e8b68dc9 100644 --- a/src/core/problem_template.jl +++ b/src/core/problem_template.jl @@ -187,6 +187,27 @@ function IOM.set_event_model!( return end +# `IOM._deepcopy_template` already shares the network model's PNM matrices by reference +# across the template/copy boundary because deep-copying them throws (PNM #312). Event +# models need the same treatment for a different reason: build-time discovery +# (`_build_device_model_events!`) mutates `EventModel.attribute_device_map`, and callers +# inspect that mutation on the exact object they passed to `set_event_model!`. A plain +# `deepcopy` of the template would clone each event model, so the mutation performed on +# the copy used to build the model would be invisible on the caller's original object. +# Null the field before delegating to the generic (PNM-matrix-aware) implementation, then +# restore identity on both sides so discovery writes land on the caller's own objects. +function IOM._deepcopy_template(template::PowerOperationsProblemTemplate) + events = template.events + template.events = IOM.AbstractEventModel[] + template_ = try + invoke(IOM._deepcopy_template, Tuple{IOM.AbstractProblemTemplate}, template) + finally + template.events = events + end + template_.events = copy(events) + return template_ +end + """ Sets the service model in a template using a name and the service type and formulation. Builds a default ServiceModel with use_service_name set to true. diff --git a/src/event_models/event_model.jl b/src/event_models/event_model.jl index 37441927..60fe6b56 100644 --- a/src/event_models/event_model.jl +++ b/src/event_models/event_model.jl @@ -68,7 +68,12 @@ 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 +# Qualified: `get_value` is IOM's generic (`get_value(::InitialCondition)`); a bare +# definition here would silently create a separate local `get_value` in POM's namespace +# (since it was only ever `using`'d, not `import`ed) and shadow IOM's method for every +# other unqualified `get_value(ic)` call across the package (storage, hybrid, thermal +# generation, AGC initial-condition consumers). +IOM.get_value(c::StateVariableValueCondition) = c.value """ DiscreteEventCondition(condition_function::Function) diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index e5711943..2c1c1cfd 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -114,6 +114,7 @@ function validate_template_impl!(model::IOM.AbstractOptimizationModel) _check_branch_rating_time_series_formulation!(template.branches, system) validate_network_model(network_model, unmodeled_branch_types, model_has_branch_filters) _build_device_model_outages!(template, system) + _build_device_model_events!(template, system) return end @@ -654,3 +655,104 @@ function _warn_unmatched_user_outages( end return end + +################################################################################# +# Outage-event discovery and validation (time-series outage events; distinct +# from the security-constrained `_build_device_model_outages!` above) +################################################################################# + +""" +For each event model attached to the template: validate its time-series mapping, +populate `attribute_device_map` (attribute UUID → concrete device type → device names) +from the system's supplemental attributes, and distribute the event model to every +`DeviceModel` in the template whose device type carries the attribute and supports +events. +""" +function _build_device_model_events!( + template::PowerOperationsProblemTemplate, + sys::PSY.System, +) + for event_model in get_event_models(template) + event_type = get_event_type(event_model) + if isempty(PSY.get_supplemental_attributes(event_type, sys)) + error( + "There are no supplemental attributes of type $event_type in the system. \ + Add the outage data to the system or remove the event model from the \ + template.", + ) + end + for event in PSY.get_supplemental_attributes(event_type, sys) + _validate_event_timeseries_data(sys, event, event_model) + event_uuid = IS.get_uuid(event) + attribute_device_map = get_attribute_device_map(event_model) + attribute_device_map[event_uuid] = Dict{DataType, Set{String}}() + device_types_with_attribute = Set{DataType}() + for device in PSY.get_associated_components(sys, event) + dtype = typeof(device) + if !supports_events(dtype) + @warn "Device $(PSY.get_name(device)) of type $dtype carries a \ + $event_type attribute but the type does not support events; \ + it will not be modeled." _group = + IOM.LOG_GROUP_MODELS_VALIDATION + continue + end + push!(device_types_with_attribute, dtype) + name_set = get!( + attribute_device_map[event_uuid], + dtype, + Set{String}(), + ) + push!(name_set, PSY.get_name(device)) + end + for device_type in device_types_with_attribute + device_model = get_model(template, device_type) + if device_model === nothing + @warn "Devices of type $device_type carry a $event_type attribute \ + but the template has no DeviceModel for that type; the event \ + will not be modeled for them." _group = + IOM.LOG_GROUP_MODELS_VALIDATION + continue + end + key = EventKey(event_type, device_type) + if !haskey(IOM.get_events(device_model), key) + IOM.set_event_model!(device_model, key, event_model) + end + end + end + end + return +end + +function _validate_event_timeseries_data( + sys::PSY.System, + event::PSY.Contingency, + event_model::EventModel, +) + for (k, v) in event_model.timeseries_mapping + if !isnothing(v) + try + PSY.get_time_series(IS.SingleTimeSeries, event, v) + catch + device_names = + PSY.get_name.(PSY.get_associated_components(sys, event)) + error( + "Event $event belonging to devices $device_names is missing a \ + time series with name $v", + ) + end + end + if !haskey(get_empty_timeseries_mapping(typeof(event)), k) + error( + "Key $k passed as part of the event time series mapping does not \ + correspond to a parameter.", + ) + end + if k == :outage_status && isnothing(v) + error( + "FixedForcedOutage requires a timeseries mapping for the \ + :outage_status parameter", + ) + end + end + return +end diff --git a/test/includes.jl b/test/includes.jl index 77165035..cab34ce0 100644 --- a/test/includes.jl +++ b/test/includes.jl @@ -58,6 +58,7 @@ include("test_utils/mbc_math_helpers.jl") include("test_utils/iec_test_systems.jl") include("test_utils/hydro_testing_utils.jl") include("test_utils/hybrid_test_utils.jl") +include("test_utils/events_test_utils.jl") ENV["RUNNING_SIENNA_TESTS"] = "true" ENV["SIENNA_RANDOM_SEED"] = 1234 # Set a fixed seed for reproducibility in tests diff --git a/test/test_events.jl b/test/test_events.jl index 87d4c98b..3c7193aa 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -51,3 +51,105 @@ end # Same event model instance can't be attached twice @test_throws ErrorException set_event_model!(template, em) end + +@testset "Event discovery and validation at build" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) + outage = attach_fixed_forced_outage!(sys, thermal) + + template = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + set_event_model!(template, em) + + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + + # Discovery populated the map: attribute uuid -> device type -> names + map_ = get_attribute_device_map(em) + uuid = IS.get_uuid(outage) + @test haskey(map_, uuid) + @test map_[uuid][PSY.ThermalStandard] == Set([PSY.get_name(thermal)]) + + # The caller's template DeviceModels were not mutated (build-copy isolation) + caller_dm = get_model(template, PSY.ThermalStandard) + @test isempty(IOM.get_events(caller_dm)) +end + +@testset "Event validation errors" begin + sys_clean = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + template = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + set_event_model!(template, em) + model = DecisionModel(template, sys_clean; optimizer = HiGHS_optimizer) + # No supplemental attributes in the system -> loud build failure + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED + + # Unknown mapping key rejected + sys2 = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal2 = first(PSY.get_components(PSY.ThermalStandard, sys2)) + attach_fixed_forced_outage!(sys2, thermal2) + template2 = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em_bad = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :not_a_parameter => "outage_profile", + ), + ) + set_event_model!(template2, em_bad) + model2 = DecisionModel(template2, sys2; optimizer = HiGHS_optimizer) + @test build!(model2; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED + + # FixedForcedOutage requires :outage_status mapping + sys3 = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal3 = first(PSY.get_components(PSY.ThermalStandard, sys3)) + attach_fixed_forced_outage!(sys3, thermal3) + template3 = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em_nomapping = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + set_event_model!(template3, em_nomapping) + model3 = DecisionModel(template3, sys3; optimizer = HiGHS_optimizer) + @test build!(model3; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED +end + +@testset "Events excluded from initialization problem" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) + attach_fixed_forced_outage!(sys, thermal) + template = get_thermal_standard_uc_template() + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + set_event_model!(template, em) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + # `build!` discards the initial-conditions container once it is solved and + # serialized (see `handle_initial_conditions!`), so inspect it directly by + # replicating the pre-solve portion of the build pipeline instead of going + # through the full `build!`/`solve!` round trip. + POM.build_pre_step!(model) + IOM.instantiate_network_model!(model) + POM.build_initial_conditions!(model) + ic_container = IOM.get_initial_conditions_model_container(IOM.get_internal(model)) + @test ic_container !== nothing + ic_keys = IOM.get_parameter_keys(ic_container) + @test !any(k -> IOM.get_entry_type(k) <: EventParameter, ic_keys) +end diff --git a/test/test_utils/events_test_utils.jl b/test/test_utils/events_test_utils.jl new file mode 100644 index 00000000..249b7207 --- /dev/null +++ b/test/test_utils/events_test_utils.jl @@ -0,0 +1,25 @@ +# Attaches a FixedForcedOutage supplemental attribute to `device` and a 0/1 +# SingleTimeSeries named `ts_name` to the attribute. Returns the attribute. +# Adapted from PSI test/test_utils/events_simulation_utils.jl (build-relevant part only). +function attach_fixed_forced_outage!( + sys::PSY.System, + device::PSY.Device; + ts_name = "outage_profile", + outage_profile = nothing, +) + outage = PSY.FixedForcedOutage(; outage_status = 0.0) + PSY.add_supplemental_attribute!(sys, device, outage) + resolution = first(PSY.get_time_series_resolutions(sys)) + initial_time = PSY.get_forecast_initial_timestamp(sys) + horizon_count = Int(PSY.get_forecast_horizon(sys) / resolution) + if isnothing(outage_profile) + outage_profile = zeros(horizon_count) # 0 = available for the whole horizon + end + ts_data = TimeSeries.TimeArray( + range(initial_time; length = length(outage_profile), step = resolution), + outage_profile, + ) + ts = PSY.SingleTimeSeries(; name = ts_name, data = ts_data) + PSY.add_time_series!(sys, outage, ts) + return outage +end From e19fb6c924650a93bc13c144eb5e63ad1e4d1332 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 16:55:16 -0700 Subject: [PATCH 06/19] Address round-1 review: loud error on event-model key conflicts Two distinct event models of the same contingency type discovering the same device type can't both be registered under the device model's single (contingency type, device type) events slot. Replace the silent-skip guard in _build_device_model_events! with a loud error when the existing registration belongs to a different event model than the one being processed; re-discovering the same event model for the same key stays a no-op. Add a covering test case. Also reword the _deepcopy_template override comment to describe the deepcopy-unsafe solver-cache behavior directly instead of citing a PR number. --- src/core/problem_template.jl | 5 +++-- src/operation/template_validation.jl | 19 +++++++++++++++++-- test/test_events.jl | 27 +++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/core/problem_template.jl b/src/core/problem_template.jl index e8b68dc9..261eb72e 100644 --- a/src/core/problem_template.jl +++ b/src/core/problem_template.jl @@ -188,8 +188,9 @@ function IOM.set_event_model!( end # `IOM._deepcopy_template` already shares the network model's PNM matrices by reference -# across the template/copy boundary because deep-copying them throws (PNM #312). Event -# models need the same treatment for a different reason: build-time discovery +# across the template/copy boundary because their solver caches hold raw factorization +# handles that error on deepcopy; the matrices are read-only inputs, so sharing them is +# safe. Event models need the same treatment for a different reason: build-time discovery # (`_build_device_model_events!`) mutates `EventModel.attribute_device_map`, and callers # inspect that mutation on the exact object they passed to `set_event_model!`. A plain # `deepcopy` of the template would clone each event model, so the mutation performed on diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 2c1c1cfd..3de417da 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -714,9 +714,24 @@ function _build_device_model_events!( continue end key = EventKey(event_type, device_type) - if !haskey(IOM.get_events(device_model), key) - IOM.set_event_model!(device_model, key, event_model) + existing_events = IOM.get_events(device_model) + if haskey(existing_events, key) + # The same event model can legitimately be discovered again for this + # device type (e.g. a second outage attribute of the same contingency + # type attached to another device of the same type); re-registering it + # is a no-op. A *different* event model targeting the same + # (contingency type, device type) pair can't both be honored — the + # device model has one slot per key — so that case must fail loudly + # instead of silently dropping the second registration. + existing_events[key] === event_model && continue + error( + "Two distinct event models of contingency type $event_type both \ + target device type $device_type. Only one event model per \ + (contingency type, device type) pair is supported. Merge the \ + event models or remove one from the template.", + ) end + IOM.set_event_model!(device_model, key, event_model) end end end diff --git a/test/test_events.jl b/test/test_events.jl index 3c7193aa..9d0872f7 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -125,6 +125,33 @@ end model3 = DecisionModel(template3, sys3; optimizer = HiGHS_optimizer) @test build!(model3; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.FAILED + + # Two distinct event models of the same contingency type both discovering the same + # device type is a conflict: a DeviceModel has one events slot per (contingency + # type, device type) key, so the second registration can't be silently dropped. + sys4 = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal4 = first(PSY.get_components(PSY.ThermalStandard, sys4)) + attach_fixed_forced_outage!(sys4, thermal4) + template4 = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em4a = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + em4b = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + set_event_model!(template4, em4a) + set_event_model!(template4, em4b) + model4 = DecisionModel(template4, sys4; optimizer = HiGHS_optimizer) + @test build!(model4; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED end @testset "Events excluded from initialization problem" begin From cc9470d742e079dc4c34040802d0af2a04fe0e4c Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 17:10:45 -0700 Subject: [PATCH 07/19] Add event parameter arguments and balance-expression injection Wires DeviceModel.events into the ArgumentConstructStage: add_parameters! creates AvailableStatusParameter and AvailableStatusChangeCountdownParameter containers for devices carrying a matching supplemental attribute, and a generic add_to_expression! offsets those parameters into the system balance via _balance_expression_targets, replacing PSI's four per-network methods with one. add_event_arguments! now overrides the no-op stub for PSY.StaticInjection devices. --- src/PowerOperationsModels.jl | 2 + src/event_models/event_arguments.jl | 130 +++++++++++++++++++++++ test/test_events.jl | 22 ++++ test/test_utils/mock_operation_models.jl | 11 +- 4 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 src/event_models/event_arguments.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index a935629f..4bc45db9 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -271,6 +271,8 @@ include("common_models/converter_control.jl") # before device-specific files that reference MBC_TYPES / IEC_TYPES. include("common_models/market_bid_plumbing.jl") +include("event_models/event_arguments.jl") + # Initial Conditions include("initial_conditions/add_initial_condition.jl") include("initial_conditions/device_initial_conditions.jl") diff --git a/src/event_models/event_arguments.jl b/src/event_models/event_arguments.jl new file mode 100644 index 00000000..1d31c262 --- /dev/null +++ b/src/event_models/event_arguments.jl @@ -0,0 +1,130 @@ +################################################################################# +# Event parameter creation (ArgumentConstructStage) +################################################################################# + +function add_parameters!( + container::OptimizationContainer, + ::Type{T}, + devices::U, + device_model::DeviceModel{D, W}, + event_model::EventModel{V, X}, +) where { + T <: ParameterType, + U <: Vector{D}, + V <: PSY.Contingency, + W <: AbstractDeviceFormulation, + X <: AbstractEventCondition, +} where {D <: PSY.Component} + if get_rebuild_model(get_settings(container)) && has_container_key(container, T, D) + return + end + _add_parameters!(container, T(), devices, device_model, event_model) + return +end + +function _add_parameters!( + container::OptimizationContainer, + ::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) + parameter_container = add_param_container!( + container, + T, + U, + V, + PSY.get_name.(devices), + 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 + +################################################################################# +# Offset parameters into the system balance expressions. +# One method for every network family: `_balance_expression_targets` resolves the +# system/area/nodal targets per network model (this replaces PSI's four +# per-network methods). +################################################################################# + +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + device_model::DeviceModel{V, W}, + network_model::NetworkModel{X}, +) where { + T <: SystemBalanceExpressions, + U <: EventParameter, + V <: PSY.StaticInjection, + W <: AbstractDeviceFormulation, + X <: AbstractNetworkModel, +} + param_array = get_parameter_array(container, U(), V) + multiplier = get_parameter_multiplier_array(container, U(), V) + time_steps = get_time_steps(container) + for d in devices + targets = _balance_expression_targets(container, T, network_model, d) + name = PSY.get_name(d) + for t in time_steps + _apply_term_to_targets!(targets, param_array[name, t], multiplier[name, t], t) + end + end + return +end + +################################################################################# +# add_event_arguments! — overrides the no-op stub in core/feedforward_interface.jl +# for the injector families. No-ops when the DeviceModel has no events attached. +################################################################################# + +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 diff --git a/test/test_events.jl b/test/test_events.jl index 9d0872f7..50be3d42 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -180,3 +180,25 @@ end ic_keys = IOM.get_parameter_keys(ic_container) @test !any(k -> IOM.get_entry_type(k) <: EventParameter, ic_keys) end + +@testset "Event parameters via mock construct - ThermalStandard UC" begin + device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) + sys = PSB.build_system(PSITestSystems, "c_sys5_uc") + model = DecisionModel(MockOperationProblem, DCPNetworkModel, sys) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + @test !isnothing( + IOM.get_parameter(container, AvailableStatusParameter, PSY.ThermalStandard), + ) + @test !isnothing( + IOM.get_parameter( + container, + AvailableStatusChangeCountdownParameter, + PSY.ThermalStandard, + ), + ) + param_array = + IOM.get_parameter_array(container, AvailableStatusParameter(), PSY.ThermalStandard) + # Initial availability is 1.0 for every (device, t) + @test all(IOM.jump_value.(param_array.data) .== 1.0) +end diff --git a/test/test_utils/mock_operation_models.jl b/test/test_utils/mock_operation_models.jl index 8b6380cb..0ea8b265 100644 --- a/test/test_utils/mock_operation_models.jl +++ b/test/test_utils/mock_operation_models.jl @@ -120,9 +120,14 @@ 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.", - ) + sys = IOM.get_system(problem) + device_type = IOM.get_component_type(model) + event_device = first(PSY.get_components(device_type, sys)) + transition_data = PSY.FixedForcedOutage(; outage_status = 0.0) + PSY.add_supplemental_attribute!(sys, event_device, transition_data) + mock_event_key = EventKey(PSY.FixedForcedOutage, device_type) + mock_event_model = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + set_event_model!(model, mock_event_key, mock_event_model) end mock_construct_devices!( problem, From 2fb7f8efa78f00395e6d2b10da7312324bc505cf Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 17:24:15 -0700 Subject: [PATCH 08/19] Add load and FixedOutput event offset-parameter arguments Loads (StaticPowerLoad/PowerLoadDispatch/PowerLoadInterruption) and FixedOutput devices now inject an ActivePowerOffsetParameter (and ReactivePowerOffsetParameter on reactive-capable networks) directly into the system balance expression when an event is attached, on top of the generic status/countdown parameters from the StaticInjection default. --- src/event_models/event_arguments.jl | 168 ++++++++++++++++++++++++++++ test/test_events.jl | 26 +++++ 2 files changed, 194 insertions(+) diff --git a/src/event_models/event_arguments.jl b/src/event_models/event_arguments.jl index 1d31c262..e9ec7ed6 100644 --- a/src/event_models/event_arguments.jl +++ b/src/event_models/event_arguments.jl @@ -128,3 +128,171 @@ function add_event_arguments!( end return end + +################################################################################# +# Load and FixedOutput argument variants: on top of the generic status/countdown +# parameters above, these devices also get an offset parameter injected directly +# into the system balance expression(s), so an outage event can remove/restore +# a device's contribution without touching its dispatch variables. +################################################################################# + +const _EventLoadFormulations = + Union{StaticPowerLoad, PowerLoadDispatch, PowerLoadInterruption} + +function _add_event_offset_arguments!( + container::OptimizationContainer, + devices_with_attributes::Vector{U}, + device_model::DeviceModel, + network_model::NetworkModel, + event_model::EventModel, + with_reactive::Bool, +) where {U <: PSY.StaticInjection} + 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, + ) + if with_reactive + 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, V}, + network_model::NetworkModel{<:AbstractActivePowerModel}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: _EventLoadFormulations, +} 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") + _add_event_offset_arguments!( + container, + devices_with_attributes, + device_model, + network_model, + event_model, + false, + ) + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{<:AbstractReactivePowerNetworkModel}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: _EventLoadFormulations, +} 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") + _add_event_offset_arguments!( + container, + devices_with_attributes, + device_model, + network_model, + event_model, + true, + ) + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, FixedOutput}, + network_model::NetworkModel{<: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") + _add_event_offset_arguments!( + container, + devices_with_attributes, + device_model, + network_model, + event_model, + false, + ) + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, FixedOutput}, + network_model::NetworkModel{<:AbstractReactivePowerNetworkModel}, +) 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") + _add_event_offset_arguments!( + container, + devices_with_attributes, + device_model, + network_model, + event_model, + true, + ) + end + return +end diff --git a/test/test_events.jl b/test/test_events.jl index 50be3d42..8eac9418 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -202,3 +202,29 @@ end # Initial availability is 1.0 for every (device, t) @test all(IOM.jump_value.(param_array.data) .== 1.0) end + +@testset "Event arguments for loads add offset parameters" begin + device_model = DeviceModel(PSY.PowerLoad, StaticPowerLoad) + sys = PSB.build_system(PSITestSystems, "c_sys5_uc") + model = DecisionModel(MockOperationProblem, CopperPlateNetworkModel, sys) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + @test !isnothing( + IOM.get_parameter(container, ActivePowerOffsetParameter, PSY.PowerLoad), + ) + # AvailableStatus/Countdown params exist too. + @test !isnothing( + IOM.get_parameter(container, AvailableStatusParameter, PSY.PowerLoad), + ) + @test !isnothing( + IOM.get_parameter( + container, + AvailableStatusChangeCountdownParameter, + PSY.PowerLoad, + ), + ) + # CopperPlate mock network -> the offset parameter's term lands in the system-level + # active power balance expression (single target: the reference-bus row). + system_balance = IOM.get_expression(container, ActivePowerBalance, PSY.System) + @test !isnothing(system_balance) +end From c6d61e85391d66e004dcf3581c4e627dfbf0c377 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 18:23:47 -0700 Subject: [PATCH 09/19] Add core event outage constraints for thermal, renewable, and load devices Implements add_event_constraints! for ThermalGen/RenewableGen/ElectricLoad across active-only and reactive-capable networks, bounding dispatch by available capacity during an outage event and adding a quadratic reactive power bound on reactive-capable networks. --- src/PowerOperationsModels.jl | 3 + src/core/constraints.jl | 18 ++ src/event_models/event_constraints.jl | 288 ++++++++++++++++++++++++++ test/test_events.jl | 68 ++++++ 4 files changed, 377 insertions(+) create mode 100644 src/event_models/event_constraints.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 4bc45db9..8afb2025 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -272,6 +272,7 @@ include("common_models/converter_control.jl") include("common_models/market_bid_plumbing.jl") include("event_models/event_arguments.jl") +include("event_models/event_constraints.jl") # Initial Conditions include("initial_conditions/add_initial_condition.jl") @@ -430,6 +431,8 @@ export AvailableStatusParameter export ActivePowerOffsetParameter export ReactivePowerOffsetParameter export AvailableStatusChangeCountdownParameter +export ActivePowerOutageConstraint +export ReactivePowerOutageConstraint # Initial Conditions Quantities export DevicePower diff --git a/src/core/constraints.jl b/src/core/constraints.jl index 14e6a203..52b98a3a 100644 --- a/src/core/constraints.jl +++ b/src/core/constraints.jl @@ -629,7 +629,25 @@ struct ImportExportBudgetConstraint <: ConstraintType end struct LineFlowBoundConstraint <: ConstraintType end abstract type EventConstraint <: ConstraintType end + +""" +Struct to create the constraint that bounds a device's active power expression by +its available capacity during an outage event. + +```math +p_t \\le P^\\text{max} \\cdot \\text{status}_t, \\quad \\forall t \\in \\{1,\\dots,T\\} +``` +""" struct ActivePowerOutageConstraint <: EventConstraint end + +""" +Struct to create the constraint that bounds a device's reactive power by its +available capacity squared during an outage event. + +```math +q_t^2 \\le Q^\\text{max} \\cdot \\text{status}_t, \\quad \\forall t \\in \\{1,\\dots,T\\} +``` +""" struct ReactivePowerOutageConstraint <: EventConstraint end ############################################################ diff --git a/src/event_models/event_constraints.jl b/src/event_models/event_constraints.jl new file mode 100644 index 00000000..42cd3863 --- /dev/null +++ b/src/event_models/event_constraints.jl @@ -0,0 +1,288 @@ +################################################################################# +# Event outage constraints (ModelConstructStage). Overrides the no-op stub in +# core/feedforward_interface.jl for the supported injector families. +################################################################################# + +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 <: 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 <: AbstractReactivePowerNetworkModel, +} 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 <: 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 <: AbstractReactivePowerNetworkModel, +} 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 <: 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 <: AbstractReactivePowerNetworkModel, +} 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 + +################################################################################# +# Quadratic reactive-power outage constraint: q^2 <= ub * status +################################################################################# + +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 <: AbstractReactivePowerNetworkModel, +} + 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, + param::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, param, 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 + +_get_reactive_power_upper_bound(device::PSY.StaticInjection) = begin + limits = PSY.get_reactive_power_limits(device, PSY.SU) + max(limits.max^2, limits.min^2) +end + +_get_reactive_power_upper_bound(device::PSY.ElectricLoad) = + PSY.get_max_reactive_power(device, PSY.SU)^2 diff --git a/test/test_events.jl b/test/test_events.jl index 8eac9418..e11eb34b 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -228,3 +228,71 @@ end system_balance = IOM.get_expression(container, ActivePowerBalance, PSY.System) @test !isnothing(system_balance) end + +@testset "Event constraints - thermal UC counts and coefficients" begin + device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) + sys = PSB.build_system(PSITestSystems, "c_sys5_uc") + model = DecisionModel(MockOperationProblem, DCPNetworkModel, sys) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + # add_parameterized_upper_bound_range_constraints stores its constraint under + # meta = "ub" (constraint_meta(UpperBound())). + cons = IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + PSY.ThermalStandard, + "ub", + ) + n_thermal_with_event = 1 # mock attaches the outage to exactly one device + time_steps = IOM.get_time_steps(container) + @test size(cons)[1] == n_thermal_with_event + @test size(cons)[2] == length(time_steps) + # Coefficient check: constraint is expr(p) - ub * status <= 0 with status = 1.0 + # (params are plain Float64 in a non-recurrent build, so the RHS is baked in). + c1 = JuMP.constraint_object(cons[axes(cons)[1][1], 1]) + @test c1.set isa MOI.LessThan{Float64} +end + +@testset "Event constraints - renewable counts on ActivePowerVariable" begin + device_model = DeviceModel(PSY.RenewableDispatch, RenewableFullDispatch) + sys = PSB.build_system(PSITestSystems, "c_sys5_re") + model = DecisionModel(MockOperationProblem, DCPNetworkModel, sys) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + # No service model attached -> lhs_type falls back to ActivePowerVariable. + cons = IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + PSY.RenewableDispatch, + "ub", + ) + n_renewable_with_event = 1 # mock attaches the outage to exactly one device + time_steps = IOM.get_time_steps(container) + @test size(cons)[1] == n_renewable_with_event + @test size(cons)[2] == length(time_steps) + c1 = JuMP.constraint_object(cons[axes(cons)[1][1], 1]) + @test c1.set isa MOI.LessThan{Float64} +end + +@testset "Event constraints - load counts on ActivePowerVariable" begin + # PowerLoadDispatch is a controllable-load formulation: applying it to a plain + # PSY.PowerLoad silently swaps to StaticPowerLoad (no ActivePowerVariable), so + # use InterruptiblePowerLoad + c_sys5_il, matching the constructor test fixture. + device_model = DeviceModel(PSY.InterruptiblePowerLoad, PowerLoadDispatch) + sys = PSB.build_system(PSITestSystems, "c_sys5_il") + model = DecisionModel(MockOperationProblem, DCPNetworkModel, sys) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + cons = IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + PSY.InterruptiblePowerLoad, + "ub", + ) + n_load_with_event = 1 # mock attaches the outage to exactly one device + time_steps = IOM.get_time_steps(container) + @test size(cons)[1] == n_load_with_event + @test size(cons)[2] == length(time_steps) + c1 = JuMP.constraint_object(cons[axes(cons)[1][1], 1]) + @test c1.set isa MOI.LessThan{Float64} +end From f25d729dbb1e7ed5620bb7e63a67060bc2479afe Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 18:49:18 -0700 Subject: [PATCH 10/19] Add hydro and storage event outage constraints Ports HydroGen/HydroPumpTurbine/EnergyReservoirStorage add_event_constraints! methods and their pump/input-output contingency-constraint helpers, plus fixes a get_variable/add_constraints_container! instance-vs-type bug in the shared reactive-power contingency helper (never previously exercised by any test). --- src/PowerOperationsModels.jl | 1 + src/event_models/event_constraints.jl | 288 +++++++++++++++++++++++++- test/test_events.jl | 71 +++++++ 3 files changed, 358 insertions(+), 2 deletions(-) diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 8afb2025..f3d9f83e 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -433,6 +433,7 @@ export ReactivePowerOffsetParameter export AvailableStatusChangeCountdownParameter export ActivePowerOutageConstraint export ReactivePowerOutageConstraint +export ActivePowerPumpOutageConstraint # Initial Conditions Quantities export DevicePower diff --git a/src/event_models/event_constraints.jl b/src/event_models/event_constraints.jl index 42cd3863..2270888b 100644 --- a/src/event_models/event_constraints.jl +++ b/src/event_models/event_constraints.jl @@ -233,7 +233,7 @@ function add_reactive_power_contingency_constraint( W <: AbstractDeviceFormulation, X <: AbstractReactivePowerNetworkModel, } - array_reactive = get_variable(container, ReactivePowerVariable(), V) + array_reactive = get_variable(container, ReactivePowerVariable, V) _add_reactive_power_contingency_constraint_impl!( container, ReactivePowerOutageConstraint, @@ -260,7 +260,7 @@ function _add_reactive_power_contingency_constraint_impl!( names = PSY.get_name.(devices) constraint_container = add_constraints_container!( container, - ReactivePowerOutageConstraint(), + ReactivePowerOutageConstraint, V, names, time_steps; @@ -286,3 +286,287 @@ end _get_reactive_power_upper_bound(device::PSY.ElectricLoad) = PSY.get_max_reactive_power(device, PSY.SU)^2 + +################################################################################# +# Hydro (ported from HydroPowerSimulations src/contingency_model.jl) +################################################################################# + +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 <: AbstractActivePowerModel, +} where {U <: PSY.HydroGen} + 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 <: AbstractReactivePowerNetworkModel, +} where {U <: PSY.HydroGen} + 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 <: AbstractActivePowerModel, +} where {U <: PSY.HydroPumpTurbine} + 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_pump_turbine_active_power_contingency_constraints!( + container, + devices_with_attributes, + device_model, + ) + 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 <: AbstractReactivePowerNetworkModel, +} where {U <: PSY.HydroPumpTurbine} + 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_pump_turbine_active_power_contingency_constraints!( + container, + devices_with_attributes, + device_model, + ) + add_reactive_power_contingency_constraint( + container, + ReactivePowerOutageConstraint, + ReactivePowerVariable, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_pump_turbine_active_power_contingency_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, +} where {U <: PSY.HydroPumpTurbine} + names = PSY.get_name.(devices) + time_steps = get_time_steps(container) + array_active_power = get_variable(container, ActivePowerVariable, U) + array_active_power_pump = get_variable(container, ActivePowerPumpVariable, U) + constraint_active_power = add_constraints_container!( + container, + ActivePowerOutageConstraint, + U, + names, + time_steps, + ) + constraint_active_power_pump = add_constraints_container!( + container, + ActivePowerPumpOutageConstraint, + U, + names, + time_steps, + ) + param_array = get_parameter_array(container, AvailableStatusParameter(), U) + jump_model = get_jump_model(container) + for device in devices, t in time_steps + name = PSY.get_name(device) + ub_active_power = PSY.get_active_power_limits(device, PSY.SU).max + constraint_active_power[name, t] = JuMP.@constraint( + jump_model, + array_active_power[name, t] <= ub_active_power * param_array[name, t] + ) + ub_active_power_pump = PSY.get_active_power_limits_pump(device, PSY.SU).max + constraint_active_power_pump[name, t] = JuMP.@constraint( + jump_model, + array_active_power_pump[name, t] <= + ub_active_power_pump * param_array[name, t] + ) + end + return +end + +################################################################################# +# Storage (ported from StorageSystemsSimulations src/contingency_model.jl) +################################################################################# + +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 <: AbstractActivePowerModel, +} where {U <: PSY.EnergyReservoirStorage} + 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_input_output_active_power_contingency_constraints!( + container, + devices_with_attributes, + device_model, + ) + 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 <: AbstractReactivePowerNetworkModel, +} where {U <: PSY.EnergyReservoirStorage} + 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_input_output_active_power_contingency_constraints!( + container, + devices_with_attributes, + device_model, + ) + add_reactive_power_contingency_constraint( + container, + ReactivePowerOutageConstraint, + ReactivePowerVariable, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_input_output_active_power_contingency_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, +} where {U <: PSY.EnergyReservoirStorage} + names = PSY.get_name.(devices) + time_steps = get_time_steps(container) + array_in = get_variable(container, ActivePowerInVariable, U) + array_out = get_variable(container, ActivePowerOutVariable, U) + constraint_input = add_constraints_container!( + container, + ActivePowerOutageConstraint, + U, + names, + time_steps; + meta = "input", + ) + constraint_output = add_constraints_container!( + container, + ActivePowerOutageConstraint, + U, + names, + time_steps; + meta = "output", + ) + param_array = get_parameter_array(container, AvailableStatusParameter(), U) + jump_model = get_jump_model(container) + for device in devices, t in time_steps + name = PSY.get_name(device) + ub_input = PSY.get_input_active_power_limits(device, PSY.SU).max + constraint_input[name, t] = JuMP.@constraint( + jump_model, + array_in[name, t] <= ub_input * param_array[name, t] + ) + ub_output = PSY.get_output_active_power_limits(device, PSY.SU).max + constraint_output[name, t] = JuMP.@constraint( + jump_model, + array_out[name, t] <= ub_output * param_array[name, t] + ) + end + return +end diff --git a/test/test_events.jl b/test/test_events.jl index e11eb34b..e95233e1 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -296,3 +296,74 @@ end c1 = JuMP.constraint_object(cons[axes(cons)[1][1], 1]) @test c1.set isa MOI.LessThan{Float64} end + +@testset "Event constraints - hydro" begin + device_model = DeviceModel(PSY.HydroDispatch, HydroDispatchRunOfRiver) + sys = PSB.build_system(PSITestSystems, "c_sys5_hy") + model = DecisionModel(MockOperationProblem, DCPNetworkModel, sys) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + # add_parameterized_upper_bound_range_constraints stores its constraint under + # meta = "ub" (constraint_meta(UpperBound())), matching the thermal/renewable pattern. + @test !isnothing( + IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + PSY.HydroDispatch, + "ub", + ), + ) +end + +@testset "Event constraints - storage" begin + device_model = DeviceModel(EnergyReservoirStorage, StorageDispatchWithReserves) + sys = PSB.build_system(PSITestSystems, "c_sys5_bat") + model = DecisionModel(MockOperationProblem, DCPNetworkModel, sys) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + cons_in = IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + EnergyReservoirStorage, + "input", + ) + cons_out = IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + EnergyReservoirStorage, + "output", + ) + @test !isnothing(cons_in) + @test !isnothing(cons_out) +end + +@testset "Event constraints - hydro pump turbine" begin + device_model = DeviceModel( + HydroPumpTurbine, + HydroPumpEnergyDispatch; + attributes = Dict{String, Any}( + "reservation" => true, + "energy_target" => true, + ), + ) + sys = PSB.build_system( + PSITestSystems, + "c_sys5_hydro_pump_energy"; + add_reserves = true, + add_single_time_series = true, + ) + transform_single_time_series!(sys, Hour(24), Hour(24)) + model = DecisionModel(MockOperationProblem, CopperPlateNetworkModel, sys) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + @test !isnothing( + IOM.get_constraint(container, ActivePowerOutageConstraint(), HydroPumpTurbine), + ) + @test !isnothing( + IOM.get_constraint( + container, + ActivePowerPumpOutageConstraint(), + HydroPumpTurbine, + ), + ) +end From 4b8e0a26c3230994fea0aa425664c879fcf20584 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 19:25:37 -0700 Subject: [PATCH 11/19] test: add E2E build/solve and forced-outage coverage for events port Covers full-template build+solve across CopperPlate/PTDF/DCP/ACP network models with a FixedForcedOutage event, the PTDF two-target balance offset and ACP reactive-offset paths for load events, and a recurrent-solve mock test confirming a forced outage drives thermal output to zero. Also strengthens the initial-conditions exclusion test to confirm event parameters land in the main container while staying absent from the IC container. --- test/test_events.jl | 160 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/test/test_events.jl b/test/test_events.jl index e95233e1..4850423d 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -179,6 +179,17 @@ end @test ic_container !== nothing ic_keys = IOM.get_parameter_keys(ic_container) @test !any(k -> IOM.get_entry_type(k) <: EventParameter, ic_keys) + + # Continue the build pipeline into the main container (the next step after + # initial conditions in `build_model!`) to confirm event parameters land there, + # in contrast to their absence from the IC container asserted above. + POM.build_problem!( + IOM.get_optimization_container(model), + IOM.get_template(model), + IOM.get_system(model), + ) + main_keys = IOM.get_parameter_keys(IOM.get_optimization_container(model)) + @test any(k -> IOM.get_entry_type(k) <: EventParameter, main_keys) end @testset "Event parameters via mock construct - ThermalStandard UC" begin @@ -367,3 +378,152 @@ end ), ) end + +@testset "E2E: thermal UC with FixedForcedOutage event - $(net)" for net in + ( + CopperPlateNetworkModel, + PTDFNetworkModel, + DCPNetworkModel, +) + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) + attach_fixed_forced_outage!(sys, thermal) + template = get_thermal_dispatch_template_network(NetworkModel(net)) + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + set_event_model!(template, em) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + res = IOM.OptimizationProblemOutputs(model) + # Event parameters are written to results (should_write_resulting_value = true) + @test "AvailableStatusParameter__ThermalStandard" in + IOM.list_parameter_names(res) +end + +@testset "E2E: thermal UC with FixedForcedOutage event - ACPNetworkModel (reactive)" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) + attach_fixed_forced_outage!(sys, thermal) + template = get_thermal_dispatch_template_network(NetworkModel(ACPNetworkModel)) + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + set_event_model!(template, em) + model = DecisionModel(template, sys; optimizer = ipopt_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + container = IOM.get_optimization_container(model) + # The quadratic reactive-power outage constraint (q^2 <= ub * status) is only + # added under AbstractReactivePowerNetworkModel; confirm it was actually built. + @test !isnothing( + IOM.get_constraint( + container, + ReactivePowerOutageConstraint(), + PSY.ThermalStandard, + "ub", + ), + ) +end + +@testset "E2E: PTDF network with a load event exercises the 2-target balance offset" begin + # PTDF's `_balance_expression_targets` writes an offset term to both the + # system-level entry and the nodal (ACBus) entry -- unlike CopperPlate/DCP, + # which only write one target. A load's FixedForcedOutage exercises this + # because loads get an `ActivePowerOffsetParameter` injected directly into the + # balance expression (see `_add_event_offset_arguments!`), unlike thermal units + # which only touch the status/countdown parameters. + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + load = first(PSY.get_components(PSY.PowerLoad, sys)) + attach_fixed_forced_outage!(sys, load) + template = get_thermal_dispatch_template_network(NetworkModel(PTDFNetworkModel)) + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + set_event_model!(template, em) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + container = IOM.get_optimization_container(model) + @test !isnothing( + IOM.get_parameter(container, ActivePowerOffsetParameter, PSY.PowerLoad), + ) + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +end + +@testset "E2E: ACP network with a load event drives the reactive offset parameter into ReactivePowerBalance" begin + # ACPNetworkModel <: AbstractReactivePowerNetworkModel, so the load + # add_event_arguments! method with `with_reactive = true` runs, adding + # ReactivePowerOffsetParameter and injecting it into ReactivePowerBalance. + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + load = first(PSY.get_components(PSY.PowerLoad, sys)) + attach_fixed_forced_outage!(sys, load) + template = get_thermal_dispatch_template_network(NetworkModel(ACPNetworkModel)) + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + set_event_model!(template, em) + model = DecisionModel(template, sys; optimizer = ipopt_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + container = IOM.get_optimization_container(model) + @test !isnothing( + IOM.get_parameter(container, ReactivePowerOffsetParameter, PSY.PowerLoad), + ) + # ACPNetworkModel is a nodal (non-PTDF) network model, so the balance target is + # the per-bus ACBus expression, not a system-wide one (see + # `_balance_expression_targets`'s `<:AbstractNetworkModel` fallback method). + nodal_reactive_balance = IOM.get_expression(container, ReactivePowerBalance, PSY.ACBus) + @test !isnothing(nodal_reactive_balance) + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED +end + +@testset "Forced outage drives device output to zero" begin + device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) + sys = PSB.build_system(PSITestSystems, "c_sys5_uc") + model = DecisionModel(MockOperationProblem, DCPNetworkModel, sys) + mock_construct_device!( + model, + device_model; + add_event_model = true, + built_for_recurrent_solves = true, + ) + container = IOM.get_optimization_container(model) + param_array = IOM.get_parameter_array( + container, + AvailableStatusParameter(), + PSY.ThermalStandard, + ) + outaged_name = axes(param_array)[1][1] + for t in axes(param_array)[2] + JuMP.fix(param_array[outaged_name, t], 0.0; force = true) + end + jm = IOM.get_jump_model(container) + JuMP.set_optimizer(jm, HiGHS.Optimizer) + JuMP.set_silent(jm) + JuMP.optimize!(jm) + @test JuMP.termination_status(jm) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + p = IOM.get_variable(container, ActivePowerVariable, PSY.ThermalStandard) + @test all( + abs(JuMP.value(p[outaged_name, t])) <= 1e-6 for t in axes(p)[2] + ) +end From b62e91dc8d591f34437d463313ef790b05d88da7 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 19:36:53 -0700 Subject: [PATCH 12/19] test: match unqualified OptimizationProblemOutputs/list_parameter_names style Aligns the new E2E thermal-outage testset with the naming convention already established in test_model_decision.jl (both are exported by IOM and used unqualified there). --- test/test_events.jl | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/test_events.jl b/test/test_events.jl index 4850423d..db91d206 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -401,10 +401,9 @@ end @test build!(model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = IOM.OptimizationProblemOutputs(model) + res = OptimizationProblemOutputs(model) # Event parameters are written to results (should_write_resulting_value = true) - @test "AvailableStatusParameter__ThermalStandard" in - IOM.list_parameter_names(res) + @test "AvailableStatusParameter__ThermalStandard" in list_parameter_names(res) end @testset "E2E: thermal UC with FixedForcedOutage event - ACPNetworkModel (reactive)" begin From 8473f2d47adbdc8cb17a90452331eab5dea4639e Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 19:52:58 -0700 Subject: [PATCH 13/19] test: strengthen reactive load-offset testset with a coefficient check The prior testset only checked that ReactivePowerOffsetParameter and ReactivePowerBalance both exist, which proves nothing about the offset being wired into the balance (the expression is allocated for every ACP build regardless of events). Add a recurrent-solve mock testset that checks JuMP.coefficient of the offset parameter's variable in the balance expression directly, so the test fails if the wiring is ever removed. --- test/test_events.jl | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/test_events.jl b/test/test_events.jl index db91d206..70d7a84d 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -491,11 +491,54 @@ end # ACPNetworkModel is a nodal (non-PTDF) network model, so the balance target is # the per-bus ACBus expression, not a system-wide one (see # `_balance_expression_targets`'s `<:AbstractNetworkModel` fallback method). + # NOTE: existence of the parameter and existence of the expression container + # together do not prove the offset term is actually wired INTO the balance -- + # `ReactivePowerBalance__ACBus` is allocated for every ACP build regardless of + # events. The next testset verifies that linkage directly via a coefficient + # check (this full E2E build can't do that itself: in a non-recurrent build, + # event parameters are baked Float64 constants -- see `get_param_eltype` -- + # so their contribution is folded into the expression's constant and isn't + # structurally inspectable). nodal_reactive_balance = IOM.get_expression(container, ReactivePowerBalance, PSY.ACBus) @test !isnothing(nodal_reactive_balance) @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED end +@testset "Event arguments for loads: reactive offset parameter has a nonzero coefficient in ReactivePowerBalance" begin + # Recurrent-solve mode makes event parameters real JuMP variables (see + # `get_param_eltype`), so we can check the offset's coefficient in the balance + # expression directly with `JuMP.coefficient` -- a structural check that fails + # if `add_to_expression!(container, ReactivePowerBalance, + # ReactivePowerOffsetParameter, ...)` is ever removed from the reactive-load + # `add_event_arguments!` method, unlike merely checking that the parameter and + # the balance expression both exist (see the previous testset's note). + device_model = DeviceModel(PSY.PowerLoad, StaticPowerLoad) + sys = PSB.build_system(PSITestSystems, "c_sys5_uc") + model = DecisionModel(MockOperationProblem, ACPNetworkModel, sys) + mock_construct_device!( + model, + device_model; + add_event_model = true, + built_for_recurrent_solves = true, + ) + container = IOM.get_optimization_container(model) + load = first(PSY.get_components(PSY.PowerLoad, sys)) + network_model = IOM.get_network_model(IOM.get_template(model)) + bus_no = + PNM.get_mapped_bus_number(get_network_reduction(network_model), PSY.get_bus(load)) + t = first(IOM.get_time_steps(container)) + balance_row = IOM.get_expression(container, ReactivePowerBalance, PSY.ACBus)[bus_no, t] + param_ref = IOM.get_parameter_array( + container, + ReactivePowerOffsetParameter(), + PSY.PowerLoad, + )[ + PSY.get_name(load), + t, + ] + @test JuMP.coefficient(balance_row, param_ref) != 0.0 +end + @testset "Forced outage drives device output to zero" begin device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) sys = PSB.build_system(PSITestSystems, "c_sys5_uc") From 0e526088484fbe9f8760320c93b9ced5628b11c0 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 20:04:13 -0700 Subject: [PATCH 14/19] Document outage-events constraints in formulation library Adds an Outage events subsection covering the availability parameters and per-device-family outage constraints added when an EventModel is attached to a template. public.md needs no changes: it registers symbols via a blanket @autodocs, which already picks up the new exports. --- docs/src/reference/formulation_library.md | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/src/reference/formulation_library.md b/docs/src/reference/formulation_library.md index 094cab6c..e85da143 100644 --- a/docs/src/reference/formulation_library.md +++ b/docs/src/reference/formulation_library.md @@ -512,6 +512,32 @@ injection uses to reach its nodal, area, or system target. network it is dropped from the template (with an `@info` message) rather than being built as a no-op. +## Outage events + +Attaching an `EventModel` for a `PSY.Contingency` supplemental attribute (e.g. +`FixedForcedOutage`) to a `DeviceModel` in the template adds availability parameters and outage +constraints to every device of that type carrying the attribute, on top of whatever variables and +constraints its device formulation already contributes. + +Parameters (per device and time step): [`AvailableStatusParameter`](@ref) (1 = available, +initialized to 1) and [`AvailableStatusChangeCountdownParameter`](@ref); loads and `FixedOutput` +devices also get the balance offsets [`ActivePowerOffsetParameter`](@ref) / +[`ReactivePowerOffsetParameter`](@ref). + +[`ActivePowerOutageConstraint`](@ref) bounds active power by available capacity, +``p_t \le P^\text{max} \cdot \text{status}_t``, with the left-hand side depending on the device +family: the range-expression upper bound for thermal and hydro generators, the active-power +variable for loads and for renewables without a service model, the charge and discharge variables +together for `PSY.EnergyReservoirStorage`, and, for `PSY.HydroPumpTurbine`, both the generation +variable ([`ActivePowerOutageConstraint`](@ref)) and the pump variable +([`ActivePowerPumpOutageConstraint`](@ref)). Under reactive-power-capable networks, +[`ReactivePowerOutageConstraint`](@ref) additionally bounds +``q_t^2 \le (Q^\text{max})^2 \cdot \text{status}_t``. + +The parameter values are constant within a single build; updating them across solves (outage +sampling, countdown projection) is simulation-runtime functionality that lives outside this +package. + ## [Service Formulations](@id service_formulations) | Formulation | Service type | Argument stage | Model stage | From a3a6c3bd2e06a42093200cfa60b5bf9aebcf4907 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 20:16:48 -0700 Subject: [PATCH 15/19] docs: close out outage-events port plan bookkeeping Record the completed event framework port in pom_port_plan.md (Workstream C and execution order), and add the SDD plan and design spec artifacts for the branch. --- .claude/plans/2026-07-29-events-port.md | 2116 +++++++++++++++++ .claude/pom_port_plan.md | 19 +- .../specs/2026-07-29-events-port-design.md | 259 ++ 3 files changed, 2389 insertions(+), 5 deletions(-) create mode 100644 .claude/plans/2026-07-29-events-port.md create mode 100644 .claude/specs/2026-07-29-events-port-design.md diff --git a/.claude/plans/2026-07-29-events-port.md b/.claude/plans/2026-07-29-events-port.md new file mode 100644 index 00000000..49db3299 --- /dev/null +++ b/.claude/plans/2026-07-29-events-port.md @@ -0,0 +1,2116 @@ +# Events Port (PSI → POM) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port the time-series outage events feature (PSI "Feature A") into POM so a standalone `DecisionModel` builds event parameters and outage constraints, per the approved spec at `.claude/specs/2026-07-29-events-port-design.md`. + +**Architecture:** New `src/event_models/` directory holds the container types (`EventKey`, `EventModel`, condition structs), traits, parameter builders, and constraint builders. +POM already has the parameter/constraint types in `src/core/`, no-op stubs for `add_event_arguments!`/`add_event_constraints!` in `src/core/feedforward_interface.jl:49-69`, and every constructor call site wired — this port replaces the stubs with real dispatch methods and adds template-level attachment plus build-time discovery. +IOM needs exactly one correction (Task 4b): its event parameter machinery bounds the contingency slot on `IS.InfrastructureSystemsComponent`, but contingency types live under `IS.SupplementalAttribute` — every other IOM piece is consumed as-is. + +**Tech Stack:** Julia, JuMP, PowerSystems (psy6), InfrastructureOptimizationModels (main), HiGHS/Ipopt for tests, PowerSystemCaseBuilder fixtures. + +## Global Constraints + +- Never run `git commit` or `git push`. Leave all edits unstaged; run `git add -N ` for each newly created file so it shows in `git diff`. +- All Julia commands use `julia --project=test`; never bare `julia` or `--project=.`. +- Run the formatter after completing each task: `julia --project=scripts/formatter -e 'include("scripts/formatter/formatter_code.jl")'`. +- Every `PSY` getter on a unit-convertible field passes `PSY.SU` explicitly (e.g. `PSY.get_active_power_limits(d, PSY.SU)`). Never copy PSI getter calls verbatim — PSI predates the stateless-units rework. +- No `Project.toml` or `[sources]` pin changes of any kind. +- No new reaches into non-exported `IOM._*` helpers beyond those POM already uses (`IOM._set_multiplier_at!`, `IOM._set_parameter_at!`, `IOM.get_multiplier_array_data`, `IOM.get_parameter_array_data` are already in use in `src/common_models/add_parameters.jl` and may be used here). +- All type bounds use `PSY.Contingency` (never `PSY.Outage`) for event dispatch. +- `add_*!` methods end with bare `return`; store JuMP objects via `add_*_container!`, never return collections. +- Do not touch `src/common_models/add_to_expression.jl`, `src/ac_transmission_models/`, or `src/network_models/` — the concurrent transformer-refactor plan owns those files. All new event code lives in `src/event_models/`, `src/core/problem_template.jl`, `src/operation/template_validation.jl`, and test files. +- Do not modify PSI, HPS, SSS, or PSY checkouts. +- IOM changes happen ONLY in the local clone at `/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl` (branch `mb/events-port`), ONLY as scoped in Task 4b, under IOM's house rules: never edit the `version` field in its `Project.toml`, use its own formatter script, prefer mocks over PSY types in its tests, and add no `using`/`include`/`const` lines to individual `test_*.jl` files (they are included by `InfrastructureOptimizationModelsTests.jl`). +- New exported symbols need docstrings; the docs build (`julia --project=docs docs/make.jl`) is a completion gate. + +## Reference sources (read-only) + +- PSI: `/Users/mbossart/sienna/PowerSimulations.jl` — `src/core/event_keys.jl`, `src/core/event_model.jl`, `src/contingency_model/*.jl`. +- HPS `src/contingency_model.jl` and SSS `src/contingency_model.jl` — fetch from GitHub `Sienna-Platform/{HydroPowerSimulations,StorageSystemsSimulations}.jl` `main` if needed; the relevant code is reproduced in Tasks 7–8. +- IOM provides (via `using InfrastructureOptimizationModels`, `src/PowerOperationsModels.jl:206`): `AbstractEventModel`, `AbstractEventKey`, `DeviceModel.events::Dict{AbstractEventKey, AbstractEventModel}`, `set_event_model!(::DeviceModel, key, event)`, `get_events(::DeviceModel)`, `EventParameter`, `EventParametersAttributes`, and `add_param_container!(container, ::Type{T<:EventParameter}, ::Type{U}, ::Type{V}, axs...)` (note: IOM takes `Type{T}`, not an instance like PSI). + The `V` slot of that overload requires the Task 4b bound fix (`IS.InfrastructureSystemsComponent` → `IS.SupplementalAttribute`) before it dispatches for contingency types. + Local IOM clone: `/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl`, branch `mb/events-port`. + +--- + +### Task 0: Environment gate and baseline + +**Files:** none modified. + +- [ ] **Step 1: Verify POM loads.** + +Run: `julia --project=test -e 'using PowerOperationsModels; println("LOADED")'` +Expected: prints `LOADED`. +If it fails with `UndefVarError: PhaseShiftingTransformer` (or any missing transformer symbol): **STOP — do not work around it.** +The environment has resolved PSY past the #1714 transformer refactor; load restoration is owned by `.claude/plans/2026-07-26-transformer-refactor.md` Tasks 0–1. +Report the blocker and end the session. + +- [ ] **Step 2: Baseline test run.** + +Run: `julia --project=test test/runtests.jl test_device_thermal_generation_constructors` +Expected: PASS. Record the result; later tasks must not regress it. + +--- + +### Task 1: Container types — `src/event_models/event_model.jl` + +**Files:** +- Create: `src/event_models/event_model.jl` +- Modify: `src/PowerOperationsModels.jl` (includes + exports) +- Test: `test/test_events.jl` (new) + +**Interfaces:** +- Consumes: `IOM.AbstractEventKey`, `IOM.AbstractEventModel` (available unqualified via `using InfrastructureOptimizationModels`), `PSY.Contingency`, `PSY.FixedForcedOutage`, `PSY.GeometricDistributionForcedOutage`, `VariableType`. +- Produces: `EventKey{T,U}`, `EventKey(::Type{T}, ::Type{U})`, `get_entry_type(::EventKey)`, `get_component_type(::EventKey)`, `AbstractEventCondition`, `ContinuousCondition`, `PresetTimeCondition`, `StateVariableValueCondition`, `DiscreteEventCondition`, `EventModel{D,B}`, `EventModel(contingency_type, condition; timeseries_mapping, attributes)`, `get_empty_timeseries_mapping(::Type)`, `get_event_type`, `get_event_condition`, `get_attribute_device_map`. Tasks 2–9 use all of these names exactly as written. + +- [ ] **Step 1: Write the failing test.** + +Create `test/test_events.jl`: + +```julia +@testset "EventKey and EventModel construction" begin + key = EventKey(PSY.FixedForcedOutage, PSY.ThermalStandard) + @test IOM.get_entry_type(key) == PSY.FixedForcedOutage + @test IOM.get_component_type(key) == PSY.ThermalStandard + # Abstract component types are rejected + @test_throws ErrorException EventKey(PSY.FixedForcedOutage, PSY.ThermalGen) + + em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + @test get_event_type(em) == PSY.FixedForcedOutage + @test get_event_condition(em) isa ContinuousCondition + @test em.timeseries_mapping == Dict{Symbol, Union{String, Nothing}}(:outage_status => nothing) + @test isempty(get_attribute_device_map(em)) + + em_geo = EventModel(PSY.GeometricDistributionForcedOutage, ContinuousCondition()) + @test Set(keys(em_geo.timeseries_mapping)) == + Set([:mean_time_to_recovery, :outage_transition_probability]) + + pc = PresetTimeCondition([Dates.DateTime("2024-01-01T05:00:00")]) + @test get_time_stamps(pc) == [Dates.DateTime("2024-01-01T05:00:00")] +end +``` + +Note: `IOM.get_entry_type`/`IOM.get_component_type` are used above on the assumption IOM defines those generics; if `IOM.get_entry_type` does not exist, define POM-owned generics in this file and test unqualified `get_entry_type(key)` instead — check with `julia --project=test -e 'using InfrastructureOptimizationModels; println(isdefined(InfrastructureOptimizationModels, :get_entry_type))'` and use whichever holds. + +- [ ] **Step 2: Run it to verify it fails.** + +Run: `julia --project=test -e 'include("test/includes.jl"); include("test/test_events.jl")'` +Expected: FAIL with `UndefVarError: EventKey`. + +- [ ] **Step 3: Write the implementation.** + +Create `src/event_models/event_model.jl` (adapted from PSI `src/core/event_keys.jl` + `src/core/event_model.jl`; changes: subtype the IOM abstracts, drop the per-simulation-model outer key of `attribute_device_map`, add docstrings): + +```julia +""" + EventKey(::Type{T}, ::Type{U}) + +Key identifying an event of contingency type `T` applied to devices of concrete type `U`. +Used as the key of the `DeviceModel.events` dict. Errors if `U` is abstract. +""" +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 + +""" +Abstract type for the condition that triggers an event. POM stores conditions as data; +evaluating them requires a simulation runtime and happens outside this package. +""" +abstract type AbstractEventCondition end + +""" + ContinuousCondition() + +Event condition that is triggered at all timesteps. +""" +struct ContinuousCondition <: AbstractEventCondition end + +""" + PresetTimeCondition(time_stamps::Vector{Dates.DateTime}) + +Event condition that is triggered at pre-determined times. +""" +struct PresetTimeCondition <: AbstractEventCondition + time_stamps::Vector{Dates.DateTime} +end + +get_time_stamps(c::PresetTimeCondition) = c.time_stamps + +""" + StateVariableValueCondition(variable_type, device_type, device_name, value) + +Event condition triggered when the monitored variable equals `value` (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) + +Event condition driven by a user-defined function evaluated by the simulation runtime. +""" +struct DiscreteEventCondition <: AbstractEventCondition + condition_function::Function +end + +get_condition_function(c::DiscreteEventCondition) = c.condition_function + +""" + EventModel(contingency_type, condition; timeseries_mapping, attributes) + +Container binding a `PSY.Contingency` supplemental-attribute type to a trigger condition +and time-series mapping. Attach to a template with +`set_event_model!(template, event_model)`; build-time discovery populates +`attribute_device_map` (outage attribute UUID → device type → device names) and +distributes the event to the matching `DeviceModel`s. +""" +mutable struct EventModel{D <: PSY.Contingency, B <: AbstractEventCondition} <: + IOM.AbstractEventModel + condition::B + timeseries_mapping::Dict{Symbol, Union{String, Nothing}} + attribute_device_map::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{Base.UUID, Dict{DataType, Set{String}}}(), + attributes, + ) + end +end + +""" +Reserved time-series mapping keys for a contingency type. `:outage_status` is required +for `PSY.FixedForcedOutage`. +""" +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 +``` + +If Step 1's isdefined check showed IOM owns `get_entry_type`/`get_component_type` generics, define the two methods as `IOM.get_entry_type(...)`/`IOM.get_component_type(...)` extensions instead of new generics (POM may already extend them for other key types — check `grep -rn "get_entry_type" src/` and match the existing style). + +- [ ] **Step 4: Wire include and exports.** + +In `src/PowerOperationsModels.jl`: +find the last `include("core/...")` line (`grep -n 'include("core/' src/PowerOperationsModels.jl | tail -1`) and insert after it: + +```julia +include("event_models/event_model.jl") +``` + +Find the export section (`grep -n '^export' src/PowerOperationsModels.jl | head -3`) and add, grouped with a comment near the other model-container exports: + +```julia +export EventModel +export EventKey +export AbstractEventCondition +export ContinuousCondition +export PresetTimeCondition +export StateVariableValueCondition +export DiscreteEventCondition +export get_empty_timeseries_mapping +export get_event_type +export get_event_condition +export get_attribute_device_map +export set_event_model! +``` + +(`set_event_model!` currently resolves to IOM's function; POM re-exports it and Task 3 adds the template method to the same generic.) + +- [ ] **Step 5: Run the test to verify it passes.** + +Run: `julia --project=test -e 'include("test/includes.jl"); include("test/test_events.jl")'` +Expected: PASS. + +- [ ] **Step 6: Track and format.** + +```bash +git add -N src/event_models/event_model.jl test/test_events.jl +julia --project=scripts/formatter -e 'include("scripts/formatter/formatter_code.jl")' +``` + +--- + +### Task 2: Traits — `src/event_models/event_traits.jl` + +**Files:** +- Create: `src/event_models/event_traits.jl` +- Modify: `src/PowerOperationsModels.jl` (include + export) +- Test: `test/test_events.jl` + +**Interfaces:** +- Consumes: `EventModel` (Task 1), the parameter types in `src/core/parameters.jl:208-223` (`AvailableStatusParameter`, `ActivePowerOffsetParameter`, `ReactivePowerOffsetParameter`, `AvailableStatusChangeCountdownParameter`), `EventParameter` (IOM). +- Produces: `supports_events(::Type{<:PSY.Component})::Bool`, `get_parameter_multiplier(::EventParameter, ::PSY.Device, ::EventModel)`, `get_initial_parameter_value(::, ::PSY.Device, ::EventModel)`. Tasks 4 and 5 call these exact signatures. + +- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: + +```julia +@testset "Event traits" begin + @test POM.supports_events(PSY.ThermalStandard) + @test POM.supports_events(PSY.RenewableDispatch) + @test POM.supports_events(PSY.PowerLoad) + @test POM.supports_events(PSY.HydroDispatch) + @test POM.supports_events(PSY.EnergyReservoirStorage) + @test !POM.supports_events(PSY.Source) + + em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + d = PSY.ThermalStandard(nothing) + @test POM.get_initial_parameter_value(AvailableStatusParameter(), d, em) == 1.0 + @test POM.get_initial_parameter_value(AvailableStatusChangeCountdownParameter(), d, em) == 0.0 + @test POM.get_initial_parameter_value(ActivePowerOffsetParameter(), d, em) == 0.0 + @test POM.get_initial_parameter_value(ReactivePowerOffsetParameter(), d, em) == 0.0 + @test POM.get_parameter_multiplier(AvailableStatusParameter(), d, em) == 1.0 +end +``` + +Check how the test preamble aliases the package (`grep -n "const POM\|import PowerOperationsModels" test/includes.jl test/test_utils/*.jl | head -5`); if the alias is different (e.g. `PSI` for compatibility), use that alias. +If `PSY.ThermalStandard(nothing)` is unavailable in psy6, use `first(PSY.get_components(PSY.ThermalStandard, PSB.build_system(PSB.PSITestSystems, "c_sys5")))` instead. + +- [ ] **Step 2: Run to verify it fails** (same include command as Task 1). Expected: FAIL with `UndefVarError: supports_events` (or MethodError). + +- [ ] **Step 3: Implement.** Create `src/event_models/event_traits.jl`: + +```julia +#! format: off +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 + +""" +Whether devices of this type support outage events (`EventModel`). This is a device-type +capability trait for time-series outage events — distinct from `supports_outages`, the +formulation trait for security-constrained (MODF) branch contingencies. +""" +supports_events(::Type{T}) where {T <: PSY.Component} = false +supports_events(::Type{T}) where {T <: PSY.ThermalStandard} = true +supports_events(::Type{T}) where {T <: PSY.RenewableGen} = true +supports_events(::Type{T}) where {T <: PSY.ElectricLoad} = true +supports_events(::Type{T}) where {T <: PSY.Storage} = true +supports_events(::Type{T}) where {T <: PSY.HydroGen} = true +#! format: on +``` + +Note the fallback is `PSY.Component` (PSI used `PSY.StaticInjection`); the wider fallback lets discovery (Task 4) query any device type safely. +If `get_parameter_multiplier`/`get_initial_parameter_value` generics already have POM methods with different owner modules, extend the same function the existing methods extend (check `grep -rn "function get_initial_parameter_value\|get_initial_parameter_value(" src/common_models/add_parameters.jl | head -3` and mirror). + +- [ ] **Step 4: Wire include + export.** In `src/PowerOperationsModels.jl` add after the Task 1 include: + +```julia +include("event_models/event_traits.jl") +``` + +Add `export supports_events` next to the Task 1 export block. + +- [ ] **Step 5: Run the test to verify it passes.** Expected: PASS. +- [ ] **Step 6:** `git add -N src/event_models/event_traits.jl`; run the formatter. + +--- + +### Task 3: Template attachment + +**Files:** +- Modify: `src/core/problem_template.jl` (struct + accessors), `src/PowerOperationsModels.jl` (export) +- Test: `test/test_events.jl` + +**Interfaces:** +- Consumes: `EventModel` (Task 1), `PowerOperationsProblemTemplate` (`src/core/problem_template.jl`). +- Produces: `PowerOperationsProblemTemplate.events::Vector{EventModel}`, `set_event_model!(template::PowerOperationsProblemTemplate, event_model::EventModel)`, `get_event_models(template)::Vector{EventModel}`. Task 4 consumes these. + +- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: + +```julia +@testset "Template-level event attachment" begin + template = PowerOperationsProblemTemplate(CopperPlateNetworkModel) + @test isempty(get_event_models(template)) + em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + set_event_model!(template, em) + @test length(get_event_models(template)) == 1 + @test get_event_models(template)[1] === em + # Same event model instance can't be attached twice + @test_throws ErrorException set_event_model!(template, em) +end +``` + +- [ ] **Step 2: Run to verify it fails.** Expected: FAIL with `UndefVarError: get_event_models` or MethodError on `set_event_model!`. + +- [ ] **Step 3: Implement.** In `src/core/problem_template.jl`: + +Add `events::Vector{<:Any}`? No — the struct is declared before `EventModel` exists in include order (`core/` is included before `event_models/`). +Type the field as `Vector{IOM.AbstractEventModel}` (IOM abstract, already loaded): + +```julia +mutable struct PowerOperationsProblemTemplate <: IOM.AbstractProblemTemplate + network_model::NetworkModel{<:AbstractNetworkModel} + devices::DevicesModelContainer + branches::BranchModelContainer + services::ServicesModelContainer + events::Vector{IOM.AbstractEventModel} + function PowerOperationsProblemTemplate( + network::NetworkModel{T}, + ) where {T <: AbstractNetworkModel} + new( + network, + DevicesModelContainer(), + BranchModelContainer(), + ServicesModelContainer(), + Vector{IOM.AbstractEventModel}(), + ) + end +end +``` + +Below the existing accessors (`get_device_models` etc.) add: + +```julia +get_event_models(template::PowerOperationsProblemTemplate) = template.events + +""" + set_event_model!(template::PowerOperationsProblemTemplate, event_model) + +Attach an outage-event model to the template. At build time the event is validated, +its `attribute_device_map` is populated from the system's supplemental attributes, and +it is distributed to every matching `DeviceModel`. +""" +function set_event_model!( + template::PowerOperationsProblemTemplate, + event_model::IOM.AbstractEventModel, +) + if any(e -> e === event_model, template.events) + error("This event model is already attached to the template") + end + push!(template.events, event_model) + return +end +``` + +Check whether `Base.isempty(template::PowerOperationsProblemTemplate)` should consider events: it exists at `src/core/problem_template.jl` (checks devices/branches/services) — leave it unchanged; a template with only events and no device models is still "empty" for build purposes. + +- [ ] **Step 4: Export.** Add `export get_event_models` to the Task 1 export block (`set_event_model!` was exported in Task 1). +- [ ] **Step 5: Run the test to verify it passes.** Expected: PASS. +- [ ] **Step 6:** Formatter. + +--- + +### Task 4: Build-time discovery and time-series validation + +**Files:** +- Modify: `src/operation/template_validation.jl` +- Create: `test/test_utils/events_test_utils.jl` +- Modify: `test/includes.jl` (only if test_utils files are explicitly included there — check `grep -n "test_utils" test/includes.jl`; mirror how `add_branch_rating_time_series.jl` is included) +- Test: `test/test_events.jl` + +**Interfaces:** +- Consumes: Tasks 1–3 symbols; `PSY.get_supplemental_attributes(T, sys)`, `PSY.get_associated_components(sys, attribute)` (verified present in psy6 `src/get_components_interface.jl:70`), `PSY.add_supplemental_attribute!`, `IS.get_uuid`, `IOM.set_event_model!(::DeviceModel, key, event)`, `get_model(template, type)`. +- Produces: `_build_device_model_events!(template, sys)` (internal, called from `validate_template_impl!`), `_validate_event_timeseries_data(sys, event, event_model)` (internal), test helper `attach_fixed_forced_outage!(sys, device; ts_name = "outage_profile")`. + +- [ ] **Step 1: Write the test helper.** Create `test/test_utils/events_test_utils.jl`: + +```julia +# Attaches a FixedForcedOutage supplemental attribute to `device` and a 0/1 +# SingleTimeSeries named `ts_name` to the attribute. Returns the attribute. +# Adapted from PSI test/test_utils/events_simulation_utils.jl (build-relevant part only). +function attach_fixed_forced_outage!( + sys::PSY.System, + device::PSY.Device; + ts_name = "outage_profile", + outage_profile = nothing, +) + outage = PSY.FixedForcedOutage(; outage_status = 0.0) + PSY.add_supplemental_attribute!(sys, device, outage) + resolution = PSY.get_time_series_resolution(sys) + initial_time = PSY.get_forecast_initial_timestamp(sys) + horizon_count = PSY.get_forecast_horizon(sys) + if isnothing(outage_profile) + outage_profile = zeros(horizon_count) # 0 = available for the whole horizon + end + ts_data = TimeSeries.TimeArray( + range(initial_time; length = length(outage_profile), step = resolution), + outage_profile, + ) + ts = PSY.SingleTimeSeries(; name = ts_name, data = ts_data) + PSY.add_time_series!(sys, outage, ts) + return outage +end +``` + +Verify the psy6 accessor names compile (`PSY.get_time_series_resolution`, `PSY.get_forecast_initial_timestamp`, `PSY.get_forecast_horizon`); if a name errors, find the psy6 equivalent with `grep -rn "forecast_initial_timestamp\|get_forecast_horizon" ~/sienna/psy6/PowerSystems.jl/src/PowerSystems.jl` and substitute. +Include the helper the same way sibling `test/test_utils/*.jl` files are included (they are loaded by `test/includes.jl`; confirm and mirror). + +- [ ] **Step 2: Write the failing tests.** Append to `test/test_events.jl`: + +```julia +@testset "Event discovery and validation at build" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) + outage = attach_fixed_forced_outage!(sys, thermal) + + template = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:outage_status => "outage_profile"), + ) + set_event_model!(template, em) + + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT + + # Discovery populated the map: attribute uuid -> device type -> names + map_ = get_attribute_device_map(em) + uuid = IS.get_uuid(outage) + @test haskey(map_, uuid) + @test map_[uuid][PSY.ThermalStandard] == Set([PSY.get_name(thermal)]) + + # The caller's template DeviceModels were not mutated (build-copy isolation) + caller_dm = get_model(template, PSY.ThermalStandard) + @test isempty(IOM.get_events(caller_dm)) +end + +@testset "Event validation errors" begin + sys_clean = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + template = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:outage_status => "outage_profile"), + ) + set_event_model!(template, em) + model = DecisionModel(template, sys_clean; optimizer = HiGHS_optimizer) + # No supplemental attributes in the system -> loud build failure + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED + + # Unknown mapping key rejected + sys2 = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal2 = first(PSY.get_components(PSY.ThermalStandard, sys2)) + attach_fixed_forced_outage!(sys2, thermal2) + template2 = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em_bad = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:not_a_parameter => "outage_profile"), + ) + set_event_model!(template2, em_bad) + model2 = DecisionModel(template2, sys2; optimizer = HiGHS_optimizer) + @test build!(model2; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED + + # FixedForcedOutage requires :outage_status mapping + sys3 = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal3 = first(PSY.get_components(PSY.ThermalStandard, sys3)) + attach_fixed_forced_outage!(sys3, thermal3) + template3 = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em_nomapping = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + set_event_model!(template3, em_nomapping) + model3 = DecisionModel(template3, sys3; optimizer = HiGHS_optimizer) + @test build!(model3; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED +end +``` + +Adjust the PSB system name if `c_sys5_uc` is not what POM tests use — check `grep -rn 'build_system' test/test_device_thermal_generation_constructors.jl | head -3` and use the same fixture family. +`build!` failure semantics: if `build!` throws instead of returning `FAILED`, assert with `@test_throws` — match whatever `test/test_model_decision.jl` does for build failures. +The build-copy isolation assertion assumes `build!` finalizes a copied template (mirroring Feature B's `_build_device_model_outages!` isolation). +If it fails because POM finalizes the caller's template in place for events too, check how `_build_device_model_outages!` achieves isolation (`src/operation/template_validation.jl:456-544`) and route event distribution through the same copy. + +- [ ] **Step 3: Run to verify failure.** Expected: first testset FAILS (map not populated — discovery doesn't exist yet; the build may even succeed with events silently ignored, which is exactly the gap). + +- [ ] **Step 4: Implement discovery + validation.** In `src/operation/template_validation.jl`, after `_build_device_model_outages!(template, system)` (line ~116, inside `validate_template_impl!`), add: + +```julia + _build_device_model_events!(template, system) +``` + +Then add at the end of the file (adapted from PSI `src/simulation/simulation_sequence.jl:215-296`, re-hosted at template level; the per-simulation-model map key is dropped): + +```julia +################################################################################# +# Outage-event discovery and validation (time-series outage events; distinct +# from the security-constrained `_build_device_model_outages!` above) +################################################################################# + +""" +For each event model attached to the template: validate its time-series mapping, +populate `attribute_device_map` (attribute UUID → concrete device type → device names) +from the system's supplemental attributes, and distribute the event model to every +`DeviceModel` in the template whose device type carries the attribute and supports +events. +""" +function _build_device_model_events!( + template::PowerOperationsProblemTemplate, + sys::PSY.System, +) + for event_model in get_event_models(template) + event_type = get_event_type(event_model) + if isempty(PSY.get_supplemental_attributes(event_type, sys)) + error( + "There are no supplemental attributes of type $event_type in the system. \ + Add the outage data to the system or remove the event model from the \ + template.", + ) + end + for event in PSY.get_supplemental_attributes(event_type, sys) + _validate_event_timeseries_data(sys, event, event_model) + event_uuid = IS.get_uuid(event) + attribute_device_map = get_attribute_device_map(event_model) + attribute_device_map[event_uuid] = Dict{DataType, Set{String}}() + device_types_with_attribute = Set{DataType}() + for device in PSY.get_associated_components(sys, event) + dtype = typeof(device) + if !supports_events(dtype) + @warn "Device $(PSY.get_name(device)) of type $dtype carries a \ + $event_type attribute but the type does not support events; \ + it will not be modeled." _group = + IOM.LOG_GROUP_MODELS_VALIDATION + continue + end + push!(device_types_with_attribute, dtype) + name_set = get!( + attribute_device_map[event_uuid], + dtype, + Set{String}(), + ) + push!(name_set, PSY.get_name(device)) + end + for device_type in device_types_with_attribute + device_model = get_model(template, device_type) + if device_model === nothing + @warn "Devices of type $device_type carry a $event_type attribute \ + but the template has no DeviceModel for that type; the event \ + will not be modeled for them." _group = + IOM.LOG_GROUP_MODELS_VALIDATION + continue + end + key = EventKey(event_type, device_type) + if !haskey(IOM.get_events(device_model), key) + IOM.set_event_model!(device_model, key, event_model) + end + end + end + end + return +end + +function _validate_event_timeseries_data( + sys::PSY.System, + event::PSY.Contingency, + event_model::EventModel, +) + for (k, v) in event_model.timeseries_mapping + if !isnothing(v) + try + PSY.get_time_series(IS.SingleTimeSeries, event, v) + catch + device_names = + PSY.get_name.(PSY.get_associated_components(sys, event)) + error( + "Event $event belonging to devices $device_names is missing a \ + time series with name $v", + ) + end + end + if !haskey(get_empty_timeseries_mapping(typeof(event)), k) + error( + "Key $k passed as part of the event time series mapping does not \ + correspond to a parameter.", + ) + end + if k == :outage_status && isnothing(v) + error( + "FixedForcedOutage requires a timeseries mapping for the \ + :outage_status parameter", + ) + end + end + return +end +``` + +Verification notes for the implementer: +`get_model(template, device_type)` — confirm the accessor name POM/IOM uses to fetch a `DeviceModel` from a template by component type (`grep -rn "function get_model" src/ | head -3`, else check IOM); adjust the call if it is `get_model(template.devices, ...)` or similar. +`IOM.LOG_GROUP_MODELS_VALIDATION` — confirm the constant exists (`grep -rn "LOG_GROUP" src/operation/template_validation.jl | head -2`) and reuse whatever group that file already logs under. +`PSY.get_time_series(IS.SingleTimeSeries, event, v)` — supplemental attributes carry time series through IS; if the psy6 method signature differs, check `grep -rn "get_time_series" ~/sienna/psy6/InfrastructureSystems.jl/src/supplemental_attributes.jl` and adapt. + +- [ ] **Step 5: Run the tests.** Both new testsets pass. Also re-run Task 1–3 testsets (whole `test/test_events.jl`). +- [ ] **Step 6: Initial-conditions exclusion test.** POM builds an initialization problem from a reduced template (see `src/initial_conditions/initialization.jl`). +Verify events are not copied into it: read the template-construction code there; if it copies device models wholesale (including `events`), clear events on the IC copy and add a code comment stating IC problems never model outage events. +Append to `test/test_events.jl` a testset asserting the built model's IC container has no event parameters: + +```julia +@testset "Events excluded from initialization problem" begin + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) + attach_fixed_forced_outage!(sys, thermal) + template = get_thermal_standard_uc_template() + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:outage_status => "outage_profile"), + ) + set_event_model!(template, em) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT + ic_container = IOM.get_initial_conditions_optimization_container(model) + ic_keys = IOM.get_parameter_keys(ic_container) + @test !any(k -> IOM.get_entry_type(k) <: EventParameter, ic_keys) +end +``` + +Adjust helper names to what exists: template helper (`grep -n "get_thermal_standard_uc_template\|get_thermal_dispatch_template" test/test_utils/operations_problem_templates.jl`), IC container accessor and key listing (`grep -rn "initial_conditions_optimization_container\|get_parameter_keys" src/ test/ | head -5`). +This testset requires Task 5's parameter machinery to be meaningful (before Task 5, no event parameters exist anywhere, so it passes vacuously); re-run it after Task 5 and confirm it still passes. + +- [ ] **Step 7:** `git add -N test/test_utils/events_test_utils.jl`; formatter; run the full events file plus `julia --project=test test/runtests.jl test_problem_template` to confirm no Feature-B regression. + +--- + +### Task 4b: IOM type-bound fix (executed in the IOM clone) + +**Repo:** `/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl`, branch `mb/events-port`. All steps in this task run from that directory. + +**Files:** +- Modify: `src/core/parameter_container.jl:83-101`, `src/common_models/add_param_container.jl:85-103` +- Test: add a testset to the IOM test file that covers `add_param_container!` (find it: `grep -rln "add_param_container!" test/ | head -3`; use the file the existing parameter-container tests live in) + +**Why:** `PSY.Contingency <: SupplementalAttribute <: IS.InfrastructureSystemsType`, which is not under `IS.InfrastructureSystemsComponent`. +IOM's event overload of `add_param_container!` and `EventParametersAttributes` bound the contingency slot as `IS.InfrastructureSystemsComponent`, so any call with a real contingency type is a MethodError. +The `affected_devices::Vector{T}` field has zero readers in IOM (`grep -rn "affected_devices" src/ test/` returns only the definition) and is dropped. + +**Interfaces:** +- Produces: `add_param_container!(container, ::Type{T<:EventParameter}, ::Type{U<:IS.InfrastructureSystemsComponent}, ::Type{V<:IS.SupplementalAttribute}, axs...)` and `EventParametersAttributes{T<:IS.SupplementalAttribute, U<:ParameterType}`. POM Task 5 calls the former with `V = PSY.FixedForcedOutage`. + +- [ ] **Step 1: Write the failing test.** In the IOM test file that covers parameter containers, add (mirroring the file's existing mock/container fixtures — reuse whatever mock `OptimizationContainer` factory its sibling testsets use): + +```julia +@testset "Event parameter container accepts supplemental-attribute contingency types" begin + container = # same mock container construction as the surrounding testsets + IOM.add_param_container!( + container, + MockEventParameter, + MockThermalGen, + MockContingency, + ["dev1", "dev2"], + 1:24, + ) + key = IOM.ParameterKey(MockEventParameter, MockThermalGen) + pc = IOM.get_parameter(container, key) + @test IOM.get_attributes(pc) isa IOM.EventParametersAttributes{MockContingency} +end +``` + +Supporting mock types: check `test/mocks/` for an existing `MockThermalGen` (it exists per IOM conventions) and for any existing `SupplementalAttribute`/`EventParameter` mocks; if absent, add to the mocks file: + +```julia +struct MockContingency <: IS.SupplementalAttribute end +struct MockEventParameter <: InfrastructureOptimizationModels.EventParameter end +``` + +Adjust accessor names (`get_parameter`, `get_attributes`, `ParameterKey` arity) to match the file's surrounding testsets — copy their exact style. + +- [ ] **Step 2: Run to verify it fails.** + +Run: `julia --project=test test/runtests.jl` +Expected: the new testset FAILS with a MethodError (no `add_param_container!` method matching `MockContingency`, which is not an `IS.InfrastructureSystemsComponent`). +If IOM's runner supports file filtering, run just the affected file per its README/runtests conventions. + +- [ ] **Step 3: Apply the fix.** In `src/core/parameter_container.jl:83-101` replace the three `EventParametersAttributes` definitions with: + +```julia +""" +Attributes for event (contingency) parameters. `T` is the `IS.SupplementalAttribute` +subtype describing the contingency and `U` is the parameter type stored in the container. +""" +struct EventParametersAttributes{ + T <: IS.SupplementalAttribute, + U <: ParameterType, +} <: ParameterAttributes end + +function EventParametersAttributes( + ::Type{T}, + ::Type{U}, +) where {T <: IS.SupplementalAttribute, U <: ParameterType} + return EventParametersAttributes{T, U}() +end + +function get_param_type( + ::EventParametersAttributes{T, U}, +) where {T <: IS.SupplementalAttribute, U <: ParameterType} + return U +end +``` + +In `src/common_models/add_param_container.jl:96` change the event overload's where-clause bound from `V <: IS.InfrastructureSystemsComponent` to `V <: IS.SupplementalAttribute` (the body is unchanged). + +- [ ] **Step 4: Run the IOM suite.** + +Run: `julia --project=test test/runtests.jl` +Expected: PASS including the new testset and Aqua checks. + +- [ ] **Step 5: IOM formatter and tracking.** + +```bash +julia --project=scripts/formatter -e 'include("scripts/formatter/formatter_code.jl")' +git add -N . # new test/mock files only; leave everything unstaged, never commit +git status --short +``` + +Report the diff summary; the user opens the IOM PR from `mb/events-port`. + +- [ ] **Step 6: Bridge the fix into POM's test environment** (back in the POM repo). POM's `[sources]` pins IOM to the GitHub `main` branch, which does not have this fix yet; override the resolution locally (Manifest-only — `Project.toml` is untouched): + +Run: `julia --project=test -e 'using Pkg; Pkg.develop(path="/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl"); using PowerOperationsModels; println("LOADED")'` +Expected: resolves and prints `LOADED`. +`test/Manifest.toml` is not checked in, so this override is invisible to git and to CI; Task 11 verifies the upstream state before final sign-off. + +--- + +### Task 5: Event parameters and balance injection — `src/event_models/event_arguments.jl` + +> **Gate:** Task 4b must be complete and its Step 6 `Pkg.develop` bridge active, otherwise `_add_parameters!` fails with a MethodError on `add_param_container!`. + +**Files:** +- Create: `src/event_models/event_arguments.jl` +- Modify: `src/PowerOperationsModels.jl` (include), `test/test_utils/mock_operation_models.jl` (enable `add_event_model`) +- Test: `test/test_events.jl` + +**Interfaces:** +- Consumes: Tasks 1–2 symbols; IOM's `add_param_container!(container, ::Type{T<:EventParameter}, ::Type{U}, ::Type{V}, axs...)`; `IOM.get_multiplier_array_data`, `IOM.get_parameter_array_data`, `IOM._set_multiplier_at!`, `IOM._set_parameter_at!` (existing POM reaches); `_balance_expression_targets` and `_apply_term_to_targets!` (`src/common_models/add_to_expression.jl:30-98`); `get_rebuild_model`, `get_settings`, `has_container_key` (same usage as `src/common_models/add_parameters.jl:17-46`). +- Produces: `add_parameters!(container, ::Type{T}, devices, device_model, event_model::EventModel)`, `_add_parameters!(container, ::T<:EventParameter, devices, device_model, event_model)`, `add_to_expression!(container, ::Type{T<:SystemBalanceExpressions}, ::Type{U<:EventParameter}, devices, device_model, network_model)`, and the specific `add_event_arguments!` methods that override the no-op stub in `src/core/feedforward_interface.jl:51-58`. Tasks 6–9 rely on these. + +- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: + +```julia +@testset "Event parameters via mock construct - ThermalStandard UC" begin + device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) + model = PSI.mock_decision_model_from_system_name("c_sys5_uc") # see note below + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + @test !isnothing( + IOM.get_parameter(container, AvailableStatusParameter(), PSY.ThermalStandard), + ) + @test !isnothing( + IOM.get_parameter( + container, + AvailableStatusChangeCountdownParameter(), + PSY.ThermalStandard, + ), + ) + param_array = + IOM.get_parameter_array(container, AvailableStatusParameter(), PSY.ThermalStandard) + # Initial availability is 1.0 for every (device, t) + @test all(IOM.jump_value.(param_array.data) .== 1.0) +end +``` + +Note: `mock_decision_model_from_system_name` is a placeholder for however existing POM tests build a `DecisionModel{MockOperationProblem}` — copy the exact construction from an existing `mock_construct_device!` caller (`grep -n -B5 "mock_construct_device!" test/test_device_source_constructors.jl | head -12`) and use the same helper (likely `PSI.DecisionModel(MockOperationProblem, ...)`-style via a `mock_*` factory in `test/test_utils/mock_operation_models.jl`). +Likewise confirm accessor names `IOM.get_parameter`, `IOM.get_parameter_array`, `IOM.jump_value` against usage in existing POM tests (`grep -rn "get_parameter_array\|jump_value" test/test_utils/model_checks.jl | head -5`) and match. + +- [ ] **Step 2: Run to verify failure.** Expected: FAIL — `mock_construct_device!` currently errors when `add_event_model = true` ("Event models are not supported in InfrastructureOptimizationModels..."). + +- [ ] **Step 3: Implement the parameter machinery.** Create `src/event_models/event_arguments.jl` with: + +```julia +################################################################################# +# Event parameter creation (ArgumentConstructStage) +################################################################################# + +function add_parameters!( + container::OptimizationContainer, + ::Type{T}, + devices::U, + device_model::DeviceModel{D, W}, + event_model::EventModel{V, X}, +) where { + T <: ParameterType, + U <: Vector{D}, + V <: PSY.Contingency, + W <: AbstractDeviceFormulation, + X <: AbstractEventCondition, +} where {D <: PSY.Component} + if get_rebuild_model(get_settings(container)) && has_container_key(container, T, D) + return + end + _add_parameters!(container, T(), devices, device_model, event_model) + return +end + +function _add_parameters!( + container::OptimizationContainer, + ::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) + parameter_container = add_param_container!( + container, + T, + U, + V, + PSY.get_name.(devices), + 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 + +################################################################################# +# Offset parameters into the system balance expressions. +# One method for every network family: `_balance_expression_targets` resolves the +# system/area/nodal targets per network model (this replaces PSI's four +# per-network methods). +################################################################################# + +function add_to_expression!( + container::OptimizationContainer, + ::Type{T}, + ::Type{U}, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + device_model::DeviceModel{V, W}, + network_model::NetworkModel{X}, +) where { + T <: SystemBalanceExpressions, + U <: EventParameter, + V <: PSY.StaticInjection, + W <: AbstractDeviceFormulation, + X <: AbstractNetworkModel, +} + param_array = get_parameter_array(container, U(), V) + multiplier = get_parameter_multiplier_array(container, U(), V) + time_steps = get_time_steps(container) + for d in devices + targets = _balance_expression_targets(container, T, network_model, d) + name = PSY.get_name(d) + for t in time_steps + _apply_term_to_targets!(targets, param_array[name, t], multiplier[name, t], t) + end + end + return +end + +################################################################################# +# add_event_arguments! — overrides the no-op stub in core/feedforward_interface.jl +# for the injector families. No-ops when the DeviceModel has no events attached. +################################################################################# + +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 +``` + +Verify accessor availability: `get_parameter_array(container, ::EventParameter, ::Type)` and `get_parameter_multiplier_array(container, ::EventParameter, ::Type)` — check `grep -rn "get_parameter_multiplier_array" src/ | head -3`; if POM does not already use them, they are IOM exports (same names PSI used) — confirm with `julia --project=test -e 'using InfrastructureOptimizationModels; println(isdefined(InfrastructureOptimizationModels, :get_parameter_multiplier_array))'`. +`get_entry_type(key)` here is the Task 1 method for `EventKey`. + +- [ ] **Step 4: Wire the include.** In `src/PowerOperationsModels.jl`, find the last `include("common_models/...")` line and insert after it: + +```julia +include("event_models/event_arguments.jl") +``` + +- [ ] **Step 5: Enable the mock path.** In `test/test_utils/mock_operation_models.jl:116-133`, replace the `if add_event_model ... error(...) end` block with (adapted from PSI `test/test_utils/mock_operation_models.jl:114-131`, but using the real `set_event_model!` API instead of assigning the `events` field): + +```julia + if add_event_model + sys = IOM.get_system(problem) + device_type = IOM.get_component_type(model) + event_device = first(PSY.get_components(device_type, sys)) + transition_data = PSY.FixedForcedOutage(; outage_status = 0.0) + PSY.add_supplemental_attribute!(sys, event_device, transition_data) + mock_event_key = EventKey(PSY.FixedForcedOutage, device_type) + mock_event_model = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + set_event_model!(model, mock_event_key, mock_event_model) + end +``` + +Confirm `IOM.get_component_type(model)` works on a `DeviceModel` (check `grep -rn "get_component_type" src/operation/template_validation.jl | head -2` for the established accessor and reuse it). + +- [ ] **Step 6: Run the Task 5 test.** Expected: PASS. Also rerun the whole `test/test_events.jl` and `julia --project=test test/runtests.jl test_device_thermal_generation_constructors` (no regressions from the mock change). +- [ ] **Step 7:** Formatter. + +--- + +### Task 6: Load and FixedOutput argument variants (offset parameters) + +**Files:** +- Modify: `src/event_models/event_arguments.jl` +- Test: `test/test_events.jl` + +**Interfaces:** +- Consumes: Task 5 machinery; load formulations `StaticPowerLoad`, `PowerLoadDispatch`, `PowerLoadInterruption` (`src/core/formulations.jl:65-75`); `FixedOutput` (IOM); network abstracts `AbstractActivePowerModel`, `AbstractReactivePowerNetworkModel` (`src/PowerOperationsModels.jl:33-41`); `ActivePowerBalance`, `ReactivePowerBalance`. +- Produces: `add_event_arguments!` methods for loads and `FixedOutput` that additionally create `ActivePowerOffsetParameter` (and `ReactivePowerOffsetParameter` on reactive-capable networks) and inject them into the balance expressions. + +- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: + +```julia +@testset "Event arguments for loads add offset parameters" begin + device_model = DeviceModel(PSY.PowerLoad, StaticPowerLoad) + model = # same mock DecisionModel construction as the Task 5 testset, system "c_sys5_uc" + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + @test !isnothing( + IOM.get_parameter(container, ActivePowerOffsetParameter(), PSY.PowerLoad), + ) + # CopperPlate mock network -> active power balance expression contains the offset param. + # AvailableStatus/Countdown params exist too. + @test !isnothing( + IOM.get_parameter(container, AvailableStatusParameter(), PSY.PowerLoad), + ) +end +``` + +- [ ] **Step 2: Run to verify failure.** Expected: FAIL — the generic `StaticInjection` method from Task 5 runs (no offset parameter is created), so `get_parameter` for `ActivePowerOffsetParameter` errors/returns nothing. + +- [ ] **Step 3: Implement.** Append to `src/event_models/event_arguments.jl` four methods (adapted from PSI `contingency_arguments.jl:30-230`; PSI's `PM.AbstractActivePowerModel` → `AbstractActivePowerModel`, `PM.AbstractPowerModel` → `AbstractReactivePowerNetworkModel`): + +```julia +const _EventLoadFormulations = + Union{StaticPowerLoad, PowerLoadDispatch, PowerLoadInterruption} + +function _add_event_offset_arguments!( + container::OptimizationContainer, + devices_with_attributes::Vector{U}, + device_model::DeviceModel, + network_model::NetworkModel, + event_model::EventModel, + with_reactive::Bool, +) where {U <: PSY.StaticInjection} + 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, + ) + if with_reactive + 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, V}, + network_model::NetworkModel{<:AbstractActivePowerModel}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: _EventLoadFormulations, +} 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") + _add_event_offset_arguments!( + container, + devices_with_attributes, + device_model, + network_model, + event_model, + false, + ) + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, + network_model::NetworkModel{<:AbstractReactivePowerNetworkModel}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: _EventLoadFormulations, +} 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") + _add_event_offset_arguments!( + container, + devices_with_attributes, + device_model, + network_model, + event_model, + true, + ) + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, FixedOutput}, + network_model::NetworkModel{<: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") + _add_event_offset_arguments!( + container, + devices_with_attributes, + device_model, + network_model, + event_model, + false, + ) + end + return +end + +function add_event_arguments!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, FixedOutput}, + network_model::NetworkModel{<:AbstractReactivePowerNetworkModel}, +) 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") + _add_event_offset_arguments!( + container, + devices_with_attributes, + device_model, + network_model, + event_model, + true, + ) + end + return +end +``` + +The `_add_event_offset_arguments!` helper is a POM addition (PSI repeats the body four times); it is private to this file. + +- [ ] **Step 4: Ambiguity check.** The load methods overlap the Task 5 generic (`U<:StaticInjection`, unconstrained network) and the `FixedOutput` methods overlap both. + +Run: `julia --project=test -e 'using Test, PowerOperationsModels; println(length(detect_ambiguities(PowerOperationsModels)))'` +Expected: same count as before this task (measure on `main` first if unsure; new count must not increase). +If new ambiguities appear between the load and `FixedOutput` methods (a `DeviceModel{PowerLoad, FixedOutput}` matches both), add tie-breaker methods `add_event_arguments!(container, devices, ::DeviceModel{U, FixedOutput}, ::NetworkModel{<:AbstractActivePowerModel}) where {U <: PSY.PowerLoad}` (and the reactive twin) that forward to the `FixedOutput` behavior. + +- [ ] **Step 5: Run the test to verify it passes.** Expected: PASS. +- [ ] **Step 6:** Formatter; rerun `test/test_events.jl` in full. + +--- + +### Task 7: Core event constraints — `src/event_models/event_constraints.jl` + +**Files:** +- Create: `src/event_models/event_constraints.jl` +- Modify: `src/PowerOperationsModels.jl` (include + constraint exports) +- Test: `test/test_events.jl` + +**Interfaces:** +- Consumes: `ActivePowerOutageConstraint`, `ReactivePowerOutageConstraint` (`src/core/constraints.jl:631-633`); `add_parameterized_upper_bound_range_constraints` (same call shape as `src/static_injector_models/hydro_generation.jl:614-622`); `ActivePowerRangeExpressionUB`, `ActivePowerVariable`, `ReactivePowerVariable`; `has_service_model` (`src/PowerOperationsModels.jl:160`); `add_constraints_container!`, `get_parameter_array`, `get_parameter_multiplier_array`, `get_jump_model`, `get_time_steps`. +- Produces: `add_event_constraints!` methods for `PSY.ThermalGen`, `PSY.RenewableGen`, `PSY.ElectricLoad` (× active-only / reactive-capable networks); `add_reactive_power_contingency_constraint(...)` and `_get_reactive_power_upper_bound(device)`. Task 8 reuses `add_reactive_power_contingency_constraint` exactly as named. + +- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: + +```julia +@testset "Event constraints - thermal UC counts and coefficients" begin + device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) + model = # same mock DecisionModel construction as Task 5, system "c_sys5_uc" + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + cons = IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + PSY.ThermalStandard, + ) + n_thermal_with_event = 1 # mock attaches the outage to exactly one device + time_steps = IOM.get_time_steps(container) + @test size(cons)[1] == n_thermal_with_event + @test size(cons)[2] == length(time_steps) + # Coefficient check: constraint is expr(p) - ub * status <= 0 with status = 1.0 + # (params are plain Float64 in a non-recurrent build, so the RHS is baked in). + c1 = JuMP.constraint_object(cons[axes(cons)[1][1], 1]) + @test c1.set isa MOI.LessThan{Float64} +end +``` + +Confirm `IOM.get_constraint` naming against existing tests (`grep -rn "get_constraint(" test/test_utils/model_checks.jl | head -3`). + +- [ ] **Step 2: Run to verify failure.** Expected: FAIL — no `ActivePowerOutageConstraint` container exists (the stub `add_event_constraints!` no-ops). + +- [ ] **Step 3: Implement.** Create `src/event_models/event_constraints.jl` (adapted from PSI `contingency_constraints.jl`; network bounds swapped to POM abstracts; **`PSY.SU` added to every convertible getter**): + +```julia +################################################################################# +# Event outage constraints (ModelConstructStage). Overrides the no-op stub in +# core/feedforward_interface.jl for the supported injector families. +################################################################################# + +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 <: 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 <: AbstractReactivePowerNetworkModel, +} 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 <: 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 = + has_service_model(device_model) ? ActivePowerRangeExpressionUB : + ActivePowerVariable + 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 <: AbstractReactivePowerNetworkModel, +} 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 = + has_service_model(device_model) ? ActivePowerRangeExpressionUB : + ActivePowerVariable + 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 <: 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 <: AbstractReactivePowerNetworkModel, +} 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 + +################################################################################# +# Quadratic reactive-power outage constraint: q^2 <= ub * status +################################################################################# + +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 <: AbstractReactivePowerNetworkModel, +} + 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, + param::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, param, 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 + +_get_reactive_power_upper_bound(device::PSY.StaticInjection) = begin + limits = PSY.get_reactive_power_limits(device, PSY.SU) + max(limits.max^2, limits.min^2) +end + +_get_reactive_power_upper_bound(device::PSY.ElectricLoad) = + PSY.get_max_reactive_power(device, PSY.SU)^2 +``` + +- [ ] **Step 4: Wire include and exports.** Add after the Task 5 include: + +```julia +include("event_models/event_constraints.jl") +``` + +Check whether `ActivePowerOutageConstraint`/`ReactivePowerOutageConstraint`/`ActivePowerPumpOutageConstraint`/the four event parameter types are already exported (`grep -n "OutageConstraint\|AvailableStatus\|OffsetParameter" src/PowerOperationsModels.jl`); export any that are missing. + +- [ ] **Step 5: Run the test.** Expected: PASS. +Then add and run two more testsets following the identical pattern: renewable (`DeviceModel(PSY.RenewableDispatch, RenewableFullDispatch)`, expect `ActivePowerVariable` LHS since no service model) and load (`DeviceModel(PSY.PowerLoad, PowerLoadDispatch)`, expect the constraint on `ActivePowerVariable`). +Use the fixture each device type exists in (`c_sys5_re` for renewables, `c_sys5` for loads — confirm with `grep -rn "c_sys5_re" test/test_device_renewable_generation_constructors.jl | head -2`). +- [ ] **Step 6:** Formatter; ambiguity count check as in Task 6 Step 4. + +--- + +### Task 8: Hydro and storage event constraints + +**Files:** +- Modify: `src/event_models/event_constraints.jl` +- Test: `test/test_events.jl` + +**Interfaces:** +- Consumes: Task 7's `add_reactive_power_contingency_constraint`; `ActivePowerPumpOutageConstraint` (`src/core/constraints.jl:881`), `ActivePowerPumpVariable` (`src/core/variables.jl:667`), `ActivePowerInVariable`, `ActivePowerOutVariable` (storage); `PSY.HydroGen`, `PSY.HydroPumpTurbine`, `PSY.EnergyReservoirStorage`. +- Produces: `add_event_constraints!` for `PSY.HydroGen` (×2 networks), `PSY.HydroPumpTurbine` (×2), `PSY.EnergyReservoirStorage` (×2); helpers `add_pump_turbine_active_power_contingency_constraints!` and `add_input_output_active_power_contingency_constraints!`. + +- [ ] **Step 1: Write the failing tests.** Append to `test/test_events.jl`: + +```julia +@testset "Event constraints - hydro" begin + device_model = DeviceModel(PSY.HydroDispatch, HydroDispatchRunOfRiver) + model = # mock DecisionModel construction, hydro fixture (see note) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + @test !isnothing( + IOM.get_constraint(container, ActivePowerOutageConstraint(), PSY.HydroDispatch), + ) +end + +@testset "Event constraints - storage" begin + device_model = DeviceModel(PSY.EnergyReservoirStorage, StorageDispatchWithReserves) + model = # mock DecisionModel construction, storage fixture (see note) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + cons_in = IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + PSY.EnergyReservoirStorage, + "input", + ) + cons_out = IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + PSY.EnergyReservoirStorage, + "output", + ) + @test !isnothing(cons_in) + @test !isnothing(cons_out) +end +``` + +Fixture and formulation notes: find the hydro fixture and formulation names used by `test/test_device_hydro_constructors.jl` (`grep -n "build_system\|DeviceModel(" test/test_device_hydro_constructors.jl | head -6`) and the storage equivalents in `test/test_storage_device_models.jl`-style files (`grep -rn "EnergyReservoirStorage" test/ | head -5`); use the same names. +The meta-string variant of `IOM.get_constraint` (`"input"`/`"output"`) — confirm the accessor arity in `test/test_utils/model_checks.jl` usage; if metas are addressed differently, match it. +If POM has a `PSY.HydroPumpTurbine` formulation and fixture, add a third testset for the pump constraint (`ActivePowerPumpOutageConstraint`); if no fixture exists (`grep -rn "HydroPumpTurbine" test/ | head -3` empty), note it in the test file as uncovered and still implement the methods. + +- [ ] **Step 2: Run to verify failure.** Expected: FAIL (constraints don't exist — the generic stub no-ops for hydro/storage). + +- [ ] **Step 3: Implement.** Append to `src/event_models/event_constraints.jl` (ported from HPS/SSS `src/contingency_model.jl` with `PSY.SU` unit fixes and POM network abstracts; the `@assert !isempty` in SSS becomes a loud `error` to match the rest of the file): + +```julia +################################################################################# +# Hydro (ported from HydroPowerSimulations src/contingency_model.jl) +################################################################################# + +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 <: AbstractActivePowerModel, +} where {U <: PSY.HydroGen} + 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 <: AbstractReactivePowerNetworkModel, +} where {U <: PSY.HydroGen} + 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 +``` + +Note on the HydroGen active-power LHS: HPS uses `ActivePowerRangeExpressionUB`. +If a targeted hydro formulation in POM does not create that expression, the constraint call will throw at build — run the Task 8 hydro testset against each hydro formulation POM's constructors wire (`grep -n "DeviceModel{" src/static_injector_models/hydrogeneration_constructor.jl | head`), and for any formulation without the UB range expression use `ActivePowerVariable` as the LHS in a formulation-specific method, mirroring the renewable pattern. + +```julia +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 <: AbstractActivePowerModel, +} where {U <: PSY.HydroPumpTurbine} + 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_pump_turbine_active_power_contingency_constraints!( + container, + devices_with_attributes, + device_model, + ) + 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 <: AbstractReactivePowerNetworkModel, +} where {U <: PSY.HydroPumpTurbine} + 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_pump_turbine_active_power_contingency_constraints!( + container, + devices_with_attributes, + device_model, + ) + add_reactive_power_contingency_constraint( + container, + ReactivePowerOutageConstraint, + ReactivePowerVariable, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_pump_turbine_active_power_contingency_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, +} where {U <: PSY.HydroPumpTurbine} + names = PSY.get_name.(devices) + time_steps = get_time_steps(container) + array_active_power = get_variable(container, ActivePowerVariable(), U) + array_active_power_pump = get_variable(container, ActivePowerPumpVariable(), U) + constraint_active_power = add_constraints_container!( + container, + ActivePowerOutageConstraint(), + U, + names, + time_steps, + ) + constraint_active_power_pump = add_constraints_container!( + container, + ActivePowerPumpOutageConstraint(), + U, + names, + time_steps, + ) + param_array = get_parameter_array(container, AvailableStatusParameter(), U) + jump_model = get_jump_model(container) + for device in devices, t in time_steps + name = PSY.get_name(device) + ub_active_power = PSY.get_active_power_limits(device, PSY.SU).max + constraint_active_power[name, t] = JuMP.@constraint( + jump_model, + array_active_power[name, t] <= ub_active_power * param_array[name, t] + ) + ub_active_power_pump = PSY.get_active_power_limits_pump(device, PSY.SU).max + constraint_active_power_pump[name, t] = JuMP.@constraint( + jump_model, + array_active_power_pump[name, t] <= + ub_active_power_pump * param_array[name, t] + ) + end + return +end + +################################################################################# +# Storage (ported from StorageSystemsSimulations src/contingency_model.jl) +################################################################################# + +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 <: AbstractActivePowerModel, +} where {U <: PSY.EnergyReservoirStorage} + 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_input_output_active_power_contingency_constraints!( + container, + devices_with_attributes, + device_model, + ) + 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 <: AbstractReactivePowerNetworkModel, +} where {U <: PSY.EnergyReservoirStorage} + 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_input_output_active_power_contingency_constraints!( + container, + devices_with_attributes, + device_model, + ) + add_reactive_power_contingency_constraint( + container, + ReactivePowerOutageConstraint, + ReactivePowerVariable, + AvailableStatusParameter, + devices_with_attributes, + device_model, + W, + ) + end + return +end + +function add_input_output_active_power_contingency_constraints!( + container::OptimizationContainer, + devices::T, + device_model::DeviceModel{U, V}, +) where { + T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, + V <: AbstractDeviceFormulation, +} where {U <: PSY.EnergyReservoirStorage} + names = PSY.get_name.(devices) + time_steps = get_time_steps(container) + array_in = get_variable(container, ActivePowerInVariable(), U) + array_out = get_variable(container, ActivePowerOutVariable(), U) + constraint_input = add_constraints_container!( + container, + ActivePowerOutageConstraint(), + U, + names, + time_steps; + meta = "input", + ) + constraint_output = add_constraints_container!( + container, + ActivePowerOutageConstraint(), + U, + names, + time_steps; + meta = "output", + ) + param_array = get_parameter_array(container, AvailableStatusParameter(), U) + jump_model = get_jump_model(container) + for device in devices, t in time_steps + name = PSY.get_name(device) + ub_input = PSY.get_input_active_power_limits(device, PSY.SU).max + constraint_input[name, t] = JuMP.@constraint( + jump_model, + array_in[name, t] <= ub_input * param_array[name, t] + ) + ub_output = PSY.get_output_active_power_limits(device, PSY.SU).max + constraint_output[name, t] = JuMP.@constraint( + jump_model, + array_out[name, t] <= ub_output * param_array[name, t] + ) + end + return +end +``` + +- [ ] **Step 4: Run the tests.** Expected: PASS (with fixture/formulation names resolved per Step 1 notes). +- [ ] **Step 5:** Formatter; ambiguity count check; run `julia --project=test test/runtests.jl test_device_hydro_constructors` for regression. + +--- + +### Task 9: End-to-end build/solve tests and forced-outage behavior + +**Files:** +- Test: `test/test_events.jl` + +**Interfaces:** +- Consumes: everything from Tasks 1–8; `HiGHS_optimizer` (`test/test_utils/solver_definitions.jl`); PSB fixtures. + +- [ ] **Step 1: Full-template build+solve across networks.** Append to `test/test_events.jl`: + +```julia +@testset "E2E: thermal UC with FixedForcedOutage event - $(net)" for net in + (CopperPlateNetworkModel, PTDFNetworkModel, DCPNetworkModel) + sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") + thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) + attach_fixed_forced_outage!(sys, thermal) + template = get_thermal_dispatch_template_network(NetworkModel(net)) + em = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:outage_status => "outage_profile"), + ) + set_event_model!(template, em) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + res = IOM.OptimizationProblemOutputs(model) + # Event parameters are written to results (should_write_resulting_value = true) + @test "AvailableStatusParameter__ThermalStandard" in + IOM.list_parameter_names(res) +end +``` + +Confirm `solve!` vs `IOM.solve!` and `list_parameter_names` against `test/test_model_decision.jl` usage and match. +Add an ACP variant testset (`ACPNetworkModel` with `ipopt_optimizer`) asserting the quadratic `ReactivePowerOutageConstraint` exists: +`IOM.get_constraint(IOM.get_optimization_container(model), ReactivePowerOutageConstraint(), PSY.ThermalStandard, "ub")`. + +- [ ] **Step 2: Forced-zero behavior.** Append: + +```julia +@testset "Forced outage drives device output to zero" begin + device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) + model = # mock DecisionModel construction as in Task 5, system "c_sys5_uc" + mock_construct_device!( + model, + device_model; + add_event_model = true, + built_for_recurrent_solves = true, + ) + container = IOM.get_optimization_container(model) + param_array = IOM.get_parameter_array( + container, + AvailableStatusParameter(), + PSY.ThermalStandard, + ) + outaged_name = axes(param_array)[1][1] + for t in axes(param_array)[2] + JuMP.fix(param_array[outaged_name, t], 0.0; force = true) + end + jm = IOM.get_jump_model(container) + JuMP.set_optimizer(jm, HiGHS.Optimizer) + JuMP.set_silent(jm) + JuMP.optimize!(jm) + @test JuMP.termination_status(jm) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) + p = IOM.get_variable(container, ActivePowerVariable(), PSY.ThermalStandard) + @test all( + abs(JuMP.value(p[outaged_name, t])) <= 1e-6 for t in axes(p)[2] + ) +end +``` + +In recurrent-solve mode the parameters are fixed JuMP variables, so `JuMP.fix` works; the UC formulation lets the unit commit off, keeping the model feasible with zero output. +If the mock container carries no objective, `optimize!` solves a feasibility problem — that is sufficient; the binding `p ≤ max·status = 0` constraint forces the result regardless of objective. +If the mock construct does not build the balance/objective needed for feasibility, relax the test to: assert the `ActivePowerOutageConstraint` row for `(outaged_name, t)` has its RHS/parameter term at `0.0` after fixing (inspect via `JuMP.constraint_object`). + +- [ ] **Step 3: Run the whole events file.** + +Run: `julia --project=test -e 'include("test/includes.jl"); include("test/test_events.jl")'` +Expected: all testsets PASS. + +- [ ] **Step 4: Run the file under the parallel runner** (fresh-module context catches `Main`-only bugs): + +Run: `julia --project=test test/runtests.jl test_events` +Expected: PASS. + +- [ ] **Step 5:** Formatter. + +--- + +### Task 10: Documentation + +**Files:** +- Modify: `docs/src/reference/public.md` (and `docs/src/reference/formulation_library.md` if it enumerates constraints/parameters) + +- [ ] **Step 1: Register new public symbols.** Open `docs/src/reference/public.md` and determine its convention (explicit `@docs` blocks vs `@autodocs`). +If symbols are listed explicitly, add: `EventModel`, `EventKey`, `AbstractEventCondition`, `ContinuousCondition`, `PresetTimeCondition`, `StateVariableValueCondition`, `DiscreteEventCondition`, `set_event_model!`, `get_event_models`, `supports_events`, `get_empty_timeseries_mapping`, `get_event_type`, `get_event_condition`, `get_attribute_device_map`, plus the parameter/constraint types if other parameters/constraints are listed there (`AvailableStatusParameter`, `AvailableStatusChangeCountdownParameter`, `ActivePowerOffsetParameter`, `ReactivePowerOffsetParameter`, `ActivePowerOutageConstraint`, `ReactivePowerOutageConstraint`, `ActivePowerPumpOutageConstraint`). + +- [ ] **Step 2: Formulation-library entry.** If `docs/src/reference/formulation_library.md` documents device formulations' constraints, add a short "Outage events" subsection: + +```markdown +## Outage events + +Attaching an `EventModel` for a `PSY.Contingency` supplemental attribute (e.g. +`FixedForcedOutage`) to a template adds availability parameters and outage +constraints to every supported device carrying the attribute. + +Parameters (per device and time step): `AvailableStatusParameter` (1 = available, +initialized to 1), `AvailableStatusChangeCountdownParameter`, and for loads and +`FixedOutput` devices the balance offsets `ActivePowerOffsetParameter` / +`ReactivePowerOffsetParameter`. + +Constraints: + +``math +p_{d,t} \le \overline{P}_d \cdot \text{status}_{d,t} +`` + +with the LHS given by the device family (range-expression upper bound for thermal +and hydro, the active power variable for loads and renewables without services, +charge/discharge variables for storage, generation and pumping variables for pump +turbines). Under reactive-power-capable networks the quadratic constraint +``q_{d,t}^2 \le \overline{Q}_d^2 \cdot \text{status}_{d,t}`` is also added. + +The parameter values are constant within a single build; updating them across +solves (outage sampling, countdown projection) is simulation-runtime functionality +that lives outside this package. +``` + +(Match the file's existing math-fence style — ```` ```math ```` fences vs `` ``math `` inline — before pasting.) + +- [ ] **Step 3: Build docs.** + +Run: `julia --project=docs docs/make.jl` +Expected: build completes; no missing-docstring or cross-reference errors. +Fix any failures by adding the flagged docstring or registration. + +- [ ] **Step 4:** Formatter (it formats `docs/src` too). + +--- + +### Task 11: Final gates and plan-file bookkeeping + +**Files:** +- Modify: `.claude/pom_port_plan.md` + +- [ ] **Step 1: Ambiguity gate.** + +Run: `julia --project=test -e 'using Test, PowerOperationsModels; a = detect_ambiguities(PowerOperationsModels); println(length(a)); foreach(println, a)'` +Expected: count identical to `main` baseline (measure by stashing if needed). New ambiguities from `add_event_*` overlaps must be fixed with tie-breaker methods, not ignored. + +- [ ] **Step 2: Full test suite.** + +Run: `julia --project=test test/runtests.jl --jobs=8` +Expected: all files PASS, including `test_events`. Investigate and fix any regression before proceeding (per repo rules, fix unrelated flakiness you hit rather than rerunning around it). + +- [ ] **Step 3: Update the port plan.** In `.claude/pom_port_plan.md`: +correct the stale line 144-147 note ("POM has no `core/event_model.jl` ... only the `AvailableStatusParameter` type exists") to record that the event framework is ported (template-level `set_event_model!`, `src/event_models/`, build-level `test_events.jl`), that hydro/storage/pump-turbine constraint coverage from HPS/SSS is included, and that the remaining PSI-side gap is simulation-runtime only (condition evaluation, sampling, state projection — out of POM scope). +Also update the "Workstream C" line in the execution order accordingly. + +- [ ] **Step 4: Verify the IOM fix is upstream and drop the local override.** Confirm the Task 4b change has merged to IOM `main` (`gh pr list --repo Sienna-Platform/InfrastructureOptimizationModels.jl --state merged --search "SupplementalAttribute"` or ask the user). +Then restore POM's normal resolution and re-verify against the real upstream: + +Run: `julia --project=test -e 'using Pkg; Pkg.free("InfrastructureOptimizationModels"); Pkg.update("InfrastructureOptimizationModels")'` +(if `Pkg.free` errors for a `[sources]`-pinned package, `Pkg.update("InfrastructureOptimizationModels")` alone re-resolves from the pinned branch once the dev entry is removed — check `test/Manifest.toml` no longer holds a local path for IOM). +Then rerun: `julia --project=test test/runtests.jl test_events` +Expected: PASS against IOM `main`. If the IOM PR has not merged yet, leave the `Pkg.develop` bridge in place, report events as blocked-on-IOM-merge, and do not sign off the plan. + +- [ ] **Step 5: Final formatter pass and diff review.** + +```bash +julia --project=scripts/formatter -e 'include("scripts/formatter/formatter_code.jl")' +git add -N src/event_models/ test/test_utils/events_test_utils.jl test/test_events.jl +git status --short +``` + +Expected: only intended files modified/added; everything unstaged (`git add -N` only marks intent); no commits made. + +--- + +## Plan self-review notes (already applied) + +- Spec coverage: package split (Tasks 1–8 POM-side; IOM gets exactly the Task 4b type-bound correction, everything else consumed as-is), template attachment + discovery + TS validation (Tasks 3–4), psy6 units fixes (`PSY.SU` in Tasks 7–8 code), IC exclusion (Task 4 Step 6), hydro/storage/pump coverage (Task 8), E2E + forced-zero behavior (Task 9), docs gate (Task 10), ambiguities + full suite + IOM-merge verification + port-plan bookkeeping (Task 11), concurrency constraint (Global Constraints: forbidden files owned by the transformer plan). +- Deviation from spec, deliberate: constructor count-tests live in `test/test_events.jl` rather than appended to the existing `test_device_*_constructors.jl` files, to keep the events branch free of textual conflicts with the concurrent transformer-refactor branch. Coverage is identical. +- Deviation from spec, deliberate: PSI's four per-network `add_to_expression!` methods collapse into one method built on POM's `_balance_expression_targets` (covers CopperPlate, PTDF/AreaPTDF, AreaBalance, and nodal AC/DCP in one dispatch) — POM grew this abstraction after the spec's inventory was written. +- The `events` field on `PowerOperationsProblemTemplate` is typed `Vector{IOM.AbstractEventModel}` (not `Vector{EventModel}` as the spec sketched) because `core/problem_template.jl` is included before `event_models/`; accessors still return the concrete `EventModel`s. +- Known verification points intentionally delegated to task steps (each has an explicit check command): IOM ownership of `get_entry_type` generics, `get_model(template, T)` accessor name, `PSY.get_time_series` on supplemental attributes, mock `DecisionModel` factory name, fixture names per device family, `IOM.get_constraint` meta arity, hydro formulations lacking `ActivePowerRangeExpressionUB`. diff --git a/.claude/pom_port_plan.md b/.claude/pom_port_plan.md index 8345178e..e5fd6d9d 100644 --- a/.claude/pom_port_plan.md +++ b/.claude/pom_port_plan.md @@ -141,10 +141,17 @@ per file. Annotated where a test is **code-blocked** by a Workstream M/C item. --- ## Workstream C — Tier-0 code blockers in POM (port code, then test) -- **Event framework** — POM has **no `core/event_model.jl`**, no `EventModel`/`FixedForcedOutage` - machinery (only the `AvailableStatusParameter` type exists). Port the event framework - (`AbstractEventCondition` family, FixedForcedOutage time-series application, event extension hooks, - outage projection into decision models), **then** port `test_events.jl` (all 13 testsets). +- **Event framework — ported.** `src/event_models/` (`event_model.jl`, `event_traits.jl`, + `event_arguments.jl`, `event_constraints.jl`) provides the `EventModel`/`AbstractEventCondition` + family, template-level `set_event_model!` attachment, build-time discovery and time-series + validation, and event parameters/constraints for thermal, renewable, load, hydro, pump-turbine, + and storage devices. `test/test_events.jl` (21 testsets) covers construction, traits, template + attachment, discovery/validation errors, exclusion from the initialization problem, per-device + constraint coefficients, and E2E build/solve including forced-zero output under a + `FixedForcedOutage` event. This required one IOM-side fix (a `SupplementalAttribute` type-bound + correction) — see the IOM-merge-dependency note below. The remaining gap versus PSI is + simulation-runtime only: condition evaluation, sampling, and state projection across simulation + steps, all out of scope for POM (no simulation orchestration here). - **MBC variable-tranche-count** and **MBC concavity/convexity validation** — absent; small code adds that unblock the remaining time-varying-tranche and validation MBC tests. @@ -165,5 +172,7 @@ is tracked in POM as the `branches_modeled` trait (already present). 3. **Workstream M small features** (#1549, #1573, #1538, #1614, #1605, #1622, #1566, #1612) — then port the PF-Source / MBC / curtailment tests they unblock. 4. **Workstream G1** (#1617) — track upstream merge; port reserve/service SC layer + its test file. -5. **Workstream C** — event framework → test_events.jl; MBC tranche/concavity → remaining MBC tests. +5. **Workstream C** — event framework and test_events.jl: **done**, pending the IOM `SupplementalAttribute` + bound-fix merge to IOM `main` (local overrides in `test/Project.toml`/`docs/Project.toml` stay until + then); MBC tranche/concavity → remaining MBC tests still open. 6. **DLR (#1559/#1561)** and the **verify** items — scope separately. diff --git a/.claude/specs/2026-07-29-events-port-design.md b/.claude/specs/2026-07-29-events-port-design.md new file mode 100644 index 00000000..6a05609e --- /dev/null +++ b/.claude/specs/2026-07-29-events-port-design.md @@ -0,0 +1,259 @@ +# Events feature port: PSI → POM — design spec + +Date: 2026-07-29. +Status: approved design, pending implementation plan. + +## Goal + +Port the time-series/stochastic outage **events** feature from PowerSimulations.jl (PSI) into PowerOperationsModels.jl (POM), as part of the psy6 work. +POM gains everything portable: the full model-build machinery plus the container types. +The simulation runtime stays in PSI. +InfrastructureOptimizationModels.jl (IOM) requires **one small type-bound correction** (see the package split below); the rest of its domain-neutral scaffolding is consumed as-is. + +## Disambiguation (load-bearing) + +PSI has two unrelated features sharing "contingency"/"outage" vocabulary: + +- **Feature A — outage events** (this spec): `EventModel`, `PSY.FixedForcedOutage` / `PSY.GeometricDistributionForcedOutage` supplemental attributes, availability/countdown/offset parameters, upper-bound outage constraints. + In PSI this lives, confusingly, in `src/contingency_model/`. +- **Feature B — N-1 security-constrained (MODF)**: `DeviceModel.outages`, `supports_outages` formulation trait, post-contingency flow machinery. + Already ported to POM. + This spec does not touch it, and deliberately avoids colliding with it. + +## Decisions (settled during brainstorming) + +1. **Port, not migrate**: PSI `main` stays untouched; POM gains adapted code. PSI adopting POM/IOM is a separate future effort. +2. **POM scope = build + container types** (layers 1+2). Runtime projection (layer 3) stays PSI-only until a state abstraction exists in IOM/POM. +3. **Template-level attachment API** with build-time auto-discovery, mirroring Feature B's outage discovery pattern. +4. **Cleaned-up port** (approach B): fix PSI's known warts at the boundary instead of copying them. +5. **Hydro and storage are in scope**: POM owns those constructors in-repo (PSI does not), so the event methods from HydroPowerSimulations.jl (HPS) and StorageSystemsSimulations.jl (SSS) `src/contingency_model.jl` fold in, with their `test_events.jl` suites as reference behavior. + +## Package split + +### IOM — one type-bound correction (verified 2026-07-30) + +Already present on `main` and consumed as-is: +`AbstractEventModel`, `AbstractEventKey` (`src/core/device_model.jl`), the `DeviceModel.events::Dict{AbstractEventKey, AbstractEventModel}` field, `set_event_model!(::DeviceModel, ...)`, `get_events`, `EventParameter`, and the `add_param_container!` overload for event parameters. + +**Required fix:** the contingency slot of the event parameter machinery is bounded on the wrong IS hierarchy. +`PSY.Contingency <: SupplementalAttribute <: IS.InfrastructureSystemsType`, which is **not** under `IS.InfrastructureSystemsComponent` — but IOM bounds that slot as `IS.InfrastructureSystemsComponent` in `EventParametersAttributes` (`src/core/parameter_container.jl:83-101`) and in the `add_param_container!` event overload (`src/common_models/add_param_container.jl:96`). +Any real call with a contingency type (e.g. `PSY.FixedForcedOutage`) is a MethodError today; the scaffolding has never been exercised downstream. +Fix in IOM (domain-neutral — `IS.SupplementalAttribute` is an IS abstraction): change the bound to `T <: IS.SupplementalAttribute` / `V <: IS.SupplementalAttribute` in both files, and drop the `affected_devices::Vector{T}` field (it has zero readers in IOM; with the corrected bound its name no longer matches its type). +Local clone for this work: `/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl` (branch `mb/events-port`). +POM pins IOM to the floating `main` branch, so no POM pin change is needed — but the IOM PR must merge to `main` before POM CI can build events; local development bridges the gap with a Manifest-only `Pkg.develop` of the clone. + +### POM — gains the portable feature + +- New `src/event_models/` directory (build machinery + container types). +- New parameter/constraint types in `src/core/`. +- `events` field + `set_event_model!` on `PowerOperationsProblemTemplate`. +- Discovery/distribution/validation pass in `operation/template_validation.jl`. +- `add_event_arguments!` / `add_event_constraints!` call sites in the static-injector and storage constructors. + +### PSI — untouched + +Runtime stays: condition evaluation against `SimulationState`, Bernoulli sampling (`SimulationInternal.rng`), `apply_simulation_events!`, `update_decision_state!` / `update_system_state!` event methods, countdown-first parameter-update ordering, the feedforward `has_outage` override, `SimulationSequence(events = ...)` attachment. + +### Out of scope + +- Runtime parameter updates between solves (no IOM/POM state abstraction exists yet). +- Feature B (already in POM). +- Simulation orchestration of any kind (standing POM rule). + +## POM type design and layout + +New directory `src/event_models/` (name kills PSI's `contingency_model/` misnomer): + +### `event_keys.jl` + +`EventKey{T <: PSY.Contingency, U <: PSY.Component} <: IOM.AbstractEventKey` with `meta::String`; errors on abstract `U`. +Accessors `get_entry_type`, `get_component_type`. +Same shape as PSI's (`PSI/src/core/event_keys.jl`), subtyped under IOM's abstract. + +### `event_model.jl` + +`EventModel{D <: PSY.Contingency, B <: AbstractEventCondition} <: IOM.AbstractEventModel`. +Fields: `condition::B`, `timeseries_mapping::Dict{Symbol, Union{String, Nothing}}`, `attribute_device_map::Dict{Base.UUID, Dict{DataType, Set{String}}}`, `attributes::Dict{String, Any}`. +Change from PSI: the map **drops the per-simulation-model outer key** (`Dict{Symbol, ...}` in PSI) — POM builds one model at a time; the model-name dimension is runtime bookkeeping PSI keeps. +Reserved `timeseries_mapping` keys per contingency type (hardcoded, as in PSI `get_empty_timeseries_mapping`): `:outage_status` for `FixedForcedOutage`; `:mean_time_to_recovery`, `:outage_transition_probability` for `GeometricDistributionForcedOutage`. +Condition types, data-only structs under `AbstractEventCondition`: `ContinuousCondition`, `PresetTimeCondition`, `StateVariableValueCondition`, `DiscreteEventCondition`. +POM stores conditions but never evaluates them; evaluation is runtime and stays PSI-side. + +### `event_traits.jl` + +Injector-level capability trait **renamed to `supports_events(::Type{<:PSY.Component})`** (PSI calls it `supports_outages`, which collides with POM's existing Feature-B *formulation* trait). +Allow-list: `ThermalStandard`, `RenewableGen`, `ElectricLoad`, `HydroGen`, `Storage`; default `false` for `StaticInjection`. +Parameter defaults: `get_parameter_multiplier(::EventParameter, ...) = 1.0`; `get_initial_parameter_value` — `AvailableStatusParameter` → 1.0, countdown/offsets → 0.0. + +### `event_arguments.jl` + +The `add_event_arguments!` family, ported from `PSI/src/contingency_model/contingency_arguments.jl`: +generic `StaticInjection`, plus load and `FixedOutput` variants, each × active-power-only / full-AC network. +The event `_add_parameters!` path building parameter containers via IOM's `EventParameter` overload of `add_param_container!`. +The `add_to_expression!` methods injecting `ActivePowerOffsetParameter` / `ReactivePowerOffsetParameter` into `ActivePowerBalance` / `ReactivePowerBalance`, one method per network family — **retargeted from PowerModels abstracts to POM's native network formulation types** (`CopperPlateNetworkModel`, PTDF, `AreaBalanceNetworkModel`, generic AC abstract). +These methods live here, not in `common_models/add_to_expression.jl` (isolation from the concurrent transformer refactor; see Concurrency). + +### `event_constraints.jl` + +The `add_event_constraints!` family, ported from `PSI/src/contingency_model/contingency_constraints.jl`: +`ActivePowerOutageConstraint` via `add_parameterized_upper_bound_range_constraints` with `AvailableStatusParameter`; LHS per family (`ActivePowerRangeExpressionUB` for thermal/hydro, service-model-dependent expression for renewables, `ActivePowerVariable` for loads). +Quadratic `ReactivePowerOutageConstraint` (`q² <= ub · status`) with the reactive-upper-bound helpers, for full-AC networks. +Hydro methods absorbed from HPS `src/contingency_model.jl`; storage methods (including the charge/discharge input-output constraint builder and the storage reactive constraint) absorbed from SSS `src/contingency_model.jl`. + +### Type registrations in `src/core/` — already present (verified 2026-07-29) + +All four parameter types exist (`src/core/parameters.jl:208-223`): `AvailableStatusParameter`, `ActivePowerOffsetParameter`, `ReactivePowerOffsetParameter`, `AvailableStatusChangeCountdownParameter`, all `<: IOM.EventParameter`, with `should_write_resulting_value = true`. +All constraint types exist (`src/core/constraints.jl:631-633, 881`): `EventConstraint <: ConstraintType`, `ActivePowerOutageConstraint`, `ReactivePowerOutageConstraint`, and `ActivePowerPumpOutageConstraint` (hydro pump — relevant to the HPS port). +None are exported and none have consumers yet; the port adds exports and consumers, not the types. +`.claude/pom_port_plan.md`'s note that "only the `AvailableStatusParameter` type exists" is stale — correct it when updating the plan. +All new code uses `PSY.Contingency` bounds uniformly (PSI mixes `PSY.Outage` and `PSY.Contingency`; the cleaned-up port does not). + +### Template integration + +`PowerOperationsProblemTemplate` (POM-owned, `src/core/problem_template.jl`) gains `events::Vector{EventModel}`. +API: `set_event_model!(template, event_model)`, `get_event_models(template)`. + +### Exports + +`EventModel`, `EventKey`, `AbstractEventCondition` + the four condition types, the four parameter types, the two constraint types, `set_event_model!`, `supports_events`. +All exported symbols get docstrings and API-page registration (docs build is a gate). +Include order in `src/PowerOperationsModels.jl`: key/type files before `event_models/` consumers, `event_models/` before the constructors that call into it. + +## Attachment, discovery, and build flow + +### User flow + +```julia +event = EventModel(PSY.FixedForcedOutage, ContinuousCondition; + timeseries_mapping = Dict(:outage_status => "outage_profile_1")) +set_event_model!(template, event) +model = DecisionModel(template, sys) +build!(model) +``` + +### Discovery and distribution (build-time) + +A new `_build_device_model_events!(template, system)` in `operation/template_validation.jl`, running alongside the existing `_build_device_model_outages!` (Feature B). +It operates on the build copy of the template, so nothing leaks back to the caller's `DeviceModel`s — the same isolation Feature B guarantees (PSI regression: `test_problem_template.jl`). +For each event model on the template: + +1. Collect the system's supplemental attributes of the event's contingency type; **error loudly if none exist** (PSI behavior and POM's no-silent-skip rule). +2. For each attribute, resolve attached devices, group names by concrete device type; keep only types passing `supports_events` that have a `DeviceModel` in the template; populate `event_model.attribute_device_map[uuid][device_type]`. +3. Call IOM's `set_event_model!(device_model, EventKey(contingency_type, device_type), event_model)` on each matching `DeviceModel`. + +### Time-series validation + +Ported from PSI `_validate_event_timeseries_data` (`simulation_sequence.jl:215-249`), run in the same pass: +every mapped name must resolve to a `SingleTimeSeries` **on the supplemental attribute** (not the device); +mapping keys must be in the reserved set for the contingency type; +`FixedForcedOutage` requires a non-nothing `:outage_status`. + +### Build stages + +POM's two-stage convention. +`ArgumentConstructStage`: constructors call `add_event_arguments!` unconditionally; it no-ops when the `DeviceModel.events` dict is empty (PSI's pattern). +It creates the availability/countdown/offset parameter containers and seeds initial values and multipliers; offset parameters are injected into balance expressions before constraints consume them (`add_expressions!` before `add_constraints!` invariant). +`ModelConstructStage`: `add_event_constraints!` adds the outage upper-bound constraints, and the quadratic reactive constraint under AC networks. +Inside `add_event_*`, devices are filtered by `PSY.has_supplemental_attributes(d, event_type)`; an empty result **errors** (never silently skips). +Sparse event-parameter containers remain a hard error (PSI behavior). + +Constructor call sites (both stages): +`thermalgeneration_constructor.jl`, `renewablegeneration_constructor.jl`, `load_constructor.jl`, `source_constructor.jl` (args only, as PSI), `hydrogeneration_constructor.jl`, `energy_storage_models/storage_constructor.jl`, plus the `FixedOutput` paths (args only, no constraints). +PSI's per-formulation coverage (which formulations get which calls) is the reference; hydro/storage coverage follows HPS/SSS. + +### psy6 adaptations (do not copy PSI verbatim) + +- Every PSY getter on a convertible field passes `PSY.SU` explicitly (reactive-power upper-bound helpers, active-power limits — the ported PSI code predates the stateless-units rework). +- Network dispatch on POM's native network formulation types, not `PM.Abstract*PowerModel`. +- Supplemental-attributes accessor surface verified against psy6 PSY during implementation (`has_supplemental_attributes`, `get_supplemental_attributes`, `get_supplemental_attribute`, `get_components(sys, event)`, attribute-attached `get_time_series`); the attribute types exist in `PSY/src/outages.jl`. +- `Test.detect_ambiguities` after adding the overlapping `add_event_*` signatures (generic `StaticInjection` vs load/`FixedOutput` specializations). + +### Initial conditions + +Events are **excluded** from the initial-conditions template. +PSI encodes this as an omission plus a comment (`initial_conditions/initialization.jl:31`); POM documents it at the IC-template construction site and tests it. + +## Testing + +Test scope is build-level; POM has no `Simulation`. +PSI `test/test_events.jl` (13 testsets), HPS `test/test_events.jl`, and SSS `test/test_events.jl` are **reference behavior** (which formulations are event-aware, what gets created, forced-zero semantics), not portable code — they require simulation runtime. + +### `test/test_events.jl` (new) + +- Template attachment + discovery: `attribute_device_map` contents against a PSB fixture with attributes attached; loud error when no supplemental attributes exist; unknown `timeseries_mapping` keys rejected; missing `:outage_status` for `FixedForcedOutage` rejected; events excluded from the IC template; build-copy isolation (no leak to caller's `DeviceModel`s). +- Build assertions per device family: parameter containers exist with correct axes and initial values (status 1.0, countdown/offsets 0.0); `ActivePowerOutageConstraint` present with coefficient-level checks against hand-computed references (MODF-suite pattern); quadratic `ReactivePowerOutageConstraint` under AC; offset parameters appear in balance expressions for load/`FixedOutput`. +- Forced-outage build variant: a model built with the status parameter at 0 solves with zero output for the affected device. + +### Constructor tests + +Extend the existing per-formulation constructor tests with an `add_event_model = true` path, ported from PSI's `mock_construct_device!` but going through the real `set_event_model!` API (PSI's mock assigns the `events` field directly). +Assert variable/constraint/parameter counts for: thermal (UC + dispatch variants), renewable, loads (static/dispatch/interruption), `FixedOutput`, source, hydro, storage — the last two validated against HPS/SSS expectations. + +### Coverage matrix and hygiene + +Device families × network formulations: CopperPlate, PTDF, native DCP, native ACP (reactive path). +Helpers that attach attributes + `SingleTimeSeries` to PSB systems go in `test/test_utils/` (adapted from the build-relevant parts of PSI `test/test_utils/events_simulation_utils.jl`; PSI's file is not modified — HPS/SSS consume it). +Build warnings assert via `operation_problem.log`, not `@test_logs`. +`Test.detect_ambiguities` gate. + +## Documentation + +Docstrings on all exported symbols; API-page registration; docs must build. +A short formulation-docs entry for the event parameters/constraints, matching existing POM formulation docs. + +## Concurrency and sequencing with the transformer-refactor work path + +Concurrent plans: `.claude/plans/2026-07-26-transformer-refactor.md` (POM, HELD behind Task 0), `.claude/plans/2026-07-27-pnm-pf-prerequisites.md` (PNM/PF), `.claude/plans/2026-07-27-psy6-integration-roadmap.md` (stage overview). + +### File-level overlap: small and append-only + +The events port is mostly new files plus injector/storage constructor call sites; the transformer refactor lives in the branch/network layer. +Shared files and risk: + +| File | Transformer plan | Events port | Risk | +|---|---|---|---| +| `src/PowerOperationsModels.jl` | includes/exports | includes/exports | textual only | +| `src/core/constraints.jl` | adds types | no additions needed (event constraint types already present) | none | +| `src/operation/template_validation.jl` | Task 8 edits Feature-B outage discovery | adds separate `_build_device_model_events!` | low; keep the events pass self-contained | +| thermal constructor tests, docs API page | light touches | extends | textual only | + +Semantic isolation is by construction: `supports_events` never touches Feature B's `supports_outages` (Task 8's territory); event `add_to_expression!` methods live in `src/event_models/`, not `common_models/add_to_expression.jl` (which the transformer plan modifies); events dispatch on abstract network formulations, indifferent to transformer geometry internals. + +### The load-restoration gate (the real constraint) + +POM's `[sources]` pin upstream packages to floating branch refs with no committed Manifest, so every fresh resolve pulls branch tips. +PSY's transformer refactor (#1714, merged 2026-07-26 into `psy6`) deleted/renamed types POM references in top-level method signatures (`PhaseShiftingTransformer`, `TapTransformer`, `Transformer2W`, `Transformer3W`, and getters). +Consequently `using PowerOperationsModels` fails at load on fresh resolves (recorded baseline: `UndefVarError: PhaseShiftingTransformer` at `add_to_expression.jl:1912`). +Nothing can be compiled, built, or tested in CI until that is fixed. +Local environments holding a pre-#1714 Manifest resolve still work, but that state is not CI-reproducible. + +The fix is the front slice of the transformer plan: +**Task 0** (hard gate: verify PNM prerequisites shipped, choose the PowerFlows ref — interim `psy6-rebase` pin — and pull the tips) and +**Task 1** (delete dead transformer-control formulations, sweep all stale type references incl. `PowerFlowsExt`; exit gate: POM loads with the extension and the stale-symbol grep is zero). + +Load is not green: Tasks 2–9 restore suite correctness, and transformer-touching tests (and PSB fixtures containing transformers, e.g. `c_sys14`) stay red in between. +An events PR runs the full suite in CI, so **merging events to a green main queues behind transformer-plan completion (Task 9's final gate), not just Task 1**. + +### Schedule + +1. **Now**: author the events port on its own branch — zero dependency on transformer-plan APIs; PSY's outage types and IOM's scaffolding are on current pins. + The IOM type-bound fix is authored in parallel in the local IOM clone and bridged into POM's test env via Manifest-only `Pkg.develop` until it merges to IOM `main`. +2. **After transformer Task 1**: rebase; run events tests locally (build-level tests on non-transformer fixtures such as `c_sys5` should pass). +3. **After transformer Task 9 and the IOM PR merging to `main`**: full-suite CI green achievable; merge events; whichever branch merges second rebases over append-only conflicts. + +Discipline: the events branch makes **no `Project.toml`/pin changes** (it needs none); all env churn is owned by transformer Task 0. +Temporarily pinning PSY to a pre-#1714 SHA to merge events first is rejected: it violates the no-mid-project-pin-churn rule and forks the baseline the transformer plan builds against. + +## Done criteria + +- Formatter clean; full suite green (`--jobs=8`); docs build. +- `Test.detect_ambiguities` clean. +- `.claude/pom_port_plan.md` Workstream C event item updated to reflect what landed (including hydro/storage coverage beyond the original PSI-parity scope). + +## Reference inventory (for the implementation plan) + +- PSI: `src/core/event_keys.jl`, `src/core/event_model.jl`, `src/contingency_model/{contingency,contingency_arguments,contingency_constraints}.jl`, `src/core/parameters.jl:79-88, 582-605`, `src/core/constraints.jl:596-598`, `src/core/optimization_container.jl:1314-1340, 1478-1490`, `src/parameters/add_parameters.jl:36-54`, constructor call sites in `src/devices_models/device_constructors/{thermalgeneration,load,renewablegeneration,source}_constructor.jl`, `src/simulation/simulation_sequence.jl:201-316` (discovery/validation logic to re-host at template level). +- HPS: `src/contingency_model.jl`, `test/test_events.jl`. +- SSS: `src/contingency_model.jl`, `test/test_utils/events.jl`, `test/test_events.jl`. +- IOM (consumed, plus the type-bound fix above): `src/core/device_model.jl` (abstracts, `events` field, `set_event_model!`), `EventParameter` / `EventParametersAttributes`, `add_param_container!` overload; fix sites `src/core/parameter_container.jl:83-101` and `src/common_models/add_param_container.jl:85-103`; local clone at `/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl` (branch `mb/events-port`). +- PSY psy6: `src/outages.jl` (`Outage <: Contingency`, `FixedForcedOutage`, `GeometricDistributionForcedOutage`, `PlannedOutage`, monitored-components API). From 64e69b7edc237607284ec399ccc1afe1bb5dcd5f Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 21:44:01 -0700 Subject: [PATCH 16/19] test: strengthen events testsets and fix reactive-constraint docstring Add an objective to the forced-outage mock test so it fails when ActivePowerOutageConstraint is removed, assert the constraint's baked RHS equals the device's max active power, cover the FixedOutput offset path, and smoke-test the event condition accessors. Also correct the ReactivePowerOutageConstraint docstring/docs math to match the max((Q^max)^2, (Q^min)^2) implementation, and update the stale "no event framework" claim in .claude/CLAUDE.md. --- .claude/CLAUDE.md | 2 +- docs/src/reference/formulation_library.md | 2 +- src/core/constraints.jl | 2 +- test/test_events.jl | 73 ++++++++++++++++++++++- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6ccde0cc..a4b777ad 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -150,7 +150,7 @@ Solvers: `HiGHS` (LP/MILP), `Ipopt` (NLP), `SCS` (SDP) — helpers `HiGHS_optimi - **~30 bare-unit PSY getters remain in build code** (the units rule above is stated but not fully enforced yet): known sites include `get_angle_limits` in `AC_branches.jl` (~1750, 1767) and `pm_translator.jl` (~280), and `get_loss` across `TwoTerminalDC_branches.jl` (71, 166, 221, 380, 818, and more). Angle limits are radians — no base conversion; loss terms are convertible. When touching one of these files, fix the bare getters in it. This POM/PNM/PF consumer sweep is the open remainder of the units-ecosystem closure effort. - **Silent TS-missing device skip** (`common_models/add_parameters.jl:~175`): a device whose time series is missing gets `@debug` + skip — it silently drops out of the model. Named silent-failure pattern; never extend it, and prefer converting it to a loud error when the opportunity arises. -- **PSI port backlog** lives in `.claude/pom_port_plan.md`: fork baseline ≈ PSI #1503, PSI swept through #1640. Highlights: no `core/event_model.jl` yet (no `EventModel`/`FixedForcedOutage` machinery — Tier-0 blocker); service-side G-1 (PSI #1617) pending; a list of symbol-verified absent bugfixes/features (#1519, #1527, #1535, #1587, #1508, #1614, #1622, …). Porting rule: formulation-specific → POM, generic optimization core → IOM; adapt to POM's type-based dispatch, don't copy PSI code verbatim. Simulation orchestration is out of scope — never port it. +- **PSI port backlog** lives in `.claude/pom_port_plan.md`: fork baseline ≈ PSI #1503, PSI swept through #1640. Highlights: the event framework is ported (`EventModel`/`FixedForcedOutage` machinery: template-level `set_event_model!`, `src/event_models/`, build-level coverage in `test/test_events.jl`) — the remaining PSI-side gap is simulation-runtime only; service-side G-1 (PSI #1617) pending; a list of symbol-verified absent bugfixes/features (#1519, #1527, #1535, #1587, #1508, #1614, #1622, …). Porting rule: formulation-specific → POM, generic optimization core → IOM; adapt to POM's type-based dispatch, don't copy PSI code verbatim. Simulation orchestration is out of scope — never port it. - **Direct-write hot spots to not extend:** `instantiate_network_model!` is a ~15-step mutation cascade with direct `model.field =` writes and no rollback; some `.data` writes into IOM containers exist (`thermal_generation.jl`). Prefer setters; don't add new direct reaches. ## Cross-package coupling (summary) diff --git a/docs/src/reference/formulation_library.md b/docs/src/reference/formulation_library.md index e85da143..c3243ed0 100644 --- a/docs/src/reference/formulation_library.md +++ b/docs/src/reference/formulation_library.md @@ -532,7 +532,7 @@ together for `PSY.EnergyReservoirStorage`, and, for `PSY.HydroPumpTurbine`, both variable ([`ActivePowerOutageConstraint`](@ref)) and the pump variable ([`ActivePowerPumpOutageConstraint`](@ref)). Under reactive-power-capable networks, [`ReactivePowerOutageConstraint`](@ref) additionally bounds -``q_t^2 \le (Q^\text{max})^2 \cdot \text{status}_t``. +``q_t^2 \le \max\left((Q^\text{max})^2, (Q^\text{min})^2\right) \cdot \text{status}_t``. The parameter values are constant within a single build; updating them across solves (outage sampling, countdown projection) is simulation-runtime functionality that lives outside this diff --git a/src/core/constraints.jl b/src/core/constraints.jl index 52b98a3a..9c2e1c30 100644 --- a/src/core/constraints.jl +++ b/src/core/constraints.jl @@ -645,7 +645,7 @@ Struct to create the constraint that bounds a device's reactive power by its available capacity squared during an outage event. ```math -q_t^2 \\le Q^\\text{max} \\cdot \\text{status}_t, \\quad \\forall t \\in \\{1,\\dots,T\\} +q_t^2 \\le \\max\\left((Q^\\text{max})^2, (Q^\\text{min})^2\\right) \\cdot \\text{status}_t, \\quad \\forall t \\in \\{1,\\dots,T\\} ``` """ struct ReactivePowerOutageConstraint <: EventConstraint end diff --git a/test/test_events.jl b/test/test_events.jl index 70d7a84d..c7d76bb1 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -20,6 +20,17 @@ @test get_time_stamps(pc) == [Dates.DateTime("2024-01-01T05:00:00")] end +@testset "Event condition types: accessors" begin + svc = StateVariableValueCondition(ActivePowerVariable(), PSY.ThermalStandard, "x", 0.0) + @test POM.get_variable_type(svc) isa ActivePowerVariable + @test POM.get_device_type(svc) == PSY.ThermalStandard + @test POM.get_device_name(svc) == "x" + @test IOM.get_value(svc) == 0.0 + + dec = DiscreteEventCondition(x -> true) + @test POM.get_condition_function(dec)(1) == true +end + @testset "Event traits" begin @test POM.supports_events(PSY.ThermalStandard) @test POM.supports_events(PSY.RenewableDispatch) @@ -240,6 +251,48 @@ end @test !isnothing(system_balance) end +@testset "Event arguments for FixedOutput take the offset path, not the constraint path" begin + # FixedOutput has no dispatch variable, so events reach it the same way they + # reach loads: status/countdown parameters plus an ActivePowerOffsetParameter + # wired into the balance, and no outage constraint at all (`construct_device!`'s + # ModelConstructStage for `DeviceModel{<:PSY.ThermalGen, FixedOutput}` is a no-op). + device_model = DeviceModel(PSY.ThermalStandard, FixedOutput) + sys = PSB.build_system(PSITestSystems, "c_sys5_uc") + # FixedOutput is driven by a `max_active_power` time series; c_sys5_uc's thermal + # units carry none, so borrow the load's forecast shape (matches the pattern in + # test_device_thermal_generation_constructors.jl's FixedOutput testset). + forecast = PSY.get_time_series( + Deterministic, + first(PSY.get_components(PSY.PowerLoad, sys)), + "max_active_power", + ) + for device in PSY.get_components(PSY.ThermalStandard, sys) + PSY.add_time_series!(sys, device, forecast) + end + model = DecisionModel(MockOperationProblem, CopperPlateNetworkModel, sys) + mock_construct_device!(model, device_model; add_event_model = true) + container = IOM.get_optimization_container(model) + @test !isnothing( + IOM.get_parameter(container, AvailableStatusParameter, PSY.ThermalStandard), + ) + @test !isnothing( + IOM.get_parameter( + container, + AvailableStatusChangeCountdownParameter, + PSY.ThermalStandard, + ), + ) + @test !isnothing( + IOM.get_parameter(container, ActivePowerOffsetParameter, PSY.ThermalStandard), + ) + @test_throws IS.InvalidValue IOM.get_constraint( + container, + ActivePowerOutageConstraint(), + PSY.ThermalStandard, + "ub", + ) +end + @testset "Event constraints - thermal UC counts and coefficients" begin device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) sys = PSB.build_system(PSITestSystems, "c_sys5_uc") @@ -260,8 +313,19 @@ end @test size(cons)[2] == length(time_steps) # Coefficient check: constraint is expr(p) - ub * status <= 0 with status = 1.0 # (params are plain Float64 in a non-recurrent build, so the RHS is baked in). - c1 = JuMP.constraint_object(cons[axes(cons)[1][1], 1]) + outaged_name = axes(cons)[1][1] + c1 = JuMP.constraint_object(cons[outaged_name, 1]) @test c1.set isa MOI.LessThan{Float64} + # Value-level check on the baked RHS: guards the IOM + # `_bound_range_with_parameter!` EventParameter specialization together with + # `IOM.get_max_active_power`. At build time status = 1.0 and the LHS + # expression (ActivePowerRangeExpressionUB) carries no constant term here, so + # the normalized upper bound must equal the device's rated max active power. + outaged_device = first( + d for d in PSY.get_components(PSY.ThermalStandard, sys) if + PSY.get_name(d) == outaged_name + ) + @test c1.set.upper ≈ PSY.get_max_active_power(outaged_device, PSY.SU) end @testset "Event constraints - renewable counts on ActivePowerVariable" begin @@ -560,11 +624,16 @@ end JuMP.fix(param_array[outaged_name, t], 0.0; force = true) end jm = IOM.get_jump_model(container) + p = IOM.get_variable(container, ActivePowerVariable, PSY.ThermalStandard) + # The mock model has no objective, so without an incentive to raise output, + # the LP returns p = 0 by default even if ActivePowerOutageConstraint were + # removed. Maximizing the outaged device's own output means the test only + # passes if the constraint is actually forcing p to zero. + JuMP.@objective(jm, Max, sum(p[outaged_name, t] for t in axes(p)[2])) JuMP.set_optimizer(jm, HiGHS.Optimizer) JuMP.set_silent(jm) JuMP.optimize!(jm) @test JuMP.termination_status(jm) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) - p = IOM.get_variable(container, ActivePowerVariable, PSY.ThermalStandard) @test all( abs(JuMP.value(p[outaged_name, t])) <= 1e-6 for t in axes(p)[2] ) From aac047e18ceaf271f5cefe3c07a2314281b9a923 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 30 Jul 2026 21:49:05 -0700 Subject: [PATCH 17/19] docs: record module-context reserve-wiring bug for follow-up --- ...07-30-reserve-wiring-module-context-bug.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .claude/plans/2026-07-30-reserve-wiring-module-context-bug.md diff --git a/.claude/plans/2026-07-30-reserve-wiring-module-context-bug.md b/.claude/plans/2026-07-30-reserve-wiring-module-context-bug.md new file mode 100644 index 00000000..3c295c04 --- /dev/null +++ b/.claude/plans/2026-07-30-reserve-wiring-module-context-bug.md @@ -0,0 +1,44 @@ +# Follow-up: reserve wiring into `ActivePowerRangeExpressionUB` is module-context dependent + +> Discovered 2026-07-30 during the events-port final fix wave, while attempting a +> renewable + service-model event-constraint test. Pre-existing bug, unrelated to events. +> Not yet root-caused; recorded here so the investigation isn't lost. + +## Symptom (100% reproducible, not flakiness) + +Build a real (non-mock) template on `c_sys5_re` with `add_reserves = true`: +`RenewableFullDispatch` device model + `RangeReserve` service model, then `build!` a `DecisionModel`. + +- Run as a top-level script (`julia --project=test script.jl`, `Main` context): + `has_service_model(device_model)` is `true` and `ActivePowerRangeExpressionUB` rows contain + **2 terms** (`ActivePowerVariable` + the reserve variable) — correct. +- Run the identical code inside **any** wrapping `module` block (exactly how + `ParallelTestRunner` runs every test file — fresh module, not `Main`): + the `ActivePowerReserveVariable` container exists, but the reserve variable + **never lands in `ActivePowerRangeExpressionUB`** — rows have only 1 term. + +Traced through `finalize_template!`, `_populate_contributing_devices!`, +`_add_services_to_device_model!`, `construct_services!` without isolating the trigger. + +## Strongest lead + +`src/core/problem_template.jl` keys the **services** dict by `Symbol(T)` +(lines ~93-94 and ~247 at time of writing) while the devices/branches paths use +`nameof(T)` (lines ~80, ~82). +`Symbol(T)`/`string(T)` dict keys are exactly the module-context bug class this repo's +CLAUDE.md already documents under "ParallelTestRunner specifics" (`Symbol(T)` embeds the +defining module path for types resolved differently in a fresh module; `nameof` does not). + +## Impact + +Any test or user code exercising service-augmented range expressions under the parallel +runner may silently build without reserve contributions — a silent-wrong-model class, not +a loud failure. +Also blocks adding a renewable+service event-constraint testset (the events port shipped +without that branch covered for this reason). + +## Suggested next step + +Reproduce with a minimal `module M; include(...); end` wrapper, then audit +`problem_template.jl`'s `Symbol(T)` keying against `nameof(T)`, fix, and add a +parallel-runner regression test plus the deferred renewable+service event testset. From 3b9e8d3e0bd38b29c97971e3f4a71e85b66ad2ae Mon Sep 17 00:00:00 2001 From: m-bossart Date: Fri, 31 Jul 2026 11:44:12 -0700 Subject: [PATCH 18/19] remove plans --- .claude/plans/2026-07-29-events-port.md | 2116 ----------------- ...07-30-reserve-wiring-module-context-bug.md | 44 - 2 files changed, 2160 deletions(-) delete mode 100644 .claude/plans/2026-07-29-events-port.md delete mode 100644 .claude/plans/2026-07-30-reserve-wiring-module-context-bug.md diff --git a/.claude/plans/2026-07-29-events-port.md b/.claude/plans/2026-07-29-events-port.md deleted file mode 100644 index 49db3299..00000000 --- a/.claude/plans/2026-07-29-events-port.md +++ /dev/null @@ -1,2116 +0,0 @@ -# Events Port (PSI → POM) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Port the time-series outage events feature (PSI "Feature A") into POM so a standalone `DecisionModel` builds event parameters and outage constraints, per the approved spec at `.claude/specs/2026-07-29-events-port-design.md`. - -**Architecture:** New `src/event_models/` directory holds the container types (`EventKey`, `EventModel`, condition structs), traits, parameter builders, and constraint builders. -POM already has the parameter/constraint types in `src/core/`, no-op stubs for `add_event_arguments!`/`add_event_constraints!` in `src/core/feedforward_interface.jl:49-69`, and every constructor call site wired — this port replaces the stubs with real dispatch methods and adds template-level attachment plus build-time discovery. -IOM needs exactly one correction (Task 4b): its event parameter machinery bounds the contingency slot on `IS.InfrastructureSystemsComponent`, but contingency types live under `IS.SupplementalAttribute` — every other IOM piece is consumed as-is. - -**Tech Stack:** Julia, JuMP, PowerSystems (psy6), InfrastructureOptimizationModels (main), HiGHS/Ipopt for tests, PowerSystemCaseBuilder fixtures. - -## Global Constraints - -- Never run `git commit` or `git push`. Leave all edits unstaged; run `git add -N ` for each newly created file so it shows in `git diff`. -- All Julia commands use `julia --project=test`; never bare `julia` or `--project=.`. -- Run the formatter after completing each task: `julia --project=scripts/formatter -e 'include("scripts/formatter/formatter_code.jl")'`. -- Every `PSY` getter on a unit-convertible field passes `PSY.SU` explicitly (e.g. `PSY.get_active_power_limits(d, PSY.SU)`). Never copy PSI getter calls verbatim — PSI predates the stateless-units rework. -- No `Project.toml` or `[sources]` pin changes of any kind. -- No new reaches into non-exported `IOM._*` helpers beyond those POM already uses (`IOM._set_multiplier_at!`, `IOM._set_parameter_at!`, `IOM.get_multiplier_array_data`, `IOM.get_parameter_array_data` are already in use in `src/common_models/add_parameters.jl` and may be used here). -- All type bounds use `PSY.Contingency` (never `PSY.Outage`) for event dispatch. -- `add_*!` methods end with bare `return`; store JuMP objects via `add_*_container!`, never return collections. -- Do not touch `src/common_models/add_to_expression.jl`, `src/ac_transmission_models/`, or `src/network_models/` — the concurrent transformer-refactor plan owns those files. All new event code lives in `src/event_models/`, `src/core/problem_template.jl`, `src/operation/template_validation.jl`, and test files. -- Do not modify PSI, HPS, SSS, or PSY checkouts. -- IOM changes happen ONLY in the local clone at `/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl` (branch `mb/events-port`), ONLY as scoped in Task 4b, under IOM's house rules: never edit the `version` field in its `Project.toml`, use its own formatter script, prefer mocks over PSY types in its tests, and add no `using`/`include`/`const` lines to individual `test_*.jl` files (they are included by `InfrastructureOptimizationModelsTests.jl`). -- New exported symbols need docstrings; the docs build (`julia --project=docs docs/make.jl`) is a completion gate. - -## Reference sources (read-only) - -- PSI: `/Users/mbossart/sienna/PowerSimulations.jl` — `src/core/event_keys.jl`, `src/core/event_model.jl`, `src/contingency_model/*.jl`. -- HPS `src/contingency_model.jl` and SSS `src/contingency_model.jl` — fetch from GitHub `Sienna-Platform/{HydroPowerSimulations,StorageSystemsSimulations}.jl` `main` if needed; the relevant code is reproduced in Tasks 7–8. -- IOM provides (via `using InfrastructureOptimizationModels`, `src/PowerOperationsModels.jl:206`): `AbstractEventModel`, `AbstractEventKey`, `DeviceModel.events::Dict{AbstractEventKey, AbstractEventModel}`, `set_event_model!(::DeviceModel, key, event)`, `get_events(::DeviceModel)`, `EventParameter`, `EventParametersAttributes`, and `add_param_container!(container, ::Type{T<:EventParameter}, ::Type{U}, ::Type{V}, axs...)` (note: IOM takes `Type{T}`, not an instance like PSI). - The `V` slot of that overload requires the Task 4b bound fix (`IS.InfrastructureSystemsComponent` → `IS.SupplementalAttribute`) before it dispatches for contingency types. - Local IOM clone: `/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl`, branch `mb/events-port`. - ---- - -### Task 0: Environment gate and baseline - -**Files:** none modified. - -- [ ] **Step 1: Verify POM loads.** - -Run: `julia --project=test -e 'using PowerOperationsModels; println("LOADED")'` -Expected: prints `LOADED`. -If it fails with `UndefVarError: PhaseShiftingTransformer` (or any missing transformer symbol): **STOP — do not work around it.** -The environment has resolved PSY past the #1714 transformer refactor; load restoration is owned by `.claude/plans/2026-07-26-transformer-refactor.md` Tasks 0–1. -Report the blocker and end the session. - -- [ ] **Step 2: Baseline test run.** - -Run: `julia --project=test test/runtests.jl test_device_thermal_generation_constructors` -Expected: PASS. Record the result; later tasks must not regress it. - ---- - -### Task 1: Container types — `src/event_models/event_model.jl` - -**Files:** -- Create: `src/event_models/event_model.jl` -- Modify: `src/PowerOperationsModels.jl` (includes + exports) -- Test: `test/test_events.jl` (new) - -**Interfaces:** -- Consumes: `IOM.AbstractEventKey`, `IOM.AbstractEventModel` (available unqualified via `using InfrastructureOptimizationModels`), `PSY.Contingency`, `PSY.FixedForcedOutage`, `PSY.GeometricDistributionForcedOutage`, `VariableType`. -- Produces: `EventKey{T,U}`, `EventKey(::Type{T}, ::Type{U})`, `get_entry_type(::EventKey)`, `get_component_type(::EventKey)`, `AbstractEventCondition`, `ContinuousCondition`, `PresetTimeCondition`, `StateVariableValueCondition`, `DiscreteEventCondition`, `EventModel{D,B}`, `EventModel(contingency_type, condition; timeseries_mapping, attributes)`, `get_empty_timeseries_mapping(::Type)`, `get_event_type`, `get_event_condition`, `get_attribute_device_map`. Tasks 2–9 use all of these names exactly as written. - -- [ ] **Step 1: Write the failing test.** - -Create `test/test_events.jl`: - -```julia -@testset "EventKey and EventModel construction" begin - key = EventKey(PSY.FixedForcedOutage, PSY.ThermalStandard) - @test IOM.get_entry_type(key) == PSY.FixedForcedOutage - @test IOM.get_component_type(key) == PSY.ThermalStandard - # Abstract component types are rejected - @test_throws ErrorException EventKey(PSY.FixedForcedOutage, PSY.ThermalGen) - - em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) - @test get_event_type(em) == PSY.FixedForcedOutage - @test get_event_condition(em) isa ContinuousCondition - @test em.timeseries_mapping == Dict{Symbol, Union{String, Nothing}}(:outage_status => nothing) - @test isempty(get_attribute_device_map(em)) - - em_geo = EventModel(PSY.GeometricDistributionForcedOutage, ContinuousCondition()) - @test Set(keys(em_geo.timeseries_mapping)) == - Set([:mean_time_to_recovery, :outage_transition_probability]) - - pc = PresetTimeCondition([Dates.DateTime("2024-01-01T05:00:00")]) - @test get_time_stamps(pc) == [Dates.DateTime("2024-01-01T05:00:00")] -end -``` - -Note: `IOM.get_entry_type`/`IOM.get_component_type` are used above on the assumption IOM defines those generics; if `IOM.get_entry_type` does not exist, define POM-owned generics in this file and test unqualified `get_entry_type(key)` instead — check with `julia --project=test -e 'using InfrastructureOptimizationModels; println(isdefined(InfrastructureOptimizationModels, :get_entry_type))'` and use whichever holds. - -- [ ] **Step 2: Run it to verify it fails.** - -Run: `julia --project=test -e 'include("test/includes.jl"); include("test/test_events.jl")'` -Expected: FAIL with `UndefVarError: EventKey`. - -- [ ] **Step 3: Write the implementation.** - -Create `src/event_models/event_model.jl` (adapted from PSI `src/core/event_keys.jl` + `src/core/event_model.jl`; changes: subtype the IOM abstracts, drop the per-simulation-model outer key of `attribute_device_map`, add docstrings): - -```julia -""" - EventKey(::Type{T}, ::Type{U}) - -Key identifying an event of contingency type `T` applied to devices of concrete type `U`. -Used as the key of the `DeviceModel.events` dict. Errors if `U` is abstract. -""" -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 - -""" -Abstract type for the condition that triggers an event. POM stores conditions as data; -evaluating them requires a simulation runtime and happens outside this package. -""" -abstract type AbstractEventCondition end - -""" - ContinuousCondition() - -Event condition that is triggered at all timesteps. -""" -struct ContinuousCondition <: AbstractEventCondition end - -""" - PresetTimeCondition(time_stamps::Vector{Dates.DateTime}) - -Event condition that is triggered at pre-determined times. -""" -struct PresetTimeCondition <: AbstractEventCondition - time_stamps::Vector{Dates.DateTime} -end - -get_time_stamps(c::PresetTimeCondition) = c.time_stamps - -""" - StateVariableValueCondition(variable_type, device_type, device_name, value) - -Event condition triggered when the monitored variable equals `value` (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) - -Event condition driven by a user-defined function evaluated by the simulation runtime. -""" -struct DiscreteEventCondition <: AbstractEventCondition - condition_function::Function -end - -get_condition_function(c::DiscreteEventCondition) = c.condition_function - -""" - EventModel(contingency_type, condition; timeseries_mapping, attributes) - -Container binding a `PSY.Contingency` supplemental-attribute type to a trigger condition -and time-series mapping. Attach to a template with -`set_event_model!(template, event_model)`; build-time discovery populates -`attribute_device_map` (outage attribute UUID → device type → device names) and -distributes the event to the matching `DeviceModel`s. -""" -mutable struct EventModel{D <: PSY.Contingency, B <: AbstractEventCondition} <: - IOM.AbstractEventModel - condition::B - timeseries_mapping::Dict{Symbol, Union{String, Nothing}} - attribute_device_map::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{Base.UUID, Dict{DataType, Set{String}}}(), - attributes, - ) - end -end - -""" -Reserved time-series mapping keys for a contingency type. `:outage_status` is required -for `PSY.FixedForcedOutage`. -""" -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 -``` - -If Step 1's isdefined check showed IOM owns `get_entry_type`/`get_component_type` generics, define the two methods as `IOM.get_entry_type(...)`/`IOM.get_component_type(...)` extensions instead of new generics (POM may already extend them for other key types — check `grep -rn "get_entry_type" src/` and match the existing style). - -- [ ] **Step 4: Wire include and exports.** - -In `src/PowerOperationsModels.jl`: -find the last `include("core/...")` line (`grep -n 'include("core/' src/PowerOperationsModels.jl | tail -1`) and insert after it: - -```julia -include("event_models/event_model.jl") -``` - -Find the export section (`grep -n '^export' src/PowerOperationsModels.jl | head -3`) and add, grouped with a comment near the other model-container exports: - -```julia -export EventModel -export EventKey -export AbstractEventCondition -export ContinuousCondition -export PresetTimeCondition -export StateVariableValueCondition -export DiscreteEventCondition -export get_empty_timeseries_mapping -export get_event_type -export get_event_condition -export get_attribute_device_map -export set_event_model! -``` - -(`set_event_model!` currently resolves to IOM's function; POM re-exports it and Task 3 adds the template method to the same generic.) - -- [ ] **Step 5: Run the test to verify it passes.** - -Run: `julia --project=test -e 'include("test/includes.jl"); include("test/test_events.jl")'` -Expected: PASS. - -- [ ] **Step 6: Track and format.** - -```bash -git add -N src/event_models/event_model.jl test/test_events.jl -julia --project=scripts/formatter -e 'include("scripts/formatter/formatter_code.jl")' -``` - ---- - -### Task 2: Traits — `src/event_models/event_traits.jl` - -**Files:** -- Create: `src/event_models/event_traits.jl` -- Modify: `src/PowerOperationsModels.jl` (include + export) -- Test: `test/test_events.jl` - -**Interfaces:** -- Consumes: `EventModel` (Task 1), the parameter types in `src/core/parameters.jl:208-223` (`AvailableStatusParameter`, `ActivePowerOffsetParameter`, `ReactivePowerOffsetParameter`, `AvailableStatusChangeCountdownParameter`), `EventParameter` (IOM). -- Produces: `supports_events(::Type{<:PSY.Component})::Bool`, `get_parameter_multiplier(::EventParameter, ::PSY.Device, ::EventModel)`, `get_initial_parameter_value(::, ::PSY.Device, ::EventModel)`. Tasks 4 and 5 call these exact signatures. - -- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: - -```julia -@testset "Event traits" begin - @test POM.supports_events(PSY.ThermalStandard) - @test POM.supports_events(PSY.RenewableDispatch) - @test POM.supports_events(PSY.PowerLoad) - @test POM.supports_events(PSY.HydroDispatch) - @test POM.supports_events(PSY.EnergyReservoirStorage) - @test !POM.supports_events(PSY.Source) - - em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) - d = PSY.ThermalStandard(nothing) - @test POM.get_initial_parameter_value(AvailableStatusParameter(), d, em) == 1.0 - @test POM.get_initial_parameter_value(AvailableStatusChangeCountdownParameter(), d, em) == 0.0 - @test POM.get_initial_parameter_value(ActivePowerOffsetParameter(), d, em) == 0.0 - @test POM.get_initial_parameter_value(ReactivePowerOffsetParameter(), d, em) == 0.0 - @test POM.get_parameter_multiplier(AvailableStatusParameter(), d, em) == 1.0 -end -``` - -Check how the test preamble aliases the package (`grep -n "const POM\|import PowerOperationsModels" test/includes.jl test/test_utils/*.jl | head -5`); if the alias is different (e.g. `PSI` for compatibility), use that alias. -If `PSY.ThermalStandard(nothing)` is unavailable in psy6, use `first(PSY.get_components(PSY.ThermalStandard, PSB.build_system(PSB.PSITestSystems, "c_sys5")))` instead. - -- [ ] **Step 2: Run to verify it fails** (same include command as Task 1). Expected: FAIL with `UndefVarError: supports_events` (or MethodError). - -- [ ] **Step 3: Implement.** Create `src/event_models/event_traits.jl`: - -```julia -#! format: off -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 - -""" -Whether devices of this type support outage events (`EventModel`). This is a device-type -capability trait for time-series outage events — distinct from `supports_outages`, the -formulation trait for security-constrained (MODF) branch contingencies. -""" -supports_events(::Type{T}) where {T <: PSY.Component} = false -supports_events(::Type{T}) where {T <: PSY.ThermalStandard} = true -supports_events(::Type{T}) where {T <: PSY.RenewableGen} = true -supports_events(::Type{T}) where {T <: PSY.ElectricLoad} = true -supports_events(::Type{T}) where {T <: PSY.Storage} = true -supports_events(::Type{T}) where {T <: PSY.HydroGen} = true -#! format: on -``` - -Note the fallback is `PSY.Component` (PSI used `PSY.StaticInjection`); the wider fallback lets discovery (Task 4) query any device type safely. -If `get_parameter_multiplier`/`get_initial_parameter_value` generics already have POM methods with different owner modules, extend the same function the existing methods extend (check `grep -rn "function get_initial_parameter_value\|get_initial_parameter_value(" src/common_models/add_parameters.jl | head -3` and mirror). - -- [ ] **Step 4: Wire include + export.** In `src/PowerOperationsModels.jl` add after the Task 1 include: - -```julia -include("event_models/event_traits.jl") -``` - -Add `export supports_events` next to the Task 1 export block. - -- [ ] **Step 5: Run the test to verify it passes.** Expected: PASS. -- [ ] **Step 6:** `git add -N src/event_models/event_traits.jl`; run the formatter. - ---- - -### Task 3: Template attachment - -**Files:** -- Modify: `src/core/problem_template.jl` (struct + accessors), `src/PowerOperationsModels.jl` (export) -- Test: `test/test_events.jl` - -**Interfaces:** -- Consumes: `EventModel` (Task 1), `PowerOperationsProblemTemplate` (`src/core/problem_template.jl`). -- Produces: `PowerOperationsProblemTemplate.events::Vector{EventModel}`, `set_event_model!(template::PowerOperationsProblemTemplate, event_model::EventModel)`, `get_event_models(template)::Vector{EventModel}`. Task 4 consumes these. - -- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: - -```julia -@testset "Template-level event attachment" begin - template = PowerOperationsProblemTemplate(CopperPlateNetworkModel) - @test isempty(get_event_models(template)) - em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) - set_event_model!(template, em) - @test length(get_event_models(template)) == 1 - @test get_event_models(template)[1] === em - # Same event model instance can't be attached twice - @test_throws ErrorException set_event_model!(template, em) -end -``` - -- [ ] **Step 2: Run to verify it fails.** Expected: FAIL with `UndefVarError: get_event_models` or MethodError on `set_event_model!`. - -- [ ] **Step 3: Implement.** In `src/core/problem_template.jl`: - -Add `events::Vector{<:Any}`? No — the struct is declared before `EventModel` exists in include order (`core/` is included before `event_models/`). -Type the field as `Vector{IOM.AbstractEventModel}` (IOM abstract, already loaded): - -```julia -mutable struct PowerOperationsProblemTemplate <: IOM.AbstractProblemTemplate - network_model::NetworkModel{<:AbstractNetworkModel} - devices::DevicesModelContainer - branches::BranchModelContainer - services::ServicesModelContainer - events::Vector{IOM.AbstractEventModel} - function PowerOperationsProblemTemplate( - network::NetworkModel{T}, - ) where {T <: AbstractNetworkModel} - new( - network, - DevicesModelContainer(), - BranchModelContainer(), - ServicesModelContainer(), - Vector{IOM.AbstractEventModel}(), - ) - end -end -``` - -Below the existing accessors (`get_device_models` etc.) add: - -```julia -get_event_models(template::PowerOperationsProblemTemplate) = template.events - -""" - set_event_model!(template::PowerOperationsProblemTemplate, event_model) - -Attach an outage-event model to the template. At build time the event is validated, -its `attribute_device_map` is populated from the system's supplemental attributes, and -it is distributed to every matching `DeviceModel`. -""" -function set_event_model!( - template::PowerOperationsProblemTemplate, - event_model::IOM.AbstractEventModel, -) - if any(e -> e === event_model, template.events) - error("This event model is already attached to the template") - end - push!(template.events, event_model) - return -end -``` - -Check whether `Base.isempty(template::PowerOperationsProblemTemplate)` should consider events: it exists at `src/core/problem_template.jl` (checks devices/branches/services) — leave it unchanged; a template with only events and no device models is still "empty" for build purposes. - -- [ ] **Step 4: Export.** Add `export get_event_models` to the Task 1 export block (`set_event_model!` was exported in Task 1). -- [ ] **Step 5: Run the test to verify it passes.** Expected: PASS. -- [ ] **Step 6:** Formatter. - ---- - -### Task 4: Build-time discovery and time-series validation - -**Files:** -- Modify: `src/operation/template_validation.jl` -- Create: `test/test_utils/events_test_utils.jl` -- Modify: `test/includes.jl` (only if test_utils files are explicitly included there — check `grep -n "test_utils" test/includes.jl`; mirror how `add_branch_rating_time_series.jl` is included) -- Test: `test/test_events.jl` - -**Interfaces:** -- Consumes: Tasks 1–3 symbols; `PSY.get_supplemental_attributes(T, sys)`, `PSY.get_associated_components(sys, attribute)` (verified present in psy6 `src/get_components_interface.jl:70`), `PSY.add_supplemental_attribute!`, `IS.get_uuid`, `IOM.set_event_model!(::DeviceModel, key, event)`, `get_model(template, type)`. -- Produces: `_build_device_model_events!(template, sys)` (internal, called from `validate_template_impl!`), `_validate_event_timeseries_data(sys, event, event_model)` (internal), test helper `attach_fixed_forced_outage!(sys, device; ts_name = "outage_profile")`. - -- [ ] **Step 1: Write the test helper.** Create `test/test_utils/events_test_utils.jl`: - -```julia -# Attaches a FixedForcedOutage supplemental attribute to `device` and a 0/1 -# SingleTimeSeries named `ts_name` to the attribute. Returns the attribute. -# Adapted from PSI test/test_utils/events_simulation_utils.jl (build-relevant part only). -function attach_fixed_forced_outage!( - sys::PSY.System, - device::PSY.Device; - ts_name = "outage_profile", - outage_profile = nothing, -) - outage = PSY.FixedForcedOutage(; outage_status = 0.0) - PSY.add_supplemental_attribute!(sys, device, outage) - resolution = PSY.get_time_series_resolution(sys) - initial_time = PSY.get_forecast_initial_timestamp(sys) - horizon_count = PSY.get_forecast_horizon(sys) - if isnothing(outage_profile) - outage_profile = zeros(horizon_count) # 0 = available for the whole horizon - end - ts_data = TimeSeries.TimeArray( - range(initial_time; length = length(outage_profile), step = resolution), - outage_profile, - ) - ts = PSY.SingleTimeSeries(; name = ts_name, data = ts_data) - PSY.add_time_series!(sys, outage, ts) - return outage -end -``` - -Verify the psy6 accessor names compile (`PSY.get_time_series_resolution`, `PSY.get_forecast_initial_timestamp`, `PSY.get_forecast_horizon`); if a name errors, find the psy6 equivalent with `grep -rn "forecast_initial_timestamp\|get_forecast_horizon" ~/sienna/psy6/PowerSystems.jl/src/PowerSystems.jl` and substitute. -Include the helper the same way sibling `test/test_utils/*.jl` files are included (they are loaded by `test/includes.jl`; confirm and mirror). - -- [ ] **Step 2: Write the failing tests.** Append to `test/test_events.jl`: - -```julia -@testset "Event discovery and validation at build" begin - sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") - thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) - outage = attach_fixed_forced_outage!(sys, thermal) - - template = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) - em = EventModel( - PSY.FixedForcedOutage, - ContinuousCondition(); - timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:outage_status => "outage_profile"), - ) - set_event_model!(template, em) - - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT - - # Discovery populated the map: attribute uuid -> device type -> names - map_ = get_attribute_device_map(em) - uuid = IS.get_uuid(outage) - @test haskey(map_, uuid) - @test map_[uuid][PSY.ThermalStandard] == Set([PSY.get_name(thermal)]) - - # The caller's template DeviceModels were not mutated (build-copy isolation) - caller_dm = get_model(template, PSY.ThermalStandard) - @test isempty(IOM.get_events(caller_dm)) -end - -@testset "Event validation errors" begin - sys_clean = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") - template = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) - em = EventModel( - PSY.FixedForcedOutage, - ContinuousCondition(); - timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:outage_status => "outage_profile"), - ) - set_event_model!(template, em) - model = DecisionModel(template, sys_clean; optimizer = HiGHS_optimizer) - # No supplemental attributes in the system -> loud build failure - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.FAILED - - # Unknown mapping key rejected - sys2 = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") - thermal2 = first(PSY.get_components(PSY.ThermalStandard, sys2)) - attach_fixed_forced_outage!(sys2, thermal2) - template2 = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) - em_bad = EventModel( - PSY.FixedForcedOutage, - ContinuousCondition(); - timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:not_a_parameter => "outage_profile"), - ) - set_event_model!(template2, em_bad) - model2 = DecisionModel(template2, sys2; optimizer = HiGHS_optimizer) - @test build!(model2; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.FAILED - - # FixedForcedOutage requires :outage_status mapping - sys3 = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") - thermal3 = first(PSY.get_components(PSY.ThermalStandard, sys3)) - attach_fixed_forced_outage!(sys3, thermal3) - template3 = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) - em_nomapping = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) - set_event_model!(template3, em_nomapping) - model3 = DecisionModel(template3, sys3; optimizer = HiGHS_optimizer) - @test build!(model3; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.FAILED -end -``` - -Adjust the PSB system name if `c_sys5_uc` is not what POM tests use — check `grep -rn 'build_system' test/test_device_thermal_generation_constructors.jl | head -3` and use the same fixture family. -`build!` failure semantics: if `build!` throws instead of returning `FAILED`, assert with `@test_throws` — match whatever `test/test_model_decision.jl` does for build failures. -The build-copy isolation assertion assumes `build!` finalizes a copied template (mirroring Feature B's `_build_device_model_outages!` isolation). -If it fails because POM finalizes the caller's template in place for events too, check how `_build_device_model_outages!` achieves isolation (`src/operation/template_validation.jl:456-544`) and route event distribution through the same copy. - -- [ ] **Step 3: Run to verify failure.** Expected: first testset FAILS (map not populated — discovery doesn't exist yet; the build may even succeed with events silently ignored, which is exactly the gap). - -- [ ] **Step 4: Implement discovery + validation.** In `src/operation/template_validation.jl`, after `_build_device_model_outages!(template, system)` (line ~116, inside `validate_template_impl!`), add: - -```julia - _build_device_model_events!(template, system) -``` - -Then add at the end of the file (adapted from PSI `src/simulation/simulation_sequence.jl:215-296`, re-hosted at template level; the per-simulation-model map key is dropped): - -```julia -################################################################################# -# Outage-event discovery and validation (time-series outage events; distinct -# from the security-constrained `_build_device_model_outages!` above) -################################################################################# - -""" -For each event model attached to the template: validate its time-series mapping, -populate `attribute_device_map` (attribute UUID → concrete device type → device names) -from the system's supplemental attributes, and distribute the event model to every -`DeviceModel` in the template whose device type carries the attribute and supports -events. -""" -function _build_device_model_events!( - template::PowerOperationsProblemTemplate, - sys::PSY.System, -) - for event_model in get_event_models(template) - event_type = get_event_type(event_model) - if isempty(PSY.get_supplemental_attributes(event_type, sys)) - error( - "There are no supplemental attributes of type $event_type in the system. \ - Add the outage data to the system or remove the event model from the \ - template.", - ) - end - for event in PSY.get_supplemental_attributes(event_type, sys) - _validate_event_timeseries_data(sys, event, event_model) - event_uuid = IS.get_uuid(event) - attribute_device_map = get_attribute_device_map(event_model) - attribute_device_map[event_uuid] = Dict{DataType, Set{String}}() - device_types_with_attribute = Set{DataType}() - for device in PSY.get_associated_components(sys, event) - dtype = typeof(device) - if !supports_events(dtype) - @warn "Device $(PSY.get_name(device)) of type $dtype carries a \ - $event_type attribute but the type does not support events; \ - it will not be modeled." _group = - IOM.LOG_GROUP_MODELS_VALIDATION - continue - end - push!(device_types_with_attribute, dtype) - name_set = get!( - attribute_device_map[event_uuid], - dtype, - Set{String}(), - ) - push!(name_set, PSY.get_name(device)) - end - for device_type in device_types_with_attribute - device_model = get_model(template, device_type) - if device_model === nothing - @warn "Devices of type $device_type carry a $event_type attribute \ - but the template has no DeviceModel for that type; the event \ - will not be modeled for them." _group = - IOM.LOG_GROUP_MODELS_VALIDATION - continue - end - key = EventKey(event_type, device_type) - if !haskey(IOM.get_events(device_model), key) - IOM.set_event_model!(device_model, key, event_model) - end - end - end - end - return -end - -function _validate_event_timeseries_data( - sys::PSY.System, - event::PSY.Contingency, - event_model::EventModel, -) - for (k, v) in event_model.timeseries_mapping - if !isnothing(v) - try - PSY.get_time_series(IS.SingleTimeSeries, event, v) - catch - device_names = - PSY.get_name.(PSY.get_associated_components(sys, event)) - error( - "Event $event belonging to devices $device_names is missing a \ - time series with name $v", - ) - end - end - if !haskey(get_empty_timeseries_mapping(typeof(event)), k) - error( - "Key $k passed as part of the event time series mapping does not \ - correspond to a parameter.", - ) - end - if k == :outage_status && isnothing(v) - error( - "FixedForcedOutage requires a timeseries mapping for the \ - :outage_status parameter", - ) - end - end - return -end -``` - -Verification notes for the implementer: -`get_model(template, device_type)` — confirm the accessor name POM/IOM uses to fetch a `DeviceModel` from a template by component type (`grep -rn "function get_model" src/ | head -3`, else check IOM); adjust the call if it is `get_model(template.devices, ...)` or similar. -`IOM.LOG_GROUP_MODELS_VALIDATION` — confirm the constant exists (`grep -rn "LOG_GROUP" src/operation/template_validation.jl | head -2`) and reuse whatever group that file already logs under. -`PSY.get_time_series(IS.SingleTimeSeries, event, v)` — supplemental attributes carry time series through IS; if the psy6 method signature differs, check `grep -rn "get_time_series" ~/sienna/psy6/InfrastructureSystems.jl/src/supplemental_attributes.jl` and adapt. - -- [ ] **Step 5: Run the tests.** Both new testsets pass. Also re-run Task 1–3 testsets (whole `test/test_events.jl`). -- [ ] **Step 6: Initial-conditions exclusion test.** POM builds an initialization problem from a reduced template (see `src/initial_conditions/initialization.jl`). -Verify events are not copied into it: read the template-construction code there; if it copies device models wholesale (including `events`), clear events on the IC copy and add a code comment stating IC problems never model outage events. -Append to `test/test_events.jl` a testset asserting the built model's IC container has no event parameters: - -```julia -@testset "Events excluded from initialization problem" begin - sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") - thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) - attach_fixed_forced_outage!(sys, thermal) - template = get_thermal_standard_uc_template() - em = EventModel( - PSY.FixedForcedOutage, - ContinuousCondition(); - timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:outage_status => "outage_profile"), - ) - set_event_model!(template, em) - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT - ic_container = IOM.get_initial_conditions_optimization_container(model) - ic_keys = IOM.get_parameter_keys(ic_container) - @test !any(k -> IOM.get_entry_type(k) <: EventParameter, ic_keys) -end -``` - -Adjust helper names to what exists: template helper (`grep -n "get_thermal_standard_uc_template\|get_thermal_dispatch_template" test/test_utils/operations_problem_templates.jl`), IC container accessor and key listing (`grep -rn "initial_conditions_optimization_container\|get_parameter_keys" src/ test/ | head -5`). -This testset requires Task 5's parameter machinery to be meaningful (before Task 5, no event parameters exist anywhere, so it passes vacuously); re-run it after Task 5 and confirm it still passes. - -- [ ] **Step 7:** `git add -N test/test_utils/events_test_utils.jl`; formatter; run the full events file plus `julia --project=test test/runtests.jl test_problem_template` to confirm no Feature-B regression. - ---- - -### Task 4b: IOM type-bound fix (executed in the IOM clone) - -**Repo:** `/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl`, branch `mb/events-port`. All steps in this task run from that directory. - -**Files:** -- Modify: `src/core/parameter_container.jl:83-101`, `src/common_models/add_param_container.jl:85-103` -- Test: add a testset to the IOM test file that covers `add_param_container!` (find it: `grep -rln "add_param_container!" test/ | head -3`; use the file the existing parameter-container tests live in) - -**Why:** `PSY.Contingency <: SupplementalAttribute <: IS.InfrastructureSystemsType`, which is not under `IS.InfrastructureSystemsComponent`. -IOM's event overload of `add_param_container!` and `EventParametersAttributes` bound the contingency slot as `IS.InfrastructureSystemsComponent`, so any call with a real contingency type is a MethodError. -The `affected_devices::Vector{T}` field has zero readers in IOM (`grep -rn "affected_devices" src/ test/` returns only the definition) and is dropped. - -**Interfaces:** -- Produces: `add_param_container!(container, ::Type{T<:EventParameter}, ::Type{U<:IS.InfrastructureSystemsComponent}, ::Type{V<:IS.SupplementalAttribute}, axs...)` and `EventParametersAttributes{T<:IS.SupplementalAttribute, U<:ParameterType}`. POM Task 5 calls the former with `V = PSY.FixedForcedOutage`. - -- [ ] **Step 1: Write the failing test.** In the IOM test file that covers parameter containers, add (mirroring the file's existing mock/container fixtures — reuse whatever mock `OptimizationContainer` factory its sibling testsets use): - -```julia -@testset "Event parameter container accepts supplemental-attribute contingency types" begin - container = # same mock container construction as the surrounding testsets - IOM.add_param_container!( - container, - MockEventParameter, - MockThermalGen, - MockContingency, - ["dev1", "dev2"], - 1:24, - ) - key = IOM.ParameterKey(MockEventParameter, MockThermalGen) - pc = IOM.get_parameter(container, key) - @test IOM.get_attributes(pc) isa IOM.EventParametersAttributes{MockContingency} -end -``` - -Supporting mock types: check `test/mocks/` for an existing `MockThermalGen` (it exists per IOM conventions) and for any existing `SupplementalAttribute`/`EventParameter` mocks; if absent, add to the mocks file: - -```julia -struct MockContingency <: IS.SupplementalAttribute end -struct MockEventParameter <: InfrastructureOptimizationModels.EventParameter end -``` - -Adjust accessor names (`get_parameter`, `get_attributes`, `ParameterKey` arity) to match the file's surrounding testsets — copy their exact style. - -- [ ] **Step 2: Run to verify it fails.** - -Run: `julia --project=test test/runtests.jl` -Expected: the new testset FAILS with a MethodError (no `add_param_container!` method matching `MockContingency`, which is not an `IS.InfrastructureSystemsComponent`). -If IOM's runner supports file filtering, run just the affected file per its README/runtests conventions. - -- [ ] **Step 3: Apply the fix.** In `src/core/parameter_container.jl:83-101` replace the three `EventParametersAttributes` definitions with: - -```julia -""" -Attributes for event (contingency) parameters. `T` is the `IS.SupplementalAttribute` -subtype describing the contingency and `U` is the parameter type stored in the container. -""" -struct EventParametersAttributes{ - T <: IS.SupplementalAttribute, - U <: ParameterType, -} <: ParameterAttributes end - -function EventParametersAttributes( - ::Type{T}, - ::Type{U}, -) where {T <: IS.SupplementalAttribute, U <: ParameterType} - return EventParametersAttributes{T, U}() -end - -function get_param_type( - ::EventParametersAttributes{T, U}, -) where {T <: IS.SupplementalAttribute, U <: ParameterType} - return U -end -``` - -In `src/common_models/add_param_container.jl:96` change the event overload's where-clause bound from `V <: IS.InfrastructureSystemsComponent` to `V <: IS.SupplementalAttribute` (the body is unchanged). - -- [ ] **Step 4: Run the IOM suite.** - -Run: `julia --project=test test/runtests.jl` -Expected: PASS including the new testset and Aqua checks. - -- [ ] **Step 5: IOM formatter and tracking.** - -```bash -julia --project=scripts/formatter -e 'include("scripts/formatter/formatter_code.jl")' -git add -N . # new test/mock files only; leave everything unstaged, never commit -git status --short -``` - -Report the diff summary; the user opens the IOM PR from `mb/events-port`. - -- [ ] **Step 6: Bridge the fix into POM's test environment** (back in the POM repo). POM's `[sources]` pins IOM to the GitHub `main` branch, which does not have this fix yet; override the resolution locally (Manifest-only — `Project.toml` is untouched): - -Run: `julia --project=test -e 'using Pkg; Pkg.develop(path="/Users/mbossart/sienna/psy6/InfrastructureOptimizationModels.jl"); using PowerOperationsModels; println("LOADED")'` -Expected: resolves and prints `LOADED`. -`test/Manifest.toml` is not checked in, so this override is invisible to git and to CI; Task 11 verifies the upstream state before final sign-off. - ---- - -### Task 5: Event parameters and balance injection — `src/event_models/event_arguments.jl` - -> **Gate:** Task 4b must be complete and its Step 6 `Pkg.develop` bridge active, otherwise `_add_parameters!` fails with a MethodError on `add_param_container!`. - -**Files:** -- Create: `src/event_models/event_arguments.jl` -- Modify: `src/PowerOperationsModels.jl` (include), `test/test_utils/mock_operation_models.jl` (enable `add_event_model`) -- Test: `test/test_events.jl` - -**Interfaces:** -- Consumes: Tasks 1–2 symbols; IOM's `add_param_container!(container, ::Type{T<:EventParameter}, ::Type{U}, ::Type{V}, axs...)`; `IOM.get_multiplier_array_data`, `IOM.get_parameter_array_data`, `IOM._set_multiplier_at!`, `IOM._set_parameter_at!` (existing POM reaches); `_balance_expression_targets` and `_apply_term_to_targets!` (`src/common_models/add_to_expression.jl:30-98`); `get_rebuild_model`, `get_settings`, `has_container_key` (same usage as `src/common_models/add_parameters.jl:17-46`). -- Produces: `add_parameters!(container, ::Type{T}, devices, device_model, event_model::EventModel)`, `_add_parameters!(container, ::T<:EventParameter, devices, device_model, event_model)`, `add_to_expression!(container, ::Type{T<:SystemBalanceExpressions}, ::Type{U<:EventParameter}, devices, device_model, network_model)`, and the specific `add_event_arguments!` methods that override the no-op stub in `src/core/feedforward_interface.jl:51-58`. Tasks 6–9 rely on these. - -- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: - -```julia -@testset "Event parameters via mock construct - ThermalStandard UC" begin - device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) - model = PSI.mock_decision_model_from_system_name("c_sys5_uc") # see note below - mock_construct_device!(model, device_model; add_event_model = true) - container = IOM.get_optimization_container(model) - @test !isnothing( - IOM.get_parameter(container, AvailableStatusParameter(), PSY.ThermalStandard), - ) - @test !isnothing( - IOM.get_parameter( - container, - AvailableStatusChangeCountdownParameter(), - PSY.ThermalStandard, - ), - ) - param_array = - IOM.get_parameter_array(container, AvailableStatusParameter(), PSY.ThermalStandard) - # Initial availability is 1.0 for every (device, t) - @test all(IOM.jump_value.(param_array.data) .== 1.0) -end -``` - -Note: `mock_decision_model_from_system_name` is a placeholder for however existing POM tests build a `DecisionModel{MockOperationProblem}` — copy the exact construction from an existing `mock_construct_device!` caller (`grep -n -B5 "mock_construct_device!" test/test_device_source_constructors.jl | head -12`) and use the same helper (likely `PSI.DecisionModel(MockOperationProblem, ...)`-style via a `mock_*` factory in `test/test_utils/mock_operation_models.jl`). -Likewise confirm accessor names `IOM.get_parameter`, `IOM.get_parameter_array`, `IOM.jump_value` against usage in existing POM tests (`grep -rn "get_parameter_array\|jump_value" test/test_utils/model_checks.jl | head -5`) and match. - -- [ ] **Step 2: Run to verify failure.** Expected: FAIL — `mock_construct_device!` currently errors when `add_event_model = true` ("Event models are not supported in InfrastructureOptimizationModels..."). - -- [ ] **Step 3: Implement the parameter machinery.** Create `src/event_models/event_arguments.jl` with: - -```julia -################################################################################# -# Event parameter creation (ArgumentConstructStage) -################################################################################# - -function add_parameters!( - container::OptimizationContainer, - ::Type{T}, - devices::U, - device_model::DeviceModel{D, W}, - event_model::EventModel{V, X}, -) where { - T <: ParameterType, - U <: Vector{D}, - V <: PSY.Contingency, - W <: AbstractDeviceFormulation, - X <: AbstractEventCondition, -} where {D <: PSY.Component} - if get_rebuild_model(get_settings(container)) && has_container_key(container, T, D) - return - end - _add_parameters!(container, T(), devices, device_model, event_model) - return -end - -function _add_parameters!( - container::OptimizationContainer, - ::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) - parameter_container = add_param_container!( - container, - T, - U, - V, - PSY.get_name.(devices), - 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 - -################################################################################# -# Offset parameters into the system balance expressions. -# One method for every network family: `_balance_expression_targets` resolves the -# system/area/nodal targets per network model (this replaces PSI's four -# per-network methods). -################################################################################# - -function add_to_expression!( - container::OptimizationContainer, - ::Type{T}, - ::Type{U}, - devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, - device_model::DeviceModel{V, W}, - network_model::NetworkModel{X}, -) where { - T <: SystemBalanceExpressions, - U <: EventParameter, - V <: PSY.StaticInjection, - W <: AbstractDeviceFormulation, - X <: AbstractNetworkModel, -} - param_array = get_parameter_array(container, U(), V) - multiplier = get_parameter_multiplier_array(container, U(), V) - time_steps = get_time_steps(container) - for d in devices - targets = _balance_expression_targets(container, T, network_model, d) - name = PSY.get_name(d) - for t in time_steps - _apply_term_to_targets!(targets, param_array[name, t], multiplier[name, t], t) - end - end - return -end - -################################################################################# -# add_event_arguments! — overrides the no-op stub in core/feedforward_interface.jl -# for the injector families. No-ops when the DeviceModel has no events attached. -################################################################################# - -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 -``` - -Verify accessor availability: `get_parameter_array(container, ::EventParameter, ::Type)` and `get_parameter_multiplier_array(container, ::EventParameter, ::Type)` — check `grep -rn "get_parameter_multiplier_array" src/ | head -3`; if POM does not already use them, they are IOM exports (same names PSI used) — confirm with `julia --project=test -e 'using InfrastructureOptimizationModels; println(isdefined(InfrastructureOptimizationModels, :get_parameter_multiplier_array))'`. -`get_entry_type(key)` here is the Task 1 method for `EventKey`. - -- [ ] **Step 4: Wire the include.** In `src/PowerOperationsModels.jl`, find the last `include("common_models/...")` line and insert after it: - -```julia -include("event_models/event_arguments.jl") -``` - -- [ ] **Step 5: Enable the mock path.** In `test/test_utils/mock_operation_models.jl:116-133`, replace the `if add_event_model ... error(...) end` block with (adapted from PSI `test/test_utils/mock_operation_models.jl:114-131`, but using the real `set_event_model!` API instead of assigning the `events` field): - -```julia - if add_event_model - sys = IOM.get_system(problem) - device_type = IOM.get_component_type(model) - event_device = first(PSY.get_components(device_type, sys)) - transition_data = PSY.FixedForcedOutage(; outage_status = 0.0) - PSY.add_supplemental_attribute!(sys, event_device, transition_data) - mock_event_key = EventKey(PSY.FixedForcedOutage, device_type) - mock_event_model = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) - set_event_model!(model, mock_event_key, mock_event_model) - end -``` - -Confirm `IOM.get_component_type(model)` works on a `DeviceModel` (check `grep -rn "get_component_type" src/operation/template_validation.jl | head -2` for the established accessor and reuse it). - -- [ ] **Step 6: Run the Task 5 test.** Expected: PASS. Also rerun the whole `test/test_events.jl` and `julia --project=test test/runtests.jl test_device_thermal_generation_constructors` (no regressions from the mock change). -- [ ] **Step 7:** Formatter. - ---- - -### Task 6: Load and FixedOutput argument variants (offset parameters) - -**Files:** -- Modify: `src/event_models/event_arguments.jl` -- Test: `test/test_events.jl` - -**Interfaces:** -- Consumes: Task 5 machinery; load formulations `StaticPowerLoad`, `PowerLoadDispatch`, `PowerLoadInterruption` (`src/core/formulations.jl:65-75`); `FixedOutput` (IOM); network abstracts `AbstractActivePowerModel`, `AbstractReactivePowerNetworkModel` (`src/PowerOperationsModels.jl:33-41`); `ActivePowerBalance`, `ReactivePowerBalance`. -- Produces: `add_event_arguments!` methods for loads and `FixedOutput` that additionally create `ActivePowerOffsetParameter` (and `ReactivePowerOffsetParameter` on reactive-capable networks) and inject them into the balance expressions. - -- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: - -```julia -@testset "Event arguments for loads add offset parameters" begin - device_model = DeviceModel(PSY.PowerLoad, StaticPowerLoad) - model = # same mock DecisionModel construction as the Task 5 testset, system "c_sys5_uc" - mock_construct_device!(model, device_model; add_event_model = true) - container = IOM.get_optimization_container(model) - @test !isnothing( - IOM.get_parameter(container, ActivePowerOffsetParameter(), PSY.PowerLoad), - ) - # CopperPlate mock network -> active power balance expression contains the offset param. - # AvailableStatus/Countdown params exist too. - @test !isnothing( - IOM.get_parameter(container, AvailableStatusParameter(), PSY.PowerLoad), - ) -end -``` - -- [ ] **Step 2: Run to verify failure.** Expected: FAIL — the generic `StaticInjection` method from Task 5 runs (no offset parameter is created), so `get_parameter` for `ActivePowerOffsetParameter` errors/returns nothing. - -- [ ] **Step 3: Implement.** Append to `src/event_models/event_arguments.jl` four methods (adapted from PSI `contingency_arguments.jl:30-230`; PSI's `PM.AbstractActivePowerModel` → `AbstractActivePowerModel`, `PM.AbstractPowerModel` → `AbstractReactivePowerNetworkModel`): - -```julia -const _EventLoadFormulations = - Union{StaticPowerLoad, PowerLoadDispatch, PowerLoadInterruption} - -function _add_event_offset_arguments!( - container::OptimizationContainer, - devices_with_attributes::Vector{U}, - device_model::DeviceModel, - network_model::NetworkModel, - event_model::EventModel, - with_reactive::Bool, -) where {U <: PSY.StaticInjection} - 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, - ) - if with_reactive - 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, V}, - network_model::NetworkModel{<:AbstractActivePowerModel}, -) where { - T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, - V <: _EventLoadFormulations, -} 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") - _add_event_offset_arguments!( - container, - devices_with_attributes, - device_model, - network_model, - event_model, - false, - ) - end - return -end - -function add_event_arguments!( - container::OptimizationContainer, - devices::T, - device_model::DeviceModel{U, V}, - network_model::NetworkModel{<:AbstractReactivePowerNetworkModel}, -) where { - T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, - V <: _EventLoadFormulations, -} 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") - _add_event_offset_arguments!( - container, - devices_with_attributes, - device_model, - network_model, - event_model, - true, - ) - end - return -end - -function add_event_arguments!( - container::OptimizationContainer, - devices::T, - device_model::DeviceModel{U, FixedOutput}, - network_model::NetworkModel{<: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") - _add_event_offset_arguments!( - container, - devices_with_attributes, - device_model, - network_model, - event_model, - false, - ) - end - return -end - -function add_event_arguments!( - container::OptimizationContainer, - devices::T, - device_model::DeviceModel{U, FixedOutput}, - network_model::NetworkModel{<:AbstractReactivePowerNetworkModel}, -) 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") - _add_event_offset_arguments!( - container, - devices_with_attributes, - device_model, - network_model, - event_model, - true, - ) - end - return -end -``` - -The `_add_event_offset_arguments!` helper is a POM addition (PSI repeats the body four times); it is private to this file. - -- [ ] **Step 4: Ambiguity check.** The load methods overlap the Task 5 generic (`U<:StaticInjection`, unconstrained network) and the `FixedOutput` methods overlap both. - -Run: `julia --project=test -e 'using Test, PowerOperationsModels; println(length(detect_ambiguities(PowerOperationsModels)))'` -Expected: same count as before this task (measure on `main` first if unsure; new count must not increase). -If new ambiguities appear between the load and `FixedOutput` methods (a `DeviceModel{PowerLoad, FixedOutput}` matches both), add tie-breaker methods `add_event_arguments!(container, devices, ::DeviceModel{U, FixedOutput}, ::NetworkModel{<:AbstractActivePowerModel}) where {U <: PSY.PowerLoad}` (and the reactive twin) that forward to the `FixedOutput` behavior. - -- [ ] **Step 5: Run the test to verify it passes.** Expected: PASS. -- [ ] **Step 6:** Formatter; rerun `test/test_events.jl` in full. - ---- - -### Task 7: Core event constraints — `src/event_models/event_constraints.jl` - -**Files:** -- Create: `src/event_models/event_constraints.jl` -- Modify: `src/PowerOperationsModels.jl` (include + constraint exports) -- Test: `test/test_events.jl` - -**Interfaces:** -- Consumes: `ActivePowerOutageConstraint`, `ReactivePowerOutageConstraint` (`src/core/constraints.jl:631-633`); `add_parameterized_upper_bound_range_constraints` (same call shape as `src/static_injector_models/hydro_generation.jl:614-622`); `ActivePowerRangeExpressionUB`, `ActivePowerVariable`, `ReactivePowerVariable`; `has_service_model` (`src/PowerOperationsModels.jl:160`); `add_constraints_container!`, `get_parameter_array`, `get_parameter_multiplier_array`, `get_jump_model`, `get_time_steps`. -- Produces: `add_event_constraints!` methods for `PSY.ThermalGen`, `PSY.RenewableGen`, `PSY.ElectricLoad` (× active-only / reactive-capable networks); `add_reactive_power_contingency_constraint(...)` and `_get_reactive_power_upper_bound(device)`. Task 8 reuses `add_reactive_power_contingency_constraint` exactly as named. - -- [ ] **Step 1: Write the failing test.** Append to `test/test_events.jl`: - -```julia -@testset "Event constraints - thermal UC counts and coefficients" begin - device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) - model = # same mock DecisionModel construction as Task 5, system "c_sys5_uc" - mock_construct_device!(model, device_model; add_event_model = true) - container = IOM.get_optimization_container(model) - cons = IOM.get_constraint( - container, - ActivePowerOutageConstraint(), - PSY.ThermalStandard, - ) - n_thermal_with_event = 1 # mock attaches the outage to exactly one device - time_steps = IOM.get_time_steps(container) - @test size(cons)[1] == n_thermal_with_event - @test size(cons)[2] == length(time_steps) - # Coefficient check: constraint is expr(p) - ub * status <= 0 with status = 1.0 - # (params are plain Float64 in a non-recurrent build, so the RHS is baked in). - c1 = JuMP.constraint_object(cons[axes(cons)[1][1], 1]) - @test c1.set isa MOI.LessThan{Float64} -end -``` - -Confirm `IOM.get_constraint` naming against existing tests (`grep -rn "get_constraint(" test/test_utils/model_checks.jl | head -3`). - -- [ ] **Step 2: Run to verify failure.** Expected: FAIL — no `ActivePowerOutageConstraint` container exists (the stub `add_event_constraints!` no-ops). - -- [ ] **Step 3: Implement.** Create `src/event_models/event_constraints.jl` (adapted from PSI `contingency_constraints.jl`; network bounds swapped to POM abstracts; **`PSY.SU` added to every convertible getter**): - -```julia -################################################################################# -# Event outage constraints (ModelConstructStage). Overrides the no-op stub in -# core/feedforward_interface.jl for the supported injector families. -################################################################################# - -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 <: 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 <: AbstractReactivePowerNetworkModel, -} 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 <: 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 = - has_service_model(device_model) ? ActivePowerRangeExpressionUB : - ActivePowerVariable - 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 <: AbstractReactivePowerNetworkModel, -} 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 = - has_service_model(device_model) ? ActivePowerRangeExpressionUB : - ActivePowerVariable - 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 <: 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 <: AbstractReactivePowerNetworkModel, -} 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 - -################################################################################# -# Quadratic reactive-power outage constraint: q^2 <= ub * status -################################################################################# - -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 <: AbstractReactivePowerNetworkModel, -} - 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, - param::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, param, 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 - -_get_reactive_power_upper_bound(device::PSY.StaticInjection) = begin - limits = PSY.get_reactive_power_limits(device, PSY.SU) - max(limits.max^2, limits.min^2) -end - -_get_reactive_power_upper_bound(device::PSY.ElectricLoad) = - PSY.get_max_reactive_power(device, PSY.SU)^2 -``` - -- [ ] **Step 4: Wire include and exports.** Add after the Task 5 include: - -```julia -include("event_models/event_constraints.jl") -``` - -Check whether `ActivePowerOutageConstraint`/`ReactivePowerOutageConstraint`/`ActivePowerPumpOutageConstraint`/the four event parameter types are already exported (`grep -n "OutageConstraint\|AvailableStatus\|OffsetParameter" src/PowerOperationsModels.jl`); export any that are missing. - -- [ ] **Step 5: Run the test.** Expected: PASS. -Then add and run two more testsets following the identical pattern: renewable (`DeviceModel(PSY.RenewableDispatch, RenewableFullDispatch)`, expect `ActivePowerVariable` LHS since no service model) and load (`DeviceModel(PSY.PowerLoad, PowerLoadDispatch)`, expect the constraint on `ActivePowerVariable`). -Use the fixture each device type exists in (`c_sys5_re` for renewables, `c_sys5` for loads — confirm with `grep -rn "c_sys5_re" test/test_device_renewable_generation_constructors.jl | head -2`). -- [ ] **Step 6:** Formatter; ambiguity count check as in Task 6 Step 4. - ---- - -### Task 8: Hydro and storage event constraints - -**Files:** -- Modify: `src/event_models/event_constraints.jl` -- Test: `test/test_events.jl` - -**Interfaces:** -- Consumes: Task 7's `add_reactive_power_contingency_constraint`; `ActivePowerPumpOutageConstraint` (`src/core/constraints.jl:881`), `ActivePowerPumpVariable` (`src/core/variables.jl:667`), `ActivePowerInVariable`, `ActivePowerOutVariable` (storage); `PSY.HydroGen`, `PSY.HydroPumpTurbine`, `PSY.EnergyReservoirStorage`. -- Produces: `add_event_constraints!` for `PSY.HydroGen` (×2 networks), `PSY.HydroPumpTurbine` (×2), `PSY.EnergyReservoirStorage` (×2); helpers `add_pump_turbine_active_power_contingency_constraints!` and `add_input_output_active_power_contingency_constraints!`. - -- [ ] **Step 1: Write the failing tests.** Append to `test/test_events.jl`: - -```julia -@testset "Event constraints - hydro" begin - device_model = DeviceModel(PSY.HydroDispatch, HydroDispatchRunOfRiver) - model = # mock DecisionModel construction, hydro fixture (see note) - mock_construct_device!(model, device_model; add_event_model = true) - container = IOM.get_optimization_container(model) - @test !isnothing( - IOM.get_constraint(container, ActivePowerOutageConstraint(), PSY.HydroDispatch), - ) -end - -@testset "Event constraints - storage" begin - device_model = DeviceModel(PSY.EnergyReservoirStorage, StorageDispatchWithReserves) - model = # mock DecisionModel construction, storage fixture (see note) - mock_construct_device!(model, device_model; add_event_model = true) - container = IOM.get_optimization_container(model) - cons_in = IOM.get_constraint( - container, - ActivePowerOutageConstraint(), - PSY.EnergyReservoirStorage, - "input", - ) - cons_out = IOM.get_constraint( - container, - ActivePowerOutageConstraint(), - PSY.EnergyReservoirStorage, - "output", - ) - @test !isnothing(cons_in) - @test !isnothing(cons_out) -end -``` - -Fixture and formulation notes: find the hydro fixture and formulation names used by `test/test_device_hydro_constructors.jl` (`grep -n "build_system\|DeviceModel(" test/test_device_hydro_constructors.jl | head -6`) and the storage equivalents in `test/test_storage_device_models.jl`-style files (`grep -rn "EnergyReservoirStorage" test/ | head -5`); use the same names. -The meta-string variant of `IOM.get_constraint` (`"input"`/`"output"`) — confirm the accessor arity in `test/test_utils/model_checks.jl` usage; if metas are addressed differently, match it. -If POM has a `PSY.HydroPumpTurbine` formulation and fixture, add a third testset for the pump constraint (`ActivePowerPumpOutageConstraint`); if no fixture exists (`grep -rn "HydroPumpTurbine" test/ | head -3` empty), note it in the test file as uncovered and still implement the methods. - -- [ ] **Step 2: Run to verify failure.** Expected: FAIL (constraints don't exist — the generic stub no-ops for hydro/storage). - -- [ ] **Step 3: Implement.** Append to `src/event_models/event_constraints.jl` (ported from HPS/SSS `src/contingency_model.jl` with `PSY.SU` unit fixes and POM network abstracts; the `@assert !isempty` in SSS becomes a loud `error` to match the rest of the file): - -```julia -################################################################################# -# Hydro (ported from HydroPowerSimulations src/contingency_model.jl) -################################################################################# - -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 <: AbstractActivePowerModel, -} where {U <: PSY.HydroGen} - 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 <: AbstractReactivePowerNetworkModel, -} where {U <: PSY.HydroGen} - 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 -``` - -Note on the HydroGen active-power LHS: HPS uses `ActivePowerRangeExpressionUB`. -If a targeted hydro formulation in POM does not create that expression, the constraint call will throw at build — run the Task 8 hydro testset against each hydro formulation POM's constructors wire (`grep -n "DeviceModel{" src/static_injector_models/hydrogeneration_constructor.jl | head`), and for any formulation without the UB range expression use `ActivePowerVariable` as the LHS in a formulation-specific method, mirroring the renewable pattern. - -```julia -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 <: AbstractActivePowerModel, -} where {U <: PSY.HydroPumpTurbine} - 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_pump_turbine_active_power_contingency_constraints!( - container, - devices_with_attributes, - device_model, - ) - 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 <: AbstractReactivePowerNetworkModel, -} where {U <: PSY.HydroPumpTurbine} - 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_pump_turbine_active_power_contingency_constraints!( - container, - devices_with_attributes, - device_model, - ) - add_reactive_power_contingency_constraint( - container, - ReactivePowerOutageConstraint, - ReactivePowerVariable, - AvailableStatusParameter, - devices_with_attributes, - device_model, - W, - ) - end - return -end - -function add_pump_turbine_active_power_contingency_constraints!( - container::OptimizationContainer, - devices::T, - device_model::DeviceModel{U, V}, -) where { - T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, - V <: AbstractDeviceFormulation, -} where {U <: PSY.HydroPumpTurbine} - names = PSY.get_name.(devices) - time_steps = get_time_steps(container) - array_active_power = get_variable(container, ActivePowerVariable(), U) - array_active_power_pump = get_variable(container, ActivePowerPumpVariable(), U) - constraint_active_power = add_constraints_container!( - container, - ActivePowerOutageConstraint(), - U, - names, - time_steps, - ) - constraint_active_power_pump = add_constraints_container!( - container, - ActivePowerPumpOutageConstraint(), - U, - names, - time_steps, - ) - param_array = get_parameter_array(container, AvailableStatusParameter(), U) - jump_model = get_jump_model(container) - for device in devices, t in time_steps - name = PSY.get_name(device) - ub_active_power = PSY.get_active_power_limits(device, PSY.SU).max - constraint_active_power[name, t] = JuMP.@constraint( - jump_model, - array_active_power[name, t] <= ub_active_power * param_array[name, t] - ) - ub_active_power_pump = PSY.get_active_power_limits_pump(device, PSY.SU).max - constraint_active_power_pump[name, t] = JuMP.@constraint( - jump_model, - array_active_power_pump[name, t] <= - ub_active_power_pump * param_array[name, t] - ) - end - return -end - -################################################################################# -# Storage (ported from StorageSystemsSimulations src/contingency_model.jl) -################################################################################# - -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 <: AbstractActivePowerModel, -} where {U <: PSY.EnergyReservoirStorage} - 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_input_output_active_power_contingency_constraints!( - container, - devices_with_attributes, - device_model, - ) - 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 <: AbstractReactivePowerNetworkModel, -} where {U <: PSY.EnergyReservoirStorage} - 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_input_output_active_power_contingency_constraints!( - container, - devices_with_attributes, - device_model, - ) - add_reactive_power_contingency_constraint( - container, - ReactivePowerOutageConstraint, - ReactivePowerVariable, - AvailableStatusParameter, - devices_with_attributes, - device_model, - W, - ) - end - return -end - -function add_input_output_active_power_contingency_constraints!( - container::OptimizationContainer, - devices::T, - device_model::DeviceModel{U, V}, -) where { - T <: Union{Vector{U}, IS.FlattenIteratorWrapper{U}}, - V <: AbstractDeviceFormulation, -} where {U <: PSY.EnergyReservoirStorage} - names = PSY.get_name.(devices) - time_steps = get_time_steps(container) - array_in = get_variable(container, ActivePowerInVariable(), U) - array_out = get_variable(container, ActivePowerOutVariable(), U) - constraint_input = add_constraints_container!( - container, - ActivePowerOutageConstraint(), - U, - names, - time_steps; - meta = "input", - ) - constraint_output = add_constraints_container!( - container, - ActivePowerOutageConstraint(), - U, - names, - time_steps; - meta = "output", - ) - param_array = get_parameter_array(container, AvailableStatusParameter(), U) - jump_model = get_jump_model(container) - for device in devices, t in time_steps - name = PSY.get_name(device) - ub_input = PSY.get_input_active_power_limits(device, PSY.SU).max - constraint_input[name, t] = JuMP.@constraint( - jump_model, - array_in[name, t] <= ub_input * param_array[name, t] - ) - ub_output = PSY.get_output_active_power_limits(device, PSY.SU).max - constraint_output[name, t] = JuMP.@constraint( - jump_model, - array_out[name, t] <= ub_output * param_array[name, t] - ) - end - return -end -``` - -- [ ] **Step 4: Run the tests.** Expected: PASS (with fixture/formulation names resolved per Step 1 notes). -- [ ] **Step 5:** Formatter; ambiguity count check; run `julia --project=test test/runtests.jl test_device_hydro_constructors` for regression. - ---- - -### Task 9: End-to-end build/solve tests and forced-outage behavior - -**Files:** -- Test: `test/test_events.jl` - -**Interfaces:** -- Consumes: everything from Tasks 1–8; `HiGHS_optimizer` (`test/test_utils/solver_definitions.jl`); PSB fixtures. - -- [ ] **Step 1: Full-template build+solve across networks.** Append to `test/test_events.jl`: - -```julia -@testset "E2E: thermal UC with FixedForcedOutage event - $(net)" for net in - (CopperPlateNetworkModel, PTDFNetworkModel, DCPNetworkModel) - sys = PSB.build_system(PSB.PSITestSystems, "c_sys5_uc") - thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) - attach_fixed_forced_outage!(sys, thermal) - template = get_thermal_dispatch_template_network(NetworkModel(net)) - em = EventModel( - PSY.FixedForcedOutage, - ContinuousCondition(); - timeseries_mapping = Dict{Symbol, Union{String, Nothing}}(:outage_status => "outage_profile"), - ) - set_event_model!(template, em) - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(model; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res = IOM.OptimizationProblemOutputs(model) - # Event parameters are written to results (should_write_resulting_value = true) - @test "AvailableStatusParameter__ThermalStandard" in - IOM.list_parameter_names(res) -end -``` - -Confirm `solve!` vs `IOM.solve!` and `list_parameter_names` against `test/test_model_decision.jl` usage and match. -Add an ACP variant testset (`ACPNetworkModel` with `ipopt_optimizer`) asserting the quadratic `ReactivePowerOutageConstraint` exists: -`IOM.get_constraint(IOM.get_optimization_container(model), ReactivePowerOutageConstraint(), PSY.ThermalStandard, "ub")`. - -- [ ] **Step 2: Forced-zero behavior.** Append: - -```julia -@testset "Forced outage drives device output to zero" begin - device_model = DeviceModel(PSY.ThermalStandard, ThermalBasicUnitCommitment) - model = # mock DecisionModel construction as in Task 5, system "c_sys5_uc" - mock_construct_device!( - model, - device_model; - add_event_model = true, - built_for_recurrent_solves = true, - ) - container = IOM.get_optimization_container(model) - param_array = IOM.get_parameter_array( - container, - AvailableStatusParameter(), - PSY.ThermalStandard, - ) - outaged_name = axes(param_array)[1][1] - for t in axes(param_array)[2] - JuMP.fix(param_array[outaged_name, t], 0.0; force = true) - end - jm = IOM.get_jump_model(container) - JuMP.set_optimizer(jm, HiGHS.Optimizer) - JuMP.set_silent(jm) - JuMP.optimize!(jm) - @test JuMP.termination_status(jm) in (MOI.OPTIMAL, MOI.LOCALLY_SOLVED) - p = IOM.get_variable(container, ActivePowerVariable(), PSY.ThermalStandard) - @test all( - abs(JuMP.value(p[outaged_name, t])) <= 1e-6 for t in axes(p)[2] - ) -end -``` - -In recurrent-solve mode the parameters are fixed JuMP variables, so `JuMP.fix` works; the UC formulation lets the unit commit off, keeping the model feasible with zero output. -If the mock container carries no objective, `optimize!` solves a feasibility problem — that is sufficient; the binding `p ≤ max·status = 0` constraint forces the result regardless of objective. -If the mock construct does not build the balance/objective needed for feasibility, relax the test to: assert the `ActivePowerOutageConstraint` row for `(outaged_name, t)` has its RHS/parameter term at `0.0` after fixing (inspect via `JuMP.constraint_object`). - -- [ ] **Step 3: Run the whole events file.** - -Run: `julia --project=test -e 'include("test/includes.jl"); include("test/test_events.jl")'` -Expected: all testsets PASS. - -- [ ] **Step 4: Run the file under the parallel runner** (fresh-module context catches `Main`-only bugs): - -Run: `julia --project=test test/runtests.jl test_events` -Expected: PASS. - -- [ ] **Step 5:** Formatter. - ---- - -### Task 10: Documentation - -**Files:** -- Modify: `docs/src/reference/public.md` (and `docs/src/reference/formulation_library.md` if it enumerates constraints/parameters) - -- [ ] **Step 1: Register new public symbols.** Open `docs/src/reference/public.md` and determine its convention (explicit `@docs` blocks vs `@autodocs`). -If symbols are listed explicitly, add: `EventModel`, `EventKey`, `AbstractEventCondition`, `ContinuousCondition`, `PresetTimeCondition`, `StateVariableValueCondition`, `DiscreteEventCondition`, `set_event_model!`, `get_event_models`, `supports_events`, `get_empty_timeseries_mapping`, `get_event_type`, `get_event_condition`, `get_attribute_device_map`, plus the parameter/constraint types if other parameters/constraints are listed there (`AvailableStatusParameter`, `AvailableStatusChangeCountdownParameter`, `ActivePowerOffsetParameter`, `ReactivePowerOffsetParameter`, `ActivePowerOutageConstraint`, `ReactivePowerOutageConstraint`, `ActivePowerPumpOutageConstraint`). - -- [ ] **Step 2: Formulation-library entry.** If `docs/src/reference/formulation_library.md` documents device formulations' constraints, add a short "Outage events" subsection: - -```markdown -## Outage events - -Attaching an `EventModel` for a `PSY.Contingency` supplemental attribute (e.g. -`FixedForcedOutage`) to a template adds availability parameters and outage -constraints to every supported device carrying the attribute. - -Parameters (per device and time step): `AvailableStatusParameter` (1 = available, -initialized to 1), `AvailableStatusChangeCountdownParameter`, and for loads and -`FixedOutput` devices the balance offsets `ActivePowerOffsetParameter` / -`ReactivePowerOffsetParameter`. - -Constraints: - -``math -p_{d,t} \le \overline{P}_d \cdot \text{status}_{d,t} -`` - -with the LHS given by the device family (range-expression upper bound for thermal -and hydro, the active power variable for loads and renewables without services, -charge/discharge variables for storage, generation and pumping variables for pump -turbines). Under reactive-power-capable networks the quadratic constraint -``q_{d,t}^2 \le \overline{Q}_d^2 \cdot \text{status}_{d,t}`` is also added. - -The parameter values are constant within a single build; updating them across -solves (outage sampling, countdown projection) is simulation-runtime functionality -that lives outside this package. -``` - -(Match the file's existing math-fence style — ```` ```math ```` fences vs `` ``math `` inline — before pasting.) - -- [ ] **Step 3: Build docs.** - -Run: `julia --project=docs docs/make.jl` -Expected: build completes; no missing-docstring or cross-reference errors. -Fix any failures by adding the flagged docstring or registration. - -- [ ] **Step 4:** Formatter (it formats `docs/src` too). - ---- - -### Task 11: Final gates and plan-file bookkeeping - -**Files:** -- Modify: `.claude/pom_port_plan.md` - -- [ ] **Step 1: Ambiguity gate.** - -Run: `julia --project=test -e 'using Test, PowerOperationsModels; a = detect_ambiguities(PowerOperationsModels); println(length(a)); foreach(println, a)'` -Expected: count identical to `main` baseline (measure by stashing if needed). New ambiguities from `add_event_*` overlaps must be fixed with tie-breaker methods, not ignored. - -- [ ] **Step 2: Full test suite.** - -Run: `julia --project=test test/runtests.jl --jobs=8` -Expected: all files PASS, including `test_events`. Investigate and fix any regression before proceeding (per repo rules, fix unrelated flakiness you hit rather than rerunning around it). - -- [ ] **Step 3: Update the port plan.** In `.claude/pom_port_plan.md`: -correct the stale line 144-147 note ("POM has no `core/event_model.jl` ... only the `AvailableStatusParameter` type exists") to record that the event framework is ported (template-level `set_event_model!`, `src/event_models/`, build-level `test_events.jl`), that hydro/storage/pump-turbine constraint coverage from HPS/SSS is included, and that the remaining PSI-side gap is simulation-runtime only (condition evaluation, sampling, state projection — out of POM scope). -Also update the "Workstream C" line in the execution order accordingly. - -- [ ] **Step 4: Verify the IOM fix is upstream and drop the local override.** Confirm the Task 4b change has merged to IOM `main` (`gh pr list --repo Sienna-Platform/InfrastructureOptimizationModels.jl --state merged --search "SupplementalAttribute"` or ask the user). -Then restore POM's normal resolution and re-verify against the real upstream: - -Run: `julia --project=test -e 'using Pkg; Pkg.free("InfrastructureOptimizationModels"); Pkg.update("InfrastructureOptimizationModels")'` -(if `Pkg.free` errors for a `[sources]`-pinned package, `Pkg.update("InfrastructureOptimizationModels")` alone re-resolves from the pinned branch once the dev entry is removed — check `test/Manifest.toml` no longer holds a local path for IOM). -Then rerun: `julia --project=test test/runtests.jl test_events` -Expected: PASS against IOM `main`. If the IOM PR has not merged yet, leave the `Pkg.develop` bridge in place, report events as blocked-on-IOM-merge, and do not sign off the plan. - -- [ ] **Step 5: Final formatter pass and diff review.** - -```bash -julia --project=scripts/formatter -e 'include("scripts/formatter/formatter_code.jl")' -git add -N src/event_models/ test/test_utils/events_test_utils.jl test/test_events.jl -git status --short -``` - -Expected: only intended files modified/added; everything unstaged (`git add -N` only marks intent); no commits made. - ---- - -## Plan self-review notes (already applied) - -- Spec coverage: package split (Tasks 1–8 POM-side; IOM gets exactly the Task 4b type-bound correction, everything else consumed as-is), template attachment + discovery + TS validation (Tasks 3–4), psy6 units fixes (`PSY.SU` in Tasks 7–8 code), IC exclusion (Task 4 Step 6), hydro/storage/pump coverage (Task 8), E2E + forced-zero behavior (Task 9), docs gate (Task 10), ambiguities + full suite + IOM-merge verification + port-plan bookkeeping (Task 11), concurrency constraint (Global Constraints: forbidden files owned by the transformer plan). -- Deviation from spec, deliberate: constructor count-tests live in `test/test_events.jl` rather than appended to the existing `test_device_*_constructors.jl` files, to keep the events branch free of textual conflicts with the concurrent transformer-refactor branch. Coverage is identical. -- Deviation from spec, deliberate: PSI's four per-network `add_to_expression!` methods collapse into one method built on POM's `_balance_expression_targets` (covers CopperPlate, PTDF/AreaPTDF, AreaBalance, and nodal AC/DCP in one dispatch) — POM grew this abstraction after the spec's inventory was written. -- The `events` field on `PowerOperationsProblemTemplate` is typed `Vector{IOM.AbstractEventModel}` (not `Vector{EventModel}` as the spec sketched) because `core/problem_template.jl` is included before `event_models/`; accessors still return the concrete `EventModel`s. -- Known verification points intentionally delegated to task steps (each has an explicit check command): IOM ownership of `get_entry_type` generics, `get_model(template, T)` accessor name, `PSY.get_time_series` on supplemental attributes, mock `DecisionModel` factory name, fixture names per device family, `IOM.get_constraint` meta arity, hydro formulations lacking `ActivePowerRangeExpressionUB`. diff --git a/.claude/plans/2026-07-30-reserve-wiring-module-context-bug.md b/.claude/plans/2026-07-30-reserve-wiring-module-context-bug.md deleted file mode 100644 index 3c295c04..00000000 --- a/.claude/plans/2026-07-30-reserve-wiring-module-context-bug.md +++ /dev/null @@ -1,44 +0,0 @@ -# Follow-up: reserve wiring into `ActivePowerRangeExpressionUB` is module-context dependent - -> Discovered 2026-07-30 during the events-port final fix wave, while attempting a -> renewable + service-model event-constraint test. Pre-existing bug, unrelated to events. -> Not yet root-caused; recorded here so the investigation isn't lost. - -## Symptom (100% reproducible, not flakiness) - -Build a real (non-mock) template on `c_sys5_re` with `add_reserves = true`: -`RenewableFullDispatch` device model + `RangeReserve` service model, then `build!` a `DecisionModel`. - -- Run as a top-level script (`julia --project=test script.jl`, `Main` context): - `has_service_model(device_model)` is `true` and `ActivePowerRangeExpressionUB` rows contain - **2 terms** (`ActivePowerVariable` + the reserve variable) — correct. -- Run the identical code inside **any** wrapping `module` block (exactly how - `ParallelTestRunner` runs every test file — fresh module, not `Main`): - the `ActivePowerReserveVariable` container exists, but the reserve variable - **never lands in `ActivePowerRangeExpressionUB`** — rows have only 1 term. - -Traced through `finalize_template!`, `_populate_contributing_devices!`, -`_add_services_to_device_model!`, `construct_services!` without isolating the trigger. - -## Strongest lead - -`src/core/problem_template.jl` keys the **services** dict by `Symbol(T)` -(lines ~93-94 and ~247 at time of writing) while the devices/branches paths use -`nameof(T)` (lines ~80, ~82). -`Symbol(T)`/`string(T)` dict keys are exactly the module-context bug class this repo's -CLAUDE.md already documents under "ParallelTestRunner specifics" (`Symbol(T)` embeds the -defining module path for types resolved differently in a fresh module; `nameof` does not). - -## Impact - -Any test or user code exercising service-augmented range expressions under the parallel -runner may silently build without reserve contributions — a silent-wrong-model class, not -a loud failure. -Also blocks adding a renewable+service event-constraint testset (the events port shipped -without that branch covered for this reason). - -## Suggested next step - -Reproduce with a minimal `module M; include(...); end` wrapper, then audit -`problem_template.jl`'s `Symbol(T)` keying against `nameof(T)`, fix, and add a -parallel-runner regression test plus the deferred renewable+service event testset. From 17c80e3df1c2adef4494bbb04ec38852b3e2f453 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Mon, 3 Aug 2026 13:34:21 -0700 Subject: [PATCH 19/19] events cleanup --- src/core/feedforward_interface.jl | 15 +++++++- src/operation/template_validation.jl | 28 +++++++++++++-- test/test_events.jl | 54 ++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/core/feedforward_interface.jl b/src/core/feedforward_interface.jl index 866a02e9..529de69b 100644 --- a/src/core/feedforward_interface.jl +++ b/src/core/feedforward_interface.jl @@ -59,12 +59,25 @@ end # ---- Event constraints (ModelConstructStage) ---- +# Fallback for device models with no outage-constraint implementation. It must stay a +# no-op for the empty-events case (every constructor calls this unconditionally), but a +# device model that carries events and lands here would get availability parameters that +# nothing in the optimization enforces — a silent wrong model — so that case errors. function add_event_constraints!( ::OptimizationContainer, ::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, - ::DeviceModel, + device_model::DeviceModel, ::NetworkModel, ) where {V <: PSY.Component} + if !isempty(get_events(device_model)) + error( + "DeviceModel{$(get_component_type(device_model)), \ + $(get_formulation(device_model))} has event models attached but no \ + add_event_constraints! implementation; its devices would get availability \ + parameters that no constraint enforces. Remove the event model or implement \ + event constraints for this device model.", + ) + end return end # requires SemiContinuousFeedforward to be defined, which probably belongs in PSI diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 3de417da..10a8bec4 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -674,14 +674,15 @@ function _build_device_model_events!( ) for event_model in get_event_models(template) event_type = get_event_type(event_model) - if isempty(PSY.get_supplemental_attributes(event_type, sys)) + attributes = PSY.get_supplemental_attributes(event_type, sys) + if isempty(attributes) error( "There are no supplemental attributes of type $event_type in the system. \ Add the outage data to the system or remove the event model from the \ template.", ) end - for event in PSY.get_supplemental_attributes(event_type, sys) + for event in attributes _validate_event_timeseries_data(sys, event, event_model) event_uuid = IS.get_uuid(event) attribute_device_map = get_attribute_device_map(event_model) @@ -730,6 +731,24 @@ function _build_device_model_events!( (contingency type, device type) pair is supported. Merge the \ event models or remove one from the template.", ) + elseif !isempty(existing_events) + # A second event model of a *different* contingency type also can't + # coexist on one device model: event parameter containers are keyed + # by (parameter type, device type) only — the contingency type is + # not part of the key — so the two models' parameters would collide + # in the optimization container. Fail here with a clear message + # instead of deep in container construction. + other_types = join( + unique(get_event_type(m) for m in values(existing_events)), + ", ", + ) + error( + "Device type $device_type is already targeted by an event model \ + of contingency type $other_types; a second event model of \ + contingency type $event_type cannot be added because event \ + parameters are keyed by device type only and would collide. \ + Attach at most one event model per device type.", + ) end IOM.set_event_model!(device_model, key, event_model) end @@ -747,7 +766,10 @@ function _validate_event_timeseries_data( if !isnothing(v) try PSY.get_time_series(IS.SingleTimeSeries, event, v) - catch + catch e + # A missing series surfaces as ArgumentError; anything else is a real + # failure that must not be masked as missing data. + e isa ArgumentError || rethrow() device_names = PSY.get_name.(PSY.get_associated_components(sys, event)) error( diff --git a/test/test_events.jl b/test/test_events.jl index c7d76bb1..62d7fbe4 100644 --- a/test/test_events.jl +++ b/test/test_events.jl @@ -638,3 +638,57 @@ end abs(JuMP.value(p[outaged_name, t])) <= 1e-6 for t in axes(p)[2] ) end + +@testset "Two event models of different contingency types on one device type fail loudly" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_uc") + thermal = first(PSY.get_components(PSY.ThermalStandard, sys)) + attach_fixed_forced_outage!(sys, thermal) + geo_outage = PSY.GeometricDistributionForcedOutage(; + mean_time_to_recovery = 2.0, + outage_transition_probability = 0.1, + ) + PSY.add_supplemental_attribute!(sys, thermal, geo_outage) + + template = get_thermal_dispatch_template_network(NetworkModel(CopperPlateNetworkModel)) + em_fixed = EventModel( + PSY.FixedForcedOutage, + ContinuousCondition(); + timeseries_mapping = Dict{Symbol, Union{String, Nothing}}( + :outage_status => "outage_profile", + ), + ) + em_geo = EventModel(PSY.GeometricDistributionForcedOutage, ContinuousCondition()) + set_event_model!(template, em_fixed) + set_event_model!(template, em_geo) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + # Discovery must reject the second event model with a clear error instead of letting + # the two models' parameter containers collide inside the optimization container. + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED +end + +@testset "Event constraints stub errors when events are attached, no-ops when empty" begin + sys = PSB.build_system(PSITestSystems, "c_sys5_uc") + model = DecisionModel(MockOperationProblem, CopperPlateNetworkModel, sys) + container = IOM.get_optimization_container(model) + network_model = NetworkModel(CopperPlateNetworkModel) + + # Empty events dict: the fallback stays a silent no-op (constructors call it + # unconditionally for every device model). + clean_model = DeviceModel(PSY.Source, FixedOutput) + @test isnothing( + POM.add_event_constraints!(container, PSY.Source[], clean_model, network_model), + ) + + # Events attached to a device model with no constraint implementation: availability + # parameters would be enforced by nothing, so the fallback must error. + event_model = DeviceModel(PSY.Source, FixedOutput) + em = EventModel(PSY.FixedForcedOutage, ContinuousCondition()) + set_event_model!(event_model, EventKey(PSY.FixedForcedOutage, PSY.Source), em) + @test_throws ErrorException POM.add_event_constraints!( + container, + PSY.Source[], + event_model, + network_model, + ) +end