diff --git a/docs/src/explanation/supplemental_attributes.md b/docs/src/explanation/supplemental_attributes.md index 0eb5a30025..2b1e2b59d5 100644 --- a/docs/src/explanation/supplemental_attributes.md +++ b/docs/src/explanation/supplemental_attributes.md @@ -169,6 +169,62 @@ end - [`GeometricDistributionForcedOutage`](@ref) - [`PlannedOutage`](@ref) +#### Narrowing post-contingency monitoring + +Every concrete [`Outage`](@ref) carries a `monitored_components` field of type +`Vector{Base.UUID}`. It identifies the [`Device`](@ref)s whose post-contingency +state a downstream simulation package (e.g., PowerSimulations) should model when +this outage occurs. Limiting the list reduces the number of post-outage variables +and constraints in security-constrained models. + +PowerSystems itself does not attach meaning to the contents of the list. In +particular, an empty `monitored_components` is left for the consumer to +interpret — typical conventions are "monitor nothing" (skip post-contingency +modeling) or "monitor everything" (preserve full N-1 behavior). Pick the policy +that matches your downstream model. + +The constructor accepts any iterable whose elements are `Base.UUID` or +`Device` — for example a `Vector`, a generator expression, or the iterator +returned by [`get_components`](@ref). Devices are converted to UUIDs +internally: + +```julia +gen1 = get_component(ThermalStandard, system, "gen1") +gen2 = get_component(ThermalStandard, system, "gen2") +outage = FixedForcedOutage(; + outage_status = 0.0, + monitored_components = [gen1, gen2], +) +add_supplemental_attribute!(system, gen1, outage) + +# Equivalent — every ThermalStandard in the system: +outage_all = FixedForcedOutage(; + outage_status = 0.0, + monitored_components = get_components(ThermalStandard, system), +) +``` + +Use the dedicated accessors to inspect or update the list at any time. The +singular `add_/remove_*!` methods take one `UUID` or `Device`; the plural +`add_/remove_*s!` and `set_` methods take any iterable of either. +`set_monitored_components!` requires the list to be empty — call +`clear_monitored_components!` first to replace an existing list: + +```julia +get_monitored_components(outage) # → Vector{UUID} +clear_monitored_components!(outage) # wipe +set_monitored_components!(outage, get_components(Line, system)) # populate (must be empty) +add_monitored_component!(outage, gen2) # append one (deduped) +add_monitored_components!(outage, [gen1, gen2]) # append many +remove_monitored_component!(outage, gen1) # remove one +remove_monitored_components!(outage, [gen1, gen2]) # remove many +``` + +When `system.runchecks == true`, `add_supplemental_attribute!` resolves each +UUID against the parent system and raises an `ArgumentError` for any UUID that +does not point to a `Device` in the system. With `runchecks = false`, UUIDs are +accepted as-is and resolution is deferred to the consumer. + ### Plant Attributes Plant attributes are a specialized category of supplemental attributes for grouping individual diff --git a/src/PowerSystems.jl b/src/PowerSystems.jl index 2861d67dd2..df1d988230 100644 --- a/src/PowerSystems.jl +++ b/src/PowerSystems.jl @@ -311,6 +311,13 @@ export FixedForcedOutage export get_mean_time_to_recovery export get_outage_transition_probability export get_outage_schedule +export get_monitored_components +export set_monitored_components! +export clear_monitored_components! +export add_monitored_component! +export add_monitored_components! +export remove_monitored_component! +export remove_monitored_components! # Impedance Correction Data export ImpedanceCorrectionData diff --git a/src/base.jl b/src/base.jl index 2d8943091e..3ec7c538e3 100644 --- a/src/base.jl +++ b/src/base.jl @@ -2077,6 +2077,28 @@ function add_supplemental_attribute!( return IS.add_supplemental_attribute!(sys.data, component, attribute) end +function add_supplemental_attribute!( + sys::System, + component::Component, + outage::Outage, +) + if get_runchecks(sys) + for uuid in get_monitored_components(outage) + comp = IS.get_component(sys, uuid) # throws ArgumentError on miss + if !(comp isa Device) + throw( + ArgumentError( + "monitored_components on $(typeof(outage)) references UUID " * + "$(uuid), which resolves to $(typeof(comp)); only " * + "Device subtypes are allowed", + ), + ) + end + end + end + return IS.add_supplemental_attribute!(sys.data, component, outage) +end + """ Begin an update of supplemental attributes. Use this function when adding or removing many supplemental attributes in order to improve performance. @@ -2432,7 +2454,7 @@ function check_ac_transmission_rate_values(sys::System) end """ -Serialize a [System](@ref) instance. Returns a `Dict{String, Any}` +Serialize a [System](@ref) instance. Returns a `Dict{String, Any}` of the form `Dict("data_format_version" => "1.0", "field1" => serialize(sys.field1), ...)`, which can then be written to a JSON3 file. """ diff --git a/src/outages.jl b/src/outages.jl index ff2d4540a9..93bfd1310f 100644 --- a/src/outages.jl +++ b/src/outages.jl @@ -3,13 +3,27 @@ Supertype for outage contingencies representing planned or unplanned equipment o Concrete subtypes include [`GeometricDistributionForcedOutage`](@ref), [`PlannedOutage`](@ref), and [`FixedForcedOutage`](@ref). + +# Interface for custom subtypes + +Subtypes are expected to provide the following fields, or override the matching +accessors via multiple dispatch: + +- `monitored_components::Set{Base.UUID}` — UUIDs of devices whose + post-contingency state should be modeled. The default + [`get_monitored_components`](@ref) reads `value.monitored_components`; override + it if your subtype does not carry the field directly. +- `internal::InfrastructureSystemsInternal` — accessed via `get_internal`. + +The default [`supports_time_series`](@ref) returns `true`; override for custom +outage types that do not support time series. """ abstract type Outage <: Contingency end abstract type UnplannedOutage <: Outage end """ -All PowerSystems [Outage](@ref) types support time series. This can be overridden for custom +All PowerSystems [Outage](@ref) types support time series. This can be overridden for custom outage types that do not support time series. """ supports_time_series(::Outage) = true @@ -17,6 +31,84 @@ supports_time_series(::Outage) = true """Get `internal`.""" get_internal(x::Outage) = x.internal +# Public API for monitored_components accepts UUIDs or Devices interchangeably. +_as_uuid(uuid::Base.UUID) = uuid +_as_uuid(device::Device) = IS.get_uuid(device) + +""" +Get the set of [`Device`](@ref) UUIDs whose post-contingency state should be modeled +when this outage occurs. PowerSystems does not assign meaning to an empty set; +downstream consumers (e.g., PowerSimulations) decide whether empty means "monitor +nothing" or "monitor everything". +""" +get_monitored_components(value::Outage) = value.monitored_components + +""" +Replace the monitored-components set for an [`Outage`](@ref) with the contents +of `items`. Accepts any iterable whose elements are `Base.UUID` or +[`Device`](@ref) (e.g., a `Vector`, a generator, or the iterator returned by +[`get_components`](@ref)). Devices are converted to their UUIDs internally. +Pass an empty iterable (or call [`clear_monitored_components!`](@ref)) to +clear the set. +""" +function set_monitored_components!(value::Outage, items) + empty!(value.monitored_components) + for x in items + push!(value.monitored_components, _as_uuid(x)) + end + return value.monitored_components +end + +""" +Empty the monitored-components set of an [`Outage`](@ref). Returns the (now empty) +underlying set. +""" +function clear_monitored_components!(value::Outage) + empty!(value.monitored_components) + return value.monitored_components +end + +""" +Add a `Base.UUID` or [`Device`](@ref) to the monitored-components set of +an [`Outage`](@ref). Adding an existing UUID is a no-op. +""" +function add_monitored_component!(value::Outage, x::Union{Base.UUID, Device}) + push!(value.monitored_components, _as_uuid(x)) + return value.monitored_components +end + +""" +Add every element of `items` (each a `Base.UUID` or [`Device`](@ref)) to +the monitored-components set of an [`Outage`](@ref). Accepts any iterable, including +the iterator returned by [`get_components`](@ref). Adding an existing UUID is a no-op. +""" +function add_monitored_components!(value::Outage, items) + for x in items + add_monitored_component!(value, x) + end + return value.monitored_components +end + +""" +Remove a `Base.UUID` or [`Device`](@ref) from the monitored-components set +of an [`Outage`](@ref). No-op when the entry is not present. +""" +function remove_monitored_component!(value::Outage, x::Union{Base.UUID, Device}) + delete!(value.monitored_components, _as_uuid(x)) + return +end + +""" +Remove every element of `items` (each a `Base.UUID` or [`Device`](@ref)) from +the monitored-components set of an [`Outage`](@ref). Accepts any iterable. +""" +function remove_monitored_components!(value::Outage, items) + for x in items + remove_monitored_component!(value, x) + end + return +end + """ Attribute that contains information regarding forced outages where the transition probabilities are modeled with geometric distributions. The outage probabilities and recovery probabilities can be modeled as time @@ -25,32 +117,37 @@ series. # Arguments - `mean_time_to_recovery::Float64`: Time elapsed to recovery after a failure in Milliseconds. - `outage_transition_probability::Float64`: Characterizes the probability of failure (1 - p) in the geometric distribution. +- `monitored_components::Set{Base.UUID}`: UUIDs of devices whose post-contingency state should be modeled when this outage occurs. Empty by default; semantics of an empty set are decided by the downstream consumer. - `internal::InfrastructureSystemsInternal`: (**Do not modify.**) PowerSystems internal reference """ struct GeometricDistributionForcedOutage <: UnplannedOutage mean_time_to_recovery::Float64 outage_transition_probability::Float64 + monitored_components::Set{Base.UUID} internal::InfrastructureSystemsInternal end """ - GeometricDistributionForcedOutage(; mean_time_to_recovery, outage_transition_probability, internal) + GeometricDistributionForcedOutage(; mean_time_to_recovery, outage_transition_probability, monitored_components, internal) Construct a [`GeometricDistributionForcedOutage`](@ref). # Arguments - `mean_time_to_recovery::Float64`: (default: `0.0`) Time elapsed to recovery after a failure in Milliseconds. - `outage_transition_probability::Float64`: (default: `0.0`) Characterizes the probability of failure (1 - p) in the geometric distribution. +- `monitored_components`: (default: `Base.UUID[]`) Any iterable of `Base.UUID` or [`Device`](@ref). Devices are converted to their UUIDs internally; duplicates are collapsed. - `internal::InfrastructureSystemsInternal`: (default: `InfrastructureSystemsInternal()`) (**Do not modify.**) PowerSystems internal reference """ function GeometricDistributionForcedOutage(; mean_time_to_recovery = 0.0, outage_transition_probability = 0.0, + monitored_components = Base.UUID[], internal = InfrastructureSystemsInternal(), ) return GeometricDistributionForcedOutage( mean_time_to_recovery, outage_transition_probability, + Set{Base.UUID}(_as_uuid(x) for x in monitored_components), internal, ) end @@ -67,28 +164,33 @@ Attribute that contains information regarding planned outages. # Arguments - `outage_schedule::String`: String name of the time series used for the scheduled outages +- `monitored_components::Set{Base.UUID}`: UUIDs of devices whose post-contingency state should be modeled when this outage occurs. Empty by default; semantics of an empty set are decided by the downstream consumer. - `internal::InfrastructureSystemsInternal`: (**Do not modify.**) PowerSystems internal reference """ struct PlannedOutage <: Outage outage_schedule::String + monitored_components::Set{Base.UUID} internal::InfrastructureSystemsInternal end """ - PlannedOutage(; outage_schedule, internal) + PlannedOutage(; outage_schedule, monitored_components, internal) Construct a [`PlannedOutage`](@ref). # Arguments - `outage_schedule::String`: String name of the time series used for the scheduled outages +- `monitored_components`: (default: `Base.UUID[]`) Any iterable of `Base.UUID` or [`Device`](@ref). Devices are converted to their UUIDs internally; duplicates are collapsed. - `internal::InfrastructureSystemsInternal`: (default: `InfrastructureSystemsInternal()`) (**Do not modify.**) PowerSystems internal reference """ function PlannedOutage(; outage_schedule, + monitored_components = Base.UUID[], internal = InfrastructureSystemsInternal(), ) return PlannedOutage( outage_schedule, + Set{Base.UUID}(_as_uuid(x) for x in monitored_components), internal, ) end @@ -102,27 +204,35 @@ The time series data for fixed outages can be obtained from the simulation of a # Arguments - `outage_status::Float64`: The forced outage status in the model. 1 represents outaged and 0 represents available. +- `monitored_components::Set{Base.UUID}`: UUIDs of devices whose post-contingency state should be modeled when this outage occurs. Empty by default; semantics of an empty set are decided by the downstream consumer. - `internal::InfrastructureSystemsInternal`: (**Do not modify.**) PowerSystems internal reference """ struct FixedForcedOutage <: UnplannedOutage outage_status::Float64 + monitored_components::Set{Base.UUID} internal::InfrastructureSystemsInternal end """ - FixedForcedOutage(; outage_status, internal) + FixedForcedOutage(; outage_status, monitored_components, internal) Construct a [`FixedForcedOutage`](@ref). # Arguments - `outage_status::Float64`: The forced outage status in the model. 1 represents outaged and 0 represents available. +- `monitored_components`: (default: `Base.UUID[]`) Any iterable of `Base.UUID` or [`Device`](@ref). Devices are converted to their UUIDs internally; duplicates are collapsed. - `internal::InfrastructureSystemsInternal`: (default: `InfrastructureSystemsInternal()`) (**Do not modify.**) PowerSystems internal reference """ function FixedForcedOutage(; outage_status, + monitored_components = Base.UUID[], internal = InfrastructureSystemsInternal(), ) - return FixedForcedOutage(outage_status, internal) + return FixedForcedOutage( + outage_status, + Set{Base.UUID}(_as_uuid(x) for x in monitored_components), + internal, + ) end """Get [`FixedForcedOutage`](@ref) `outage_status`.""" diff --git a/test/test_outages.jl b/test/test_outages.jl index 2ae961f657..5f2ce66d48 100644 --- a/test/test_outages.jl +++ b/test/test_outages.jl @@ -150,6 +150,177 @@ end geo_attr2 in thermal_geo_attrs end +@testset "Test monitored_components on Outage subtypes" begin + sys = create_system_with_outages() + gens = collect(get_components(ThermalStandard, sys)) + gen1, gen2 = gens[1], gens[2] + uuid1 = IS.get_uuid(gen1) + uuid2 = IS.get_uuid(gen2) + + # Default is empty for all three concrete types + @test isempty( + get_monitored_components( + GeometricDistributionForcedOutage(; + mean_time_to_recovery = 1.0, outage_transition_probability = 0.5, + ), + ), + ) + @test isempty(get_monitored_components(PlannedOutage(; outage_schedule = "x"))) + @test isempty(get_monitored_components(FixedForcedOutage(; outage_status = 0.0))) + + # Construct with UUIDs + fo_uuid = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 1.0, + outage_transition_probability = 0.5, + monitored_components = [uuid1, uuid2], + ) + @test get_monitored_components(fo_uuid) == Set([uuid1, uuid2]) + + # Construct with Device references + fo_dev = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 1.0, + outage_transition_probability = 0.5, + monitored_components = [gen1, gen2], + ) + @test get_monitored_components(fo_dev) == Set([uuid1, uuid2]) + + # Construct with the FlattenIteratorWrapper returned by get_components + fo_iter = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 1.0, + outage_transition_probability = 0.5, + monitored_components = get_components(ThermalStandard, sys), + ) + @test get_monitored_components(fo_iter) == Set(IS.get_uuid.(gens)) + + # Construction silently dedups duplicate UUIDs + fo_dup = GeometricDistributionForcedOutage(; + mean_time_to_recovery = 1.0, + outage_transition_probability = 0.5, + monitored_components = [uuid1, uuid1, uuid2], + ) + @test get_monitored_components(fo_dup) == Set([uuid1, uuid2]) + + # Same for PlannedOutage and FixedForcedOutage + po = PlannedOutage(; outage_schedule = "1", monitored_components = [gen1]) + @test get_monitored_components(po) == Set([uuid1]) + ff = FixedForcedOutage(; outage_status = 1.0, monitored_components = [uuid2]) + @test get_monitored_components(ff) == Set([uuid2]) + + # set_monitored_components! accepts UUID and Device iterables + o = FixedForcedOutage(; outage_status = 0.0) + set_monitored_components!(o, [uuid1]) + @test get_monitored_components(o) == Set([uuid1]) + set_monitored_components!(o, [gen2]) + @test get_monitored_components(o) == Set([uuid2]) + set_monitored_components!(o, Base.UUID[]) + @test isempty(get_monitored_components(o)) + # set_ also accepts a FlattenIteratorWrapper from get_components + set_monitored_components!(o, get_components(ThermalStandard, sys)) + @test get_monitored_components(o) == Set(IS.get_uuid.(gens)) + set_monitored_components!(o, Base.UUID[]) + + # add_monitored_component! with single UUID or Device, including dedup + add_monitored_component!(o, uuid1) + add_monitored_component!(o, gen2) + @test get_monitored_components(o) == Set([uuid1, uuid2]) + add_monitored_component!(o, gen1) # duplicate, should no-op + @test get_monitored_components(o) == Set([uuid1, uuid2]) + @test length(get_monitored_components(o)) == 2 + + # add_monitored_components! with iterables: Vector, generator, FlattenIteratorWrapper + o2 = FixedForcedOutage(; outage_status = 0.0) + add_monitored_components!(o2, [uuid1, gen2]) # mixed UUID + Device + @test get_monitored_components(o2) == Set([uuid1, uuid2]) + add_monitored_components!(o2, (g for g in gens[1:2])) # generator, all already present + @test get_monitored_components(o2) == Set([uuid1, uuid2]) + o3 = FixedForcedOutage(; outage_status = 0.0) + add_monitored_components!(o3, get_components(ThermalStandard, sys)) + @test get_monitored_components(o3) == Set(IS.get_uuid.(gens)) + + # remove_monitored_component! with single UUID or Device + remove_monitored_component!(o, uuid1) + @test get_monitored_components(o) == Set([uuid2]) + remove_monitored_component!(o, gen2) + @test isempty(get_monitored_components(o)) + # Removing absent UUID is a no-op + remove_monitored_component!(o, uuid1) + @test isempty(get_monitored_components(o)) + + # remove_monitored_components! with an iterable + remove_monitored_components!(o3, get_components(ThermalStandard, sys)) + @test isempty(get_monitored_components(o3)) + + # Validation under runchecks=true: a bogus UUID at attach time raises + bogus_uuid = Base.UUID("00000000-0000-0000-0000-000000000000") + bad_outage = FixedForcedOutage(; + outage_status = 0.0, + monitored_components = [bogus_uuid], + ) + @test_throws ArgumentError add_supplemental_attribute!(sys, gen1, bad_outage) + + # Validation under runchecks=false: same attach succeeds silently + sys_nocheck = create_system_with_outages() + set_runchecks!(sys_nocheck, false) + gen_nc = first(get_components(ThermalStandard, sys_nocheck)) + bad_outage2 = FixedForcedOutage(; + outage_status = 0.0, + monitored_components = [bogus_uuid], + ) + add_supplemental_attribute!(sys_nocheck, gen_nc, bad_outage2) + @test bogus_uuid in get_monitored_components(bad_outage2) + + # A non-Device UUID is rejected under runchecks=true + sys2 = create_system_with_outages() + bus = first(get_components(ACBus, sys2)) + bus_uuid = IS.get_uuid(bus) + bad_kind = FixedForcedOutage(; + outage_status = 0.0, + monitored_components = [bus_uuid], + ) + gen_for_attach = first(get_components(ThermalStandard, sys2)) + @test_throws ArgumentError add_supplemental_attribute!(sys2, gen_for_attach, bad_kind) +end + +@testset "Test JSON round-trip of monitored_components" begin + sys = create_system_with_outages() + gens = collect(get_components(ThermalStandard, sys)) + # Tag each existing outage with a non-empty monitored_components list so the + # field has values to round-trip. + for outage in get_supplemental_attributes(Outage, sys) + set_monitored_components!(outage, gens) + end + + # Round-trip via to_json/from_json, preserving UUIDs. + test_dir = mktempdir() + path = joinpath(test_dir, "sys_with_monitored.json") + to_json(sys, path; force = true) + sys2 = System(path) + + # Every outage must come back with the same monitored UUIDs (set semantics — + # order is not preserved), and each UUID must still resolve to a Device in + # the new system. + expected_uuids = Set(IS.get_uuid.(gens)) + outages2 = collect(get_supplemental_attributes(Outage, sys2)) + @test length(outages2) == 4 + for outage in outages2 + uuids = get_monitored_components(outage) + @test uuids isa Set{Base.UUID} + @test uuids == expected_uuids + for uuid in uuids + comp = IS.get_component(sys2, uuid) + @test comp isa ThermalStandard + end + end + + # Default (empty) monitored_components also round-trips without error. + sys_empty = create_system_with_outages() + sys_empty2, ok = validate_serialization(sys_empty) + @test ok + for outage in get_supplemental_attributes(Outage, sys_empty2) + @test isempty(get_monitored_components(outage)) + end +end + @testset "Test remove_supplemental_attributes! by type" begin sys = create_system_with_outages() # Verify initial state