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/.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). diff --git a/docs/src/reference/formulation_library.md b/docs/src/reference/formulation_library.md index 094cab6c..c3243ed0 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 \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 +package. + ## [Service Formulations](@id service_formulations) | Formulation | Service type | Argument stage | Model stage | diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 84baf5f9..f3d9f83e 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -247,6 +247,9 @@ include("core/problem_template.jl") 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 include("common_models/add_expressions.jl") @@ -268,6 +271,9 @@ 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") +include("event_models/event_constraints.jl") + # Initial Conditions include("initial_conditions/add_initial_condition.jl") include("initial_conditions/device_initial_conditions.jl") @@ -405,6 +411,30 @@ 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! +export get_event_models +export supports_events +export AvailableStatusParameter +export ActivePowerOffsetParameter +export ReactivePowerOffsetParameter +export AvailableStatusChangeCountdownParameter +export ActivePowerOutageConstraint +export ReactivePowerOutageConstraint +export ActivePowerPumpOutageConstraint + # Initial Conditions Quantities export DevicePower export DeviceStatus diff --git a/src/core/constraints.jl b/src/core/constraints.jl index 14e6a203..9c2e1c30 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 \\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/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/core/problem_template.jl b/src/core/problem_template.jl index df9602bc..261eb72e 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,46 @@ 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 + +# `IOM._deepcopy_template` already shares the network model's PNM matrices by reference +# 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 +# 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_arguments.jl b/src/event_models/event_arguments.jl new file mode 100644 index 00000000..e9ec7ed6 --- /dev/null +++ b/src/event_models/event_arguments.jl @@ -0,0 +1,298 @@ +################################################################################# +# 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 + +################################################################################# +# 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/src/event_models/event_constraints.jl b/src/event_models/event_constraints.jl new file mode 100644 index 00000000..2270888b --- /dev/null +++ b/src/event_models/event_constraints.jl @@ -0,0 +1,572 @@ +################################################################################# +# 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 + +################################################################################# +# 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/src/event_models/event_model.jl b/src/event_models/event_model.jl new file mode 100644 index 00000000..60fe6b56 --- /dev/null +++ b/src/event_models/event_model.jl @@ -0,0 +1,153 @@ +""" + 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 + +""" +Return the time stamps at which `c` is triggered. +""" +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 +# 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) + +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 + +""" +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 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/src/operation/template_validation.jl b/src/operation/template_validation.jl index e5711943..10a8bec4 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,141 @@ 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) + 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 attributes + _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) + 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.", + ) + 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 + 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 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( + "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 new file mode 100644 index 00000000..62d7fbe4 --- /dev/null +++ b/test/test_events.jl @@ -0,0 +1,694 @@ +@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 + +@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) + @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 + +@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 + +@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 + + # 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 + 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) + + # 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 + 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 + +@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 + +@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") + 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). + 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 + 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 + +@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 + +@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 = OptimizationProblemOutputs(model) + # Event parameters are written to results (should_write_resulting_value = true) + @test "AvailableStatusParameter__ThermalStandard" in 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). + # 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") + 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) + 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) + @test all( + 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 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 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,