From b0de42946b02241c2c53e686e52b573db79a0278 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 10 Sep 2026 14:54:29 -0700 Subject: [PATCH 1/6] Clear FIXED and MULTI_STEP market bids in VirtualBidDispatch and SpreadBid PowerSystems #1784 collapses CurveStyles to VARIABLE / FIXED and adds CurveMultiStep (curve_multistep) on both market bid costs. The market formulations now read those two scalars and nothing else. VirtualBidDispatch - Every participant gets a per-period ActivePowerOut/InVariable that settles and carries the location writes; the shared JuMP object for VARIABLE and the envelope settlement for FIXED are gone. - FIXED participants add a per-period BlockBidCommitmentVariable for each direction they offer in, tied to the award by BlockBidQuantityConstraint (p = Q z) and priced on z from the curve's value. Time-series curves are supported; the envelope-span validation is removed since the envelope no longer prices anything. A FIXED curve with more than one segment fails the build with the device named. Nonzero VOM on a FIXED bid is still rejected. - MULTI_STEP participants of either style get BlockBidLinkConstraint rows p[t] = p[t+1] between consecutive periods whose curves are identical; a change of curve or an empty period ends the block. A time-invariant curve is one block over the window. - An OfferWindow per (participant, direction) is read once per construct stage and shared by the commitment variables, quantity rows, objective terms and link rows. SpreadBid - Rejects FIXED only (a spread bid is always divisible) and links MULTI_STEP blocks of identical spread curves with BlockBidLinkConstraint rows. Also: the _curve_style / _curve_multistep wrappers are replaced by PSY's accessors; docstrings for BlockBidCommitmentVariable, VirtualBidDispatch and SpreadBid describe the new behaviour. Tests: the block-bid testsets are rewritten on an extended three-period helper (static and time-series curves, single and multi-step, empty periods, partial blocks, multi-segment VARIABLE blocks, rejected multi-segment FIXED), plus a FIXED location-write test and a MULTI_STEP spread-bid test in the nodal suite. Full suite: 129,851 pass. Validation on ERCOT DAM 2026-06-01 (13,874 virtual participants, Gurobi, 1% gap): 328 detected multi-hour blocks all clear at one fraction, 86.1% of 8,049 Fixed bid-hours match ERCOT's award, settlement lambda MAE 2.58 $/MWh against the awarded-MW-weighted SPP. Co-development pins (temporary, revert once PowerSystems #1784 and its SiennaSchemas / PowerOpenAPIModels companions merge): PowerSystems and the PowerOpenAPIModels subpackages at rh/update_enums in Project.toml and test/Project.toml. --- Project.toml | 16 +- src/PowerOperationsModels.jl | 2 + src/common_models/market_bid_plumbing.jl | 3 - src/core/constraints.jl | 15 + src/core/formulations.jl | 17 +- src/core/variables.jl | 8 +- src/market_models/point_to_point_bid.jl | 54 +- src/market_models/virtual_participant.jl | 611 +++++++++++++---------- test/Project.toml | 16 +- test/test_market_model.jl | 569 +++++++++++++-------- test/test_nodal_distribution.jl | 116 +++++ 11 files changed, 917 insertions(+), 510 deletions(-) diff --git a/Project.toml b/Project.toml index 8b6b7177..4ef01a5e 100644 --- a/Project.toml +++ b/Project.toml @@ -41,7 +41,7 @@ InfrastructureOptimizationModels = {rev = "jd/market_models2", url = "https://gi # LOCAL PATH CO-DEV PIN (temporary): the variable -> variable_operation_cost cost-field # rename on PSY's generated cost types is uncommitted on disk, not on the psy6 branch yet. # Revert to the git rev pin once pushed/merged. -PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} +PowerSystems = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} # TEMPORARY: pinned to the arc-keyed-catalog branch (PNM #356 + #357) until they merge to # psy6. Revert to `rev = "psy6"` once they land. PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} @@ -50,13 +50,13 @@ PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/ # PSY's own sources). POM imports none of them: they sit in [deps] only because Pkg # rejects a [sources] entry for a package absent from [deps]. Mirrors PSY's own # Project.toml. Temporary until the OpenAPI packages are registered. -PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} -InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} -InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} -PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl"} -PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl"} -PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOpenAPIModels.jl"} -PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOperationsOpenAPIModels.jl"} +PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerCoreOpenAPIModels.jl"} +InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} +InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "InfrastructureCoreOpenAPIModels.jl"} +PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerDynamicsOpenAPIModels.jl"} +PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerInvestmentsOpenAPIModels.jl"} +PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerOperationsOpenAPIModels.jl"} [extensions] PowerFlowsExt = "PowerFlows" diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 0928ae7f..da8d4260 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -820,6 +820,8 @@ export RampConstraint export RampLimitConstraint export CopperPlateBalanceConstraint export SettlementBalanceConstraint +export BlockBidQuantityConstraint +export BlockBidLinkConstraint export ClearedPositionConstraint export ActiveRangeICConstraint export NodalBalanceActiveConstraint diff --git a/src/common_models/market_bid_plumbing.jl b/src/common_models/market_bid_plumbing.jl index a9a6aa84..ab72d1e5 100644 --- a/src/common_models/market_bid_plumbing.jl +++ b/src/common_models/market_bid_plumbing.jl @@ -172,9 +172,6 @@ _has_market_bid_cost(device::OFFER_CURVE_COMPONENTS) = _has_market_bid_cost(::MBC_TYPES) = true _has_market_bid_cost(::PSY.OperationalCost) = false -"Curve-clearing style (`PSY.CurveStyles`) for a `MarketBidCost`/`MarketBidTimeSeriesCost` bid." -_curve_style(cost::MBC_TYPES) = PSY.get_curve_style(cost) - _has_import_export_cost(::PSY.StaticInjection) = false _has_import_export_cost(device::PSY.Source) = _has_import_export_cost(IOM.get_operation_cost(device)) diff --git a/src/core/constraints.jl b/src/core/constraints.jl index 3be31b59..1efe4b4f 100644 --- a/src/core/constraints.jl +++ b/src/core/constraints.jl @@ -65,6 +65,21 @@ keeps its own parameters/slacks): the settlement row carries only bid variables. """ struct SettlementBalanceConstraint <: ConstraintType end +""" +Quantity row of a FIXED-`curve_style` market bid, one per (component, direction, period): +`p[c, t] - Q[c, t] z[c, t] == 0`, with `p` the award (`ActivePowerOutVariable` / +`ActivePowerInVariable`), `Q` the period's offer quantity and `z` its +[`BlockBidCommitmentVariable`](@ref). The bid clears its whole quantity or nothing. +""" +struct BlockBidQuantityConstraint <: ConstraintType end + +""" +Link row of a MULTI_STEP-`curve_multistep` market bid between two consecutive periods of +one block: `p[c, t] - p[c, t + 1] == 0`. A block is a run of consecutive periods whose +offer curves are identical; the rows are keyed by the first period of each pair, sparse. +""" +struct BlockBidLinkConstraint <: ConstraintType end + """ `ClearedPositionVariable[loc, t] == AggregateClearedInjection[loc, t]` — one row per settlement location per timestep, never per bus. diff --git a/src/core/formulations.jl b/src/core/formulations.jl index 3fdeafa2..c5dc158a 100644 --- a/src/core/formulations.jl +++ b/src/core/formulations.jl @@ -138,9 +138,11 @@ Formulation for `PSY.VirtualParticipant` market components. Adds `ActivePowerOut bounded by `max_supply`/`max_demand`, and adds both directly to the single system-wide `SettlementBalance` row (+out, -in) — never to a physical `ActivePowerBalance` row. `settlement_point`/`trading_hubs` are recorded on the component but unresolved here: with -one settlement row, location is moot. `PSY.CurveStyles` selects the bid's shape: CURVE gets -a fresh divisible variable per period, VARIABLE one shared divisible variable across the -horizon, and FIXED a shared binary [`BlockBidCommitmentVariable`](@ref). +one settlement row, location is moot. `PSY.CurveStyles` selects the bid's shape: VARIABLE +clears a divisible quantity per period on the PWL path, FIXED clears the period's whole +quantity or nothing through a per-period binary [`BlockBidCommitmentVariable`](@ref). +`PSY.CurveMultiStep.MULTI_STEP` links consecutive periods with identical offers into one +block ([`BlockBidLinkConstraint`](@ref)). Simultaneous nonzero `ActivePowerOutVariable` and `ActivePowerInVariable` on the same participant (self-crossing) is permitted, mirroring real DAM virtual-bidding rules: an @@ -167,9 +169,12 @@ equality with no nodal effect. struct AggregateBalance <: AbstractDeviceFormulation end """ -Formulation for `PSY.PointToPointBid`: one [`ClearedTransferVariable`](@ref) per bid, written -as −q into the `from` location's [`AggregateClearedInjection`](@ref) and +q into the `to` -location's. Excluded from `SettlementBalance`: its two settlement terms would cancel exactly. +Formulation for `PSY.PointToPointBid`: one [`ClearedTransferVariable`](@ref) per bid and +period, written as −q into the `from` location's [`AggregateClearedInjection`](@ref) and +q +into the `to` location's. Excluded from `SettlementBalance`: its two settlement terms would +cancel exactly. A MULTI_STEP spread bid clears one MW across every period of a block of +identical curves ([`BlockBidLinkConstraint`](@ref)); FIXED is rejected, a spread bid is +always divisible. """ struct SpreadBid <: AbstractDeviceFormulation end diff --git a/src/core/variables.jl b/src/core/variables.jl index e3282de9..345c878c 100644 --- a/src/core/variables.jl +++ b/src/core/variables.jl @@ -43,10 +43,10 @@ struct LiftVariable <: VariableType end """ Binary block-bid commitment variable (``z``) for a FIXED-`curve_style` market bid -(`PSY.CurveStyles.FIXED`): one JuMP variable per (component, direction), reused unscaled -at every period of the horizon in both the settlement row and the objective. The block -clears at its full MW envelope in every period when ``z = 1``, or not at all when -``z = 0`` — an all-or-nothing decision across the whole bid period. +(`PSY.CurveStyles.FIXED`): one per (component, direction, period). The award equals the +period's offer quantity when ``z = 1`` and zero when ``z = 0`` +([`BlockBidQuantityConstraint`](@ref)); ``z`` prices the block in the objective and never +enters the settlement row itself. """ struct BlockBidCommitmentVariable <: VariableType end diff --git a/src/market_models/point_to_point_bid.jl b/src/market_models/point_to_point_bid.jl index 602f7c12..c7635b57 100644 --- a/src/market_models/point_to_point_bid.jl +++ b/src/market_models/point_to_point_bid.jl @@ -24,8 +24,9 @@ IOM._vom_offer_direction(::Type{SpreadBid}) = IOM.IncrementalOffer() """ Reject a `spread_bid` that cannot mean what a spread bid means. -FIXED/VARIABLE block-bid clearing is not modelled: a spread bid's whole model is one -divisible quantity per period, so a block style would be priced here as if divisible. +A spread bid is always divisible, so `curve_style` FIXED is rejected: its all-or-nothing +clearing would be priced here as if divisible. `curve_multistep` is honoured +(`_add_spread_bid_link_rows!`). A willingness-to-pay on the `to`-minus-`from` spread lives on the incremental side (PSY documents `spread_bid` as "incremental side only", and see `_vom_offer_direction` for why @@ -36,12 +37,12 @@ skipped. Both mirror `VirtualBidDispatch`'s `_validate_block_bid_vom!` idiom. function _validate_spread_bid!(container::OptimizationContainer, bid::PSY.PointToPointBid) cost = IOM.get_operation_cost(bid) name = PSY.get_name(bid) - style = _curve_style(cost) - if style != PSY.CurveStyles.CURVE + style = PSY.get_curve_style(cost) + if style == PSY.CurveStyles.FIXED error( - "PointToPointBid $(name) has spread_bid curve_style $(style). Only CURVE is " * - "supported: a spread bid clears as one divisible quantity per period, so " * - "FIXED/VARIABLE block clearing has no meaning for it.", + "PointToPointBid $(name) has spread_bid curve_style FIXED. A spread bid clears " * + "as one divisible quantity per period, so all-or-nothing clearing has no " * + "meaning for it; use VARIABLE.", ) end if IOM.is_nontrivial_offer(container, bid, get_input_offer_curves(cost)) @@ -159,8 +160,10 @@ top breakpoint, which bounds it through the PWL delta constraint. Authoring them different values is legitimate (the curve is the offer), so they are not validated against each other as `VirtualBidDispatch`'s block bids are. -No other constraints: everything else about the instrument is its two signed position -writes from the argument stage. +A MULTI_STEP bid (`curve_multistep`) also gets [`BlockBidLinkConstraint`](@ref) rows tying +consecutive periods with identical spread curves to one cleared MW +(`_add_spread_bid_link_rows!`). Everything else about the instrument is its two signed +position writes from the argument stage. """ function construct_market_component!( container::OptimizationContainer, @@ -177,5 +180,38 @@ function construct_market_component!( isempty(bids) && return wrapped = IS.FlattenIteratorWrapper(PSY.PointToPointBid, [bids]) add_variable_cost!(container, ClearedTransferVariable, wrapped, SpreadBid) + _add_spread_bid_link_rows!(container, bids) + return +end + +_is_multistep(bid::PSY.PointToPointBid) = + PSY.get_curve_multistep(IOM.get_operation_cost(bid)) == PSY.CurveMultiStep.MULTI_STEP + +""" +Link rows for MULTI_STEP spread bids: `q[b, t] - q[b, t + 1] == 0` between consecutive +periods of each block of identical spread curves (`_block_runs` on the incremental side), +so a block clears the same MW in every period it covers or nothing. +""" +function _add_spread_bid_link_rows!(container::OptimizationContainer, bids) + linked = [b for b in bids if _is_multistep(b)] + isempty(linked) && return + time_steps = get_time_steps(container) + blocks = Tuple{String, Vector{UnitRange{Int}}}[] + for bid in linked + runs = _block_runs(IOM.IncrementalOffer(), container, bid, time_steps) + long = UnitRange{Int}[r for r in runs if length(r) > 1] + isempty(long) || push!(blocks, (PSY.get_name(bid), long)) + end + isempty(blocks) && return + names = String[name for (name, _) in blocks] + rows = add_constraints_container!( + container, BlockBidLinkConstraint, PSY.PointToPointBid, names, time_steps; + sparse = true, + ) + q = get_variable(container, ClearedTransferVariable, PSY.PointToPointBid) + jump_model = get_jump_model(container) + for (name, runs) in blocks, run in runs, t in first(run):(last(run) - 1) + rows[name, t] = JuMP.@constraint(jump_model, q[name, t] - q[name, t + 1] == 0) + end return end diff --git a/src/market_models/virtual_participant.jl b/src/market_models/virtual_participant.jl index a9a81293..4aeb51c3 100644 --- a/src/market_models/virtual_participant.jl +++ b/src/market_models/virtual_participant.jl @@ -40,15 +40,14 @@ IOM._vom_offer_direction(::Type{ActivePowerInVariable}, ::Type{VirtualBidDispatc IOM.DecrementalOffer() """ -FIXED/VARIABLE block bids are costed directly from PWL segments -(`_add_block_bid_objective_terms!`), which never reads VOM: only CURVE-style devices go -through the standard `add_variable_cost!` -> `_add_vom_cost_to_objective!` path that does. -A nonzero VOM component on a FIXED/VARIABLE device's offer curve would therefore silently -vanish from the objective, so it is rejected loudly here instead (mirrors the -`ImportExportCost` "VOM cost must be zero" idiom in `_validate_occ_subtype`). +A FIXED bid is priced from its curve's total value on the commitment binary +(`_add_block_bid_objective_terms!`), a path that never reads VOM; VARIABLE bids go through +`add_variable_cost!`, which does. A nonzero VOM on a FIXED bid would vanish from the +objective, so it is rejected loudly (mirrors the `ImportExportCost` "VOM cost must be zero" +idiom in `_validate_occ_subtype`). """ function _validate_block_bid_vom!(d::PSY.VirtualParticipant, style::PSY.CurveStyles) - style == PSY.CurveStyles.CURVE && return + style == PSY.CurveStyles.FIXED || return cost = PSY.get_operation_cost(d) for curve in (get_output_offer_curves(cost), get_input_offer_curves(cost)) vom = IS.get_proportional_term(IS.get_vom_cost(curve)) @@ -56,7 +55,7 @@ function _validate_block_bid_vom!(d::PSY.VirtualParticipant, style::PSY.CurveSty error( "VirtualParticipant $(PSY.get_name(d)) has curve_style $(style) with a " * "nonzero VOM cost ($vom) on an offer curve. VOM is not supported for " * - "FIXED/VARIABLE block bids (it would silently drop from the objective); " * + "FIXED block bids (it would silently drop from the objective); " * "set the offer curve's vom_cost to zero.", ) end @@ -64,58 +63,12 @@ function _validate_block_bid_vom!(d::PSY.VirtualParticipant, style::PSY.CurveSty return end -""" -Total MW span `[0, top_breakpoint]` of a static PWL offer curve, the quantity FIXED's `z` -and VARIABLE's shared variable both scale (see [`_validate_block_bid_span!`](@ref)). -""" -_block_bid_offer_span(curve::IS.CostCurve{IS.PiecewiseIncrementalCurve}) = - last(IS.get_x_coords(IS.get_function_data(IS.get_value_curve(curve)))) -_block_bid_offer_span(curve::IS.CostCurve{<:IS.TimeSeriesPiecewiseIncrementalCurve}) = - error( - "Time-series-backed offer curves are not yet supported for FIXED/VARIABLE block bids " * - "(their envelope-vs-curve span cannot be validated at build time).", - ) - -""" -FIXED/VARIABLE block bids settle at the envelope (`max_supply`/`max_demand`, -[`_bid_max_mw`](@ref)) but are PRICED over the offer curve's own span -([`_block_bid_curve_value`](@ref)), which is a completely separate field. Nothing else ties -the two together, so a curve authored with a different top breakpoint than the envelope -silently mis-prices the block (it clears the envelope's quantity at the curve's average -price over a different quantity). Rejected loudly here, mirroring -[`_validate_block_bid_vom!`](@ref)'s idiom -- CURVE devices are exempt since their own -per-period variable is bounded directly by the curve (no separate envelope to mismatch). -""" -function _validate_block_bid_span!(d::PSY.VirtualParticipant, style::PSY.CurveStyles) - style == PSY.CurveStyles.CURVE && return - cost = PSY.get_operation_cost(d) - for (meta, curve, envelope) in ( - ("incremental", get_output_offer_curves(cost), PSY.get_max_supply(d)), - ("decremental", get_input_offer_curves(cost), PSY.get_max_demand(d)), - ) - IOM.is_nontrivial_offer(curve) || continue - span = _block_bid_offer_span(curve) - if !isapprox(span, envelope) - error( - "VirtualParticipant $(PSY.get_name(d)) has curve_style $(style) with a " * - "$(meta) offer curve spanning [0, $span] MW, which does not match its " * - "$(meta) envelope of $envelope MW. FIXED/VARIABLE block bids settle the " * - "envelope but are priced over the curve; the two must be equal or the " * - "bid is silently mispriced. Fix the curve's top breakpoint or the " * - "envelope field.", - ) - end - end - return -end - """ Validate `VirtualParticipant` `MarketBidCost`s and add the incremental/decremental PWL parameters (slope/breakpoint, static or time-series-backed). Mirrors `process_import_export_parameters!` for `Source`; startup/shutdown/cost-at-min are not processed here since virtual bids carry no commitment. Runs for every device regardless -of `curve_style`: FIXED/VARIABLE block bids reuse the same PWL parameter machinery as -CURVE bids to evaluate their offer curve. +of `curve_style`: FIXED bids read their quantity and value off the same PWL parameters. """ function process_virtual_bid_parameters!( container::OptimizationContainer, @@ -125,9 +78,7 @@ function process_virtual_bid_parameters!( devices = [d for d in devices_in if _has_market_bid_cost(d)] for d in devices - style = _curve_style(PSY.get_operation_cost(d)) - _validate_block_bid_vom!(d, style) - _validate_block_bid_span!(d, style) + _validate_block_bid_vom!(d, PSY.get_curve_style(PSY.get_operation_cost(d))) end for param in ( @@ -142,32 +93,25 @@ function process_virtual_bid_parameters!( end ################################################################################# -# Curve-style variable creation +# Curve-style partition # -# CURVE: a fresh ActivePowerOutVariable/InVariable JuMP variable per period, divisible -# across [0, mw/base] independently at each t. -# VARIABLE: the SAME ActivePowerOutVariable/InVariable JuMP object assigned at every -# period. Bounds are unchanged ([0, mw/base]), so this variable is still divisible; the -# per-period PWL cost machinery links `p[t] = Σδ_k(t)` for every t against that one shared -# object, giving a single shared fraction of the block cleared identically at every period -# with no new cost code. -# FIXED: excluded from the ActivePowerOutVariable/InVariable containers entirely (its -# quantity is not "the variable's own value" but "mw times a binary flag"), and instead -# gets its own `BlockBidCommitmentVariable` (z), built with the same -# create-once/reuse-every-period pattern as VARIABLE. +# Every device gets a fresh ActivePowerOutVariable/InVariable per period; that award is +# what settles and what the location writes carry. VARIABLE devices price it through the +# per-period PWL path. FIXED devices add a per-period BlockBidCommitmentVariable (z) tied +# to the award by BlockBidQuantityConstraint (p = Q z) and priced on z. MULTI_STEP devices +# of either style link consecutive periods with identical offers (BlockBidLinkConstraint). ################################################################################# """ Split market components into the FIXED-style block bids (which get a -`BlockBidCommitmentVariable`) and the divisible CURVE/VARIABLE bids (which get -`ActivePowerOutVariable`/`ActivePowerInVariable`). Both construct stages need the same -split. +`BlockBidCommitmentVariable` and quantity rows) and the divisible VARIABLE bids (which get +the PWL cost path). Both construct stages need the same split. """ function _partition_by_curve_style(devices) fixed = eltype(devices)[] divisible = eltype(devices)[] for d in devices - style = _curve_style(PSY.get_operation_cost(d)) + style = PSY.get_curve_style(PSY.get_operation_cost(d)) if style == PSY.CurveStyles.FIXED push!(fixed, d) else @@ -177,13 +121,15 @@ function _partition_by_curve_style(devices) return fixed, divisible end -"Envelope MW a `VirtualParticipant`'s bid is capped at for the given offer direction." -_bid_max_mw(::IOM.IncrementalOffer, d::PSY.VirtualParticipant) = PSY.get_max_supply(d) -_bid_max_mw(::IOM.DecrementalOffer, d::PSY.VirtualParticipant) = PSY.get_max_demand(d) +_is_multistep(d::PSY.VirtualParticipant) = + PSY.get_curve_multistep(PSY.get_operation_cost(d)) == PSY.CurveMultiStep.MULTI_STEP + _bid_settlement_sign(::IOM.IncrementalOffer) = 1.0 _bid_settlement_sign(::IOM.DecrementalOffer) = -1.0 _bid_direction_meta(::IOM.IncrementalOffer) = "Out" _bid_direction_meta(::IOM.DecrementalOffer) = "In" +_bid_award_variable_type(::IOM.IncrementalOffer) = ActivePowerOutVariable +_bid_award_variable_type(::IOM.DecrementalOffer) = ActivePowerInVariable _get_block_bid_variable(container::OptimizationContainer, dir::IOM.OfferDirection) = get_variable( @@ -193,6 +139,125 @@ _get_block_bid_variable(container::OptimizationContainer, dir::IOM.OfferDirectio _bid_direction_meta(dir), ) +_get_award_variable(container::OptimizationContainer, dir::IOM.OfferDirection) = + get_variable(container, _bid_award_variable_type(dir), PSY.VirtualParticipant) + +################################################################################# +# Offer curves over the model window +# +# Two paths need an offer curve period by period. A FIXED bid takes its quantity and its +# value from the curve at every period: `p = Q z`, the `z` objective term, and `z` fixed to +# zero where nothing is offered. A MULTI_STEP bid of either style takes its block boundaries +# from it: a block ends where consecutive periods stop carrying the same curve. An +# `OfferWindow` is that per-period view, read once per (component, direction) and construct +# stage and shared through an `OfferWindowCache`. The VARIABLE single-step path does not use +# it; its PWL delta terms read the parameters themselves. +# +# The PWL parameter containers pad every curve to the widest segment count in the model, so +# a one-step curve comes back with zero-width segments appended. Real segments are the +# positive-width ones; the quantity is the top breakpoint either way. +################################################################################# + +""" +One offer side over the model window, in system per-unit: `(breakpoints, slopes)` per period +for a time-series curve, a single pair for a time-invariant one. Empty when the side carries +no curve. +""" +struct OfferWindow + periods::Vector{Tuple{Vector{Float64}, Vector{Float64}}} + time_variant::Bool +end +OfferWindow() = OfferWindow(Tuple{Vector{Float64}, Vector{Float64}}[], false) +Base.isempty(window::OfferWindow) = isempty(window.periods) + +"The curve at window position `i`; a time-invariant window answers from its one curve." +function _period(window::OfferWindow, i::Int) + window.time_variant && return window.periods[i] + return window.periods[1] +end + +"Windows read so far in one construct stage, keyed by (direction meta, component name)." +const OfferWindowCache = Dict{Tuple{String, String}, OfferWindow} + +""" + _offer_window(dir, container, d, time_steps) -> OfferWindow + +`d`'s `dir` curve over the window; empty when that side carries no curve. A time-invariant +curve is read once, since every period holds the same curve. +""" +function _offer_window( + dir::IOM.OfferDirection, + container::OptimizationContainer, + d::IS.InfrastructureSystemsComponent, + time_steps, +) + curve = get_offer_curves(dir, d) + IOM.is_nontrivial_offer(curve) || return OfferWindow() + time_variant = IOM.is_time_variant(curve) + read_periods = time_steps + time_variant || (read_periods = first(time_steps):first(time_steps)) + periods = Tuple{Vector{Float64}, Vector{Float64}}[] + for t in read_periods + breakpoints, slopes = IOM._get_pwl_data(dir, container, d, t) + push!(periods, (collect(Float64, breakpoints), collect(Float64, slopes))) + end + return OfferWindow(periods, time_variant) +end + +"The cached window of `d` in direction `dir`, read on first use." +function _window!( + cache::OfferWindowCache, + dir::IOM.OfferDirection, + container::OptimizationContainer, + d::IS.InfrastructureSystemsComponent, + time_steps, +) + return get!(cache, (_bid_direction_meta(dir), PSY.get_name(d))) do + _offer_window(dir, container, d, time_steps) + end +end + +"Offered quantity at window position `i`, in per-unit." +_window_quantity(window::OfferWindow, i::Int) = Float64(maximum(_period(window, i)[1])) + +"Whether the window offers a positive quantity at some period." +_offers_quantity(window::OfferWindow) = + any(i -> _window_quantity(window, i) > 0.0, eachindex(window.periods)) + +"Number of positive-width segments in a padded breakpoint vector." +_real_segments(breakpoints) = + count(i -> breakpoints[i + 1] > breakpoints[i] + 1e-9, 1:(length(breakpoints) - 1)) + +""" +Value of clearing a whole curve for one hour: `Σ slope_k × width_k` over its segments. For a +FIXED bid this is `price × quantity`, but the vectors come padded to the model's widest curve +(IOM repeats the last breakpoint, so the extra segments have zero width), and the sum stays +correct whatever the padding holds; a direct `slopes[1] × width_1` would rely on the real +segment being stored first. +""" +function _pwl_curve_total(breakpoints, slopes)::Float64 + total = 0.0 + for i in eachindex(slopes) + total += slopes[i] * (breakpoints[i + 1] - breakpoints[i]) + end + return total +end + +""" +The FIXED devices that offer in direction `dir`: the names carrying a +`BlockBidCommitmentVariable` on that side. The argument stage creates that container only +for the FIXED devices with a positive quantity in the direction, so the model stage reads +the set off it instead of deriving it again; empty when no FIXED device offers there. +""" +function _block_bid_names(container::OptimizationContainer, dir::IOM.OfferDirection) + key_present = IOM.has_container_key( + container, BlockBidCommitmentVariable, PSY.VirtualParticipant, + _bid_direction_meta(dir), + ) + key_present || return String[] + return collect(String, axes(_get_block_bid_variable(container, dir))[1]) +end + function _new_bid_jump_var!( container::OptimizationContainer, ::Type{T}, @@ -231,44 +296,33 @@ function _populate_per_period_bid_variable!( return end -function _populate_shared_bid_variable!( - container::OptimizationContainer, - variable, - ::Type{T}, - d::PSY.VirtualParticipant, +""" +A FIXED bid is one (quantity, price) point per period: every period that offers a +quantity must have exactly one positive-width segment. Static curves are checked by PSY at +construction; time-series curves only resolve here. +""" +function _validate_block_bid_segments!( + name::String, + dir::IOM.OfferDirection, + window::OfferWindow, time_steps, -) where {T <: VariableType} - name = PSY.get_name(d) - var = _new_bid_jump_var!(container, T, d, first(time_steps)) - for t in time_steps - variable[name, t] = var +) + for (i, t) in enumerate(time_steps) + _window_quantity(window, i) > 0.0 || continue + segments = _real_segments(_period(window, i)[1]) + segments == 1 || error( + "VirtualParticipant $(name) has curve_style FIXED but its " * + "$(_bid_direction_meta(dir)) offer curve at period $(t) has $(segments) " * + "segments; a FIXED bid is a single segment per period.", + ) end return end -_populate_bid_variable!( - container::OptimizationContainer, - variable, - ::Type{T}, - d::PSY.VirtualParticipant, - time_steps, - ::Val{PSY.CurveStyles.CURVE}, -) where {T <: VariableType} = - _populate_per_period_bid_variable!(container, variable, T, d, time_steps) - -_populate_bid_variable!( - container::OptimizationContainer, - variable, - ::Type{T}, - d::PSY.VirtualParticipant, - time_steps, - ::Val{PSY.CurveStyles.VARIABLE}, -) where {T <: VariableType} = - _populate_shared_bid_variable!(container, variable, T, d, time_steps) - """ -Creates the `BlockBidCommitmentVariable` (z) for every FIXED-style device, one per -(device, direction), reused at every period — see the curve-style section header. +Creates the `BlockBidCommitmentVariable` (z) for every FIXED-style device and direction it +offers in, one per period, fixed to zero at periods with no quantity. Needs the PWL +parameters processed first. """ function _add_block_bid_commitment_variables!( container::OptimizationContainer, @@ -276,74 +330,67 @@ function _add_block_bid_commitment_variables!( ) isempty(devices) && return time_steps = get_time_steps(container) - names = PSY.get_name.(devices) + cache = OfferWindowCache() for dir in (IOM.IncrementalOffer(), IOM.DecrementalOffer()) - meta = _bid_direction_meta(dir) + offering = [ + d for d in devices if + _offers_quantity(_window!(cache, dir, container, d, time_steps)) + ] + isempty(offering) && continue variable = add_variable_container!( - container, BlockBidCommitmentVariable, PSY.VirtualParticipant, meta, names, - time_steps, + container, BlockBidCommitmentVariable, PSY.VirtualParticipant, + _bid_direction_meta(dir), PSY.get_name.(offering), time_steps, ) - for d in devices - _populate_shared_bid_variable!( - container, variable, BlockBidCommitmentVariable, d, time_steps, - ) + for d in offering + name = PSY.get_name(d) + window = _window!(cache, dir, container, d, time_steps) + _validate_block_bid_segments!(name, dir, window, time_steps) + for (i, t) in enumerate(time_steps) + z = _new_bid_jump_var!(container, BlockBidCommitmentVariable, d, t) + variable[name, t] = z + _window_quantity(window, i) > 0.0 || JuMP.fix(z, 0.0; force = true) + end end end return end """ -Adds FIXED-style block-bid settlement terms: `z` scaled by `mw/base` (natural-units -`max_supply`/`max_demand` converted at the formulation surface, per the units rule) at -every period, `+z` on the supply (out) side and `-z` on the demand (in) side. +`p[d, t] - Q[d, t] z[d, t] == 0` for every FIXED device, direction with an offer, and +period. Where nothing is offered `Q = 0` and `z` is fixed, so the row pins the award to 0. """ -function _add_block_bid_settlement_terms!( +function _add_block_bid_quantity_rows!( container::OptimizationContainer, devices, - settlement_expr, - time_steps, + cache::OfferWindowCache, ) isempty(devices) && return + time_steps = get_time_steps(container) + jump_model = get_jump_model(container) + by_name = Dict(PSY.get_name(d) => d for d in devices) for dir in (IOM.IncrementalOffer(), IOM.DecrementalOffer()) + names = _block_bid_names(container, dir) + isempty(names) && continue + rows = add_constraints_container!( + container, BlockBidQuantityConstraint, PSY.VirtualParticipant, names, + time_steps; + meta = _bid_direction_meta(dir), + ) + p = _get_award_variable(container, dir) z = _get_block_bid_variable(container, dir) - sign = _bid_settlement_sign(dir) - for d in devices - name = PSY.get_name(d) - mw = _bid_max_mw(dir, d) / PSY.get_base_power(d, PSY.NU) - iszero(mw) && continue - _add_settlement_terms!(settlement_expr, z, name, sign * mw, time_steps) + for name in names + window = _window!(cache, dir, container, by_name[name], time_steps) + for (i, t) in enumerate(time_steps) + quantity = _window_quantity(window, i) + rows[name, t] = JuMP.@constraint( + jump_model, p[name, t] - quantity * z[name, t] == 0, + ) + end end end return end -""" -Total \$ value of clearing a block-bid device's full offer curve for one hour: the sum of -`slope_k * segment_width_k` over the whole curve. A block bid's curve is assumed to span -exactly `[0, mw]` (the same assumption CURVE-style devices make about their top -breakpoint), so this is the \$/h cost (or, for a decremental curve, value) of clearing the -entire block — the quantity FIXED's `z` and VARIABLE's shared variable both scale. -""" -function _block_bid_curve_value( - dir::IOM.OfferDirection, - container::OptimizationContainer, - d::PSY.VirtualParticipant, - t::Int, -)::Float64 - breakpoints, slopes = IOM._get_pwl_data(dir, container, d, t) - return _pwl_curve_total(breakpoints, slopes) -end - -# Function barrier: `_get_pwl_data`'s breakpoint/slope vectors are not inferrable at its -# call site, so the accumulation is compiled here against their concrete types. -function _pwl_curve_total(breakpoints, slopes)::Float64 - total = 0.0 - for i in eachindex(slopes) - total += slopes[i] * (breakpoints[i + 1] - breakpoints[i]) - end - return total -end - # Function barrier: `z` is read from an abstractly-typed variable container, so the term is # built and routed here against the concrete `JuMP.VariableRef`. function _add_block_bid_cost_term!( @@ -372,40 +419,30 @@ function _add_block_bid_cost_term!( end """ -FIXED-style objective terms: the block's per-period \$ value (`_block_bid_curve_value`) is -multiplied by the shared commitment variable `z` and `dt`, never routed through -per-period PWL delta variables — `z` is the only decision, so IOM's block-offer PWL -primitives (`offer_curve_types.jl` `_block_offer_var`/`_block_offer_constraint`, meant for -per-segment divisible dispatch) don't fit this single-shared-variable shape; the terms are -built directly with `add_to_objective_*`/`add_cost_to_expression!` instead. VOM cost is -not modeled for FIXED bids (there is no per-period dispatch variable to carry it — only -VARIABLE/CURVE devices, which keep the standard `add_variable_cost!` path, get VOM), and -[`_validate_block_bid_vom!`](@ref) rejects a nonzero one rather than dropping it silently. +FIXED-style objective terms: the block's per-period value (`_pwl_curve_total` of that +period's curve) multiplied by the period's commitment variable `z` and `dt`. `z` is the only +priced decision, so no PWL delta variables are built for these devices; VOM is rejected by +[`_validate_block_bid_vom!`](@ref) rather than dropped. """ function _add_block_bid_objective_terms!( container::OptimizationContainer, devices, + cache::OfferWindowCache, ) isempty(devices) && return time_steps = get_time_steps(container) dt = Dates.value(get_resolution(container)) / MILLISECONDS_IN_HOUR + by_name = Dict(PSY.get_name(d) => d for d in devices) for dir in (IOM.IncrementalOffer(), IOM.DecrementalOffer()) + names = _block_bid_names(container, dir) + isempty(names) && continue z = _get_block_bid_variable(container, dir) sign = IOM._objective_sign(dir) - for d in devices - cost_curve = get_offer_curves(dir, d) - IOM.is_nontrivial_offer(cost_curve) || continue - name = PSY.get_name(d) - is_variant = IOM.is_time_variant(cost_curve) - static_value = 0.0 - if !is_variant - static_value = _block_bid_curve_value(dir, container, d, first(time_steps)) - end - for t in time_steps - block_value = static_value - if is_variant - block_value = _block_bid_curve_value(dir, container, d, t) - end + for name in names + window = _window!(cache, dir, container, by_name[name], time_steps) + is_variant = window.time_variant + for (i, t) in enumerate(time_steps) + block_value = _pwl_curve_total(_period(window, i)...) iszero(block_value) && continue _add_block_bid_cost_term!( container, @@ -421,18 +458,108 @@ function _add_block_bid_objective_terms!( return end +################################################################################# +# Multi-step blocks +################################################################################# + +"Whether two padded PWL curves are the same offer (same breakpoints and slopes)." +_same_offer(breakpoints_a, slopes_a, breakpoints_b, slopes_b) = + length(breakpoints_a) == length(breakpoints_b) && + isapprox(breakpoints_a, breakpoints_b; rtol = 1e-9, atol = 1e-12) && + isapprox(slopes_a, slopes_b; rtol = 1e-9, atol = 1e-9) + +""" + _block_runs(window, time_steps) -> Vector{UnitRange{Int}} + +The blocks of a MULTI_STEP offer side, as ranges of periods: a period with an offered +quantity belongs to a block, and consecutive such periods stay in one block while their +curves are identical. A change of curve or a period with no quantity ends the block. +Adjacent blocks with the same curve cannot be told apart and count as one. A time-invariant +curve is one block over the whole window (or none), with no per-period comparison. +""" +function _block_runs(window::OfferWindow, time_steps) + runs = UnitRange{Int}[] + isempty(window) && return runs + if !window.time_variant + _offers_quantity(window) && push!(runs, first(time_steps):last(time_steps)) + return runs + end + start = 0 + for (i, t) in enumerate(time_steps) + if _window_quantity(window, i) <= 0.0 + start == 0 || push!(runs, start:(t - 1)) + start = 0 + continue + end + continues = + start != 0 && _same_offer(_period(window, i)..., _period(window, i - 1)...) + if !continues + start == 0 || push!(runs, start:(t - 1)) + start = t + end + end + start == 0 || push!(runs, start:last(time_steps)) + return runs +end + +"The blocks of `d` in direction `dir`, reading its window once (see the window method)." +function _block_runs( + dir::IOM.OfferDirection, + container::OptimizationContainer, + d::IS.InfrastructureSystemsComponent, + time_steps, +) + return _block_runs(_offer_window(dir, container, d, time_steps), time_steps) +end + +""" +Link rows for every MULTI_STEP device: `p[d, t] - p[d, t + 1] == 0` between consecutive +periods of each block longer than one period, per direction with an offer. The same row +serves FIXED and VARIABLE devices; for a FIXED block it is `z[t] = z[t + 1]` through the +quantity row, since a block has one quantity. +""" +function _add_block_bid_link_rows!( + container::OptimizationContainer, + devices, + cache::OfferWindowCache, +) + linked = [d for d in devices if _is_multistep(d)] + isempty(linked) && return + time_steps = get_time_steps(container) + jump_model = get_jump_model(container) + for dir in (IOM.IncrementalOffer(), IOM.DecrementalOffer()) + blocks = Tuple{String, Vector{UnitRange{Int}}}[] + for d in linked + runs = _block_runs(_window!(cache, dir, container, d, time_steps), time_steps) + long = UnitRange{Int}[r for r in runs if length(r) > 1] + isempty(long) || push!(blocks, (PSY.get_name(d), long)) + end + isempty(blocks) && continue + names = String[name for (name, _) in blocks] + rows = add_constraints_container!( + container, BlockBidLinkConstraint, PSY.VirtualParticipant, names, + time_steps; + sparse = true, meta = _bid_direction_meta(dir), + ) + p = _get_award_variable(container, dir) + for (name, runs) in blocks, run in runs, t in first(run):(last(run) - 1) + rows[name, t] = JuMP.@constraint(jump_model, p[name, t] - p[name, t + 1] == 0) + end + end + return +end + """ -A divisible virtual settles at a point OR at trading hubs (PSY enforces mutual exclusion); -a virtual with neither has no nodal footprint and writes nowhere. Under a nodal network -model the location must carry a `NodalRedistribution` component model, or the write fails -loudly on the missing `AggregateClearedInjection` container: the template asked to clear -a located virtual without modeling its location. Under `CopperPlateNetworkModel` location -is moot and the write is a declared no-op (see the method below). Location writes are -additive to the settlement-row writes: `AggregateClearedInjection` never feeds -`SettlementBalance`, so nothing is counted twice. Each hub receives the participant's full -award: the current bid plumbing carries one award per participant, not one per hub, so a -participant settling at several hubs is rejected until a per-hub split exists rather than -silently over-injecting. +A virtual settles at a point OR at trading hubs (PSY enforces mutual exclusion); a virtual +with neither has no nodal footprint and writes nowhere. Under a nodal network model the +location must carry a `NodalRedistribution` component model, or the write fails loudly on +the missing `AggregateClearedInjection` container: the template asked to clear a located +virtual without modeling its location. Under `CopperPlateNetworkModel` location is moot and +the write is a declared no-op (see the method below). Location writes are additive to the +settlement-row writes: `AggregateClearedInjection` never feeds `SettlementBalance`, so +nothing is counted twice. Each hub receives the participant's full award: the current bid +plumbing carries one award per participant, not one per hub, so a participant settling at +several hubs is rejected until a per-hub split exists rather than silently over-injecting. """ function _add_virtual_location_writes!( container::OptimizationContainer, @@ -475,13 +602,12 @@ function _add_virtual_location_writes!( end """ -Argument stage for `VirtualBidDispatch`: creates `ActivePowerOutVariable`/ -`ActivePowerInVariable` for CURVE/VARIABLE devices and `BlockBidCommitmentVariable` for -FIXED devices (dispatched on `PSY.get_curve_style`), populates MBC PWL parameters, and -adds every device's bid to the single system-wide `SettlementBalance` row (+out, -in) and, -for divisible devices, to their settlement location's `AggregateClearedInjection` -(`_add_virtual_location_writes!`). Never touches a physical `ActivePowerBalance` row -directly: the location model distributes the position. +Argument stage for `VirtualBidDispatch`: populates the MBC PWL parameters, creates a +per-period `ActivePowerOutVariable`/`ActivePowerInVariable` for every device and adds them +to the single system-wide `SettlementBalance` row (+out, -in) and to the settlement +location's `AggregateClearedInjection` (`_add_virtual_location_writes!`), then the +per-period `BlockBidCommitmentVariable` of the FIXED devices. Never touches a physical +`ActivePowerBalance` row directly: the location model distributes the position. """ function construct_market_component!( container::OptimizationContainer, @@ -493,73 +619,53 @@ function construct_market_component!( ) devices = get_available_components(model, sys) add_cost_expressions!(container, devices, model) - fixed_devices, divisible_devices = _partition_by_curve_style(devices) + process_virtual_bid_parameters!(container, devices, model) + fixed_devices, _ = _partition_by_curve_style(devices) time_steps = get_time_steps(container) settlement_expr = get_expression(container, IOM.SettlementBalance, PSY.System) - if !isempty(divisible_devices) - names = PSY.get_name.(divisible_devices) - p_out = add_variable_container!( - container, ActivePowerOutVariable, PSY.VirtualParticipant, names, time_steps, - ) - p_in = add_variable_container!( - container, ActivePowerInVariable, PSY.VirtualParticipant, names, time_steps, + names = PSY.get_name.(devices) + p_out = add_variable_container!( + container, ActivePowerOutVariable, PSY.VirtualParticipant, names, time_steps, + ) + p_in = add_variable_container!( + container, ActivePowerInVariable, PSY.VirtualParticipant, names, time_steps, + ) + for d in devices + name = PSY.get_name(d) + _populate_per_period_bid_variable!( + container, + p_out, + ActivePowerOutVariable, + d, + time_steps, ) - for d in divisible_devices - name = PSY.get_name(d) - style = _curve_style(PSY.get_operation_cost(d)) - _populate_bid_variable!( - container, - p_out, - ActivePowerOutVariable, - d, - time_steps, - Val(style), - ) - _populate_bid_variable!( - container, - p_in, - ActivePowerInVariable, - d, - time_steps, - Val(style), - ) - _add_settlement_terms!(settlement_expr, p_out, name, 1.0, time_steps) - _add_settlement_terms!(settlement_expr, p_in, name, -1.0, time_steps) - for t in time_steps - _add_virtual_location_writes!( - container, - d, - p_out[name, t], - p_in[name, t], - t, - network_model, - ) - end - end - end - - if !isempty(fixed_devices) - _add_block_bid_commitment_variables!(container, fixed_devices) - _add_block_bid_settlement_terms!( + _populate_per_period_bid_variable!( container, - fixed_devices, - settlement_expr, + p_in, + ActivePowerInVariable, + d, time_steps, ) + _add_settlement_terms!(settlement_expr, p_out, name, 1.0, time_steps) + _add_settlement_terms!(settlement_expr, p_in, name, -1.0, time_steps) + for t in time_steps + _add_virtual_location_writes!( + container, d, p_out[name, t], p_in[name, t], t, network_model, + ) + end end - process_virtual_bid_parameters!(container, devices, model) + _add_block_bid_commitment_variables!(container, fixed_devices) return end """ -Model stage for `VirtualBidDispatch`: adds the incremental (out) / decremental (in) -objective cost terms. CURVE/VARIABLE devices keep the standard PWL delta machinery -(`add_variable_cost!`); FIXED devices get bespoke single-variable terms -(`_add_block_bid_objective_terms!`). No range or budget constraints — bounds are set -directly on the variables at creation. +Model stage for `VirtualBidDispatch`: the PWL delta objective for VARIABLE devices +(`add_variable_cost!`), the quantity rows and `z`-priced objective for FIXED devices, and +the link rows of MULTI_STEP devices, all reading each offer curve once through one window +cache. No range or budget constraints — bounds are set directly on the variables at creation. """ function construct_market_component!( container::OptimizationContainer, @@ -577,6 +683,9 @@ function construct_market_component!( add_variable_cost!(container, ActivePowerOutVariable, wrapped, VirtualBidDispatch) add_variable_cost!(container, ActivePowerInVariable, wrapped, VirtualBidDispatch) end - _add_block_bid_objective_terms!(container, fixed_devices) + cache = OfferWindowCache() + _add_block_bid_quantity_rows!(container, fixed_devices, cache) + _add_block_bid_objective_terms!(container, fixed_devices, cache) + _add_block_bid_link_rows!(container, devices, cache) return end diff --git a/test/Project.toml b/test/Project.toml index 768f42d4..26171203 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -50,21 +50,21 @@ UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" PowerOperationsModels = {path = ".."} InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} InfrastructureOptimizationModels = {rev = "jd/market_models2", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} -PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} +PowerSystems = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} PowerSystemCaseBuilder = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl"} # PSY psy6 depends on these unregistered packages; [sources] of non-root projects are # ignored by Pkg, so every environment resolving PSY must pin them itself (same rev as # PSY's own sources). Mirrors the root Project.toml. -PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerCoreOpenAPIModels.jl"} -InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} -InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "InfrastructureCoreOpenAPIModels.jl"} -PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerDynamicsOpenAPIModels.jl"} -PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerInvestmentsOpenAPIModels.jl"} +PowerCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerCoreOpenAPIModels.jl"} +InfrastructureTimeSeriesOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} +InfrastructureCoreOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "InfrastructureCoreOpenAPIModels.jl"} +PowerDynamicsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerDynamicsOpenAPIModels.jl"} +PowerInvestmentsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerInvestmentsOpenAPIModels.jl"} # TEMPORARY: pinned to the arc-keyed-catalog branch (PNM #356 + #357) until they merge to # psy6. Revert to `rev = "psy6"` once they land. PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} -PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOpenAPIModels.jl"} -PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "main", subdir = "PowerOperationsOpenAPIModels.jl"} +PowerOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", rev = "rh/update_enums", subdir = "PowerOperationsOpenAPIModels.jl"} PowerFlowFileParser = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlowFileParser.jl"} PowerFlows = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerFlows.jl"} PowerTableDataParser = {rev = "psy6", url = "https://github.com/NLR-Sienna/PowerTableDataParser.jl"} diff --git a/test/test_market_model.jl b/test/test_market_model.jl index 872d689e..c87d4628 100644 --- a/test/test_market_model.jl +++ b/test/test_market_model.jl @@ -503,13 +503,68 @@ end @test lambda_with != lambda_without end -# Three-period extension of `_vp_test_system` covering block bids (`curve_style`). Same -# thermal ($20/$50/$80 segments, cap 100) and vp_demand (70 MW, $200/MWh) as -# `_vp_test_system`; every period is identical (no forecast on vp_demand's own MW), so `vp_supply`'s -# in-merit/out-of-merit decision is driven purely by its own price/curve vs. thermal's -# $80/MWh top segment (thermal always serves at least 60 of the 70 MW demand, landing in -# that segment) -- not by any period-varying scarcity. -function _block_bid_test_system(; vp_supply_curve, vp_supply_style) +# Three-period system for block-bid tests: thermal ($20/$50/$80 segments, cap 100 MW), a +# demand bid `vp_demand` (70 MW at $200/MWh every period unless `demand_mw` gives a +# per-period MW through a time-series cost) and a supply bid `vp_supply` (10 MW envelope) +# whose curve, `curve_style` and `curve_multistep` are the knobs. `vp_supply_ts` gives the +# supply a per-period `(mw, price)` one-step curve instead of the static `vp_supply_curve`. +# With the default demand every period is identical, so vp_supply's decision is driven by +# its own price against thermal's $80/MWh top segment; `demand_mw = [70, 70, 20]` makes the +# marginal thermal segment $20/MWh in the third period. +const _BB_T0 = Dates.DateTime("2020-01-01T00:00:00") +_bb_step(mw, price) = IS.PiecewiseStepData([0.0, Float64(mw)], [Float64(price)]) +_bb_inert() = IS.PiecewiseStepData([0.0, 0.0], [0.0]) + +function _bb_sts!(sys, comp, name, values) + stamps = collect(range(_BB_T0; step = Dates.Hour(1), length = length(values))) + return PSY.add_time_series!( + sys, comp, PSY.SingleTimeSeries(name, TimeSeries.TimeArray(stamps, values)), + ) +end + +function _bb_ts_side!(sys, comp, prefix, steps) + key = _bb_sts!(sys, comp, "$(prefix)_offer", steps) + initial_key = _bb_sts!(sys, comp, "$(prefix)_init_input", zeros(length(steps))) + return PSY.make_market_bid_ts_curve(key, initial_key) +end + +# A MarketBidTimeSeriesCost with per-period one-step curves (`nothing` side = inert). +function _bb_ts_cost!( + sys, comp; + incremental = nothing, decremental = nothing, + style = PSY.CurveStyles.VARIABLE, multistep = PSY.CurveMultiStep.SINGLE_STEP, +) + n = length(something(incremental, decremental)) + inc = _bb_ts_side!(sys, comp, "inc", something(incremental, fill(_bb_inert(), n))) + dec = _bb_ts_side!(sys, comp, "dec", something(decremental, fill(_bb_inert(), n))) + zeros_linear = fill(IS.LinearFunctionData(0.0, 0.0), n) + shut_down = _bb_sts!(sys, comp, "shut_down", zeros_linear) + no_load = _bb_sts!(sys, comp, "no_load", zeros_linear) + start_up = _bb_sts!(sys, comp, "start_up", fill((0.0, 0.0, 0.0), n)) + cost = PSY.MarketBidTimeSeriesCost(; + minimum_energy_offer = IS.TimeSeriesLinearCurve(no_load), + start_up = start_up, + shut_down = IS.TimeSeriesLinearCurve(shut_down), + incremental_offer_curves = inc, + decremental_offer_curves = dec, + curve_style = style, + curve_multistep = multistep, + ) + PSY.set_operation_cost!(comp, cost) + return cost +end + +# Four hourly values so every series matches the thermal's `max_active_power` length; the +# model horizon is three periods. +_bb_pad4(v) = vcat(v, v[end]) + +function _block_bid_test_system(; + vp_supply_curve = nothing, + vp_supply_style = PSY.CurveStyles.VARIABLE, + vp_supply_multistep = PSY.CurveMultiStep.SINGLE_STEP, + vp_supply_ts = nothing, + demand_mw = nothing, +) sys = PSY.System(100.0) bus = _add_simple_bus!(sys) @@ -531,23 +586,7 @@ function _block_bid_test_system(; vp_supply_curve, vp_supply_style) name = "thermal1", active_power_limits = (min = 0.0, max = 100.0), ) - PSY.add_time_series!( - sys, - thermal, - PSY.SingleTimeSeries( - "max_active_power", - TimeSeries.TimeArray( - [ - Dates.DateTime("2020-01-01T00:00:00"), - Dates.DateTime("2020-01-01T01:00:00"), - Dates.DateTime("2020-01-01T02:00:00"), - Dates.DateTime("2020-01-01T03:00:00"), - ], - [1.0, 1.0, 1.0, 1.0], - ), - ), - ) - PSY.transform_single_time_series!(sys, Dates.Hour(3), Dates.Hour(3)) + _bb_sts!(sys, thermal, "max_active_power", [1.0, 1.0, 1.0, 1.0]) vp_demand = PSY.VirtualParticipant(; name = "vp_demand", @@ -563,6 +602,12 @@ function _block_bid_test_system(; vp_supply_curve, vp_supply_style) ), ) PSY.add_component!(sys, vp_demand) + if demand_mw !== nothing + _bb_ts_cost!( + sys, vp_demand; + decremental = _bb_pad4([_bb_step(mw, 200.0) for mw in demand_mw]), + ) + end vp_supply = PSY.VirtualParticipant(; name = "vp_supply", @@ -572,10 +617,22 @@ function _block_bid_test_system(; vp_supply_curve, vp_supply_style) max_demand = 0.0, operation_cost = PSY.MarketBidCost(; curve_style = vp_supply_style, - incremental_offer_curves = vp_supply_curve, + curve_multistep = vp_supply_multistep, + incremental_offer_curves = something( + vp_supply_curve, + _single_segment_curve(50.0), + ), ), ) PSY.add_component!(sys, vp_supply) + if vp_supply_ts !== nothing + _bb_ts_cost!( + sys, vp_supply; + incremental = _bb_pad4([_bb_step(mw, price) for (mw, price) in vp_supply_ts]), + style = vp_supply_style, multistep = vp_supply_multistep, + ) + end + PSY.transform_single_time_series!(sys, Dates.Hour(3), Dates.Hour(3)) return sys end @@ -591,111 +648,267 @@ _kinked_curve() = PSY.NU, ) -@testset "Block bids: FIXED clears identically in every period or not at all (price straddle)" begin - for (price, expect_commit) in ((50.0, true), (95.0, false)) +# Two segments both in merit against $80 but only the first against $20. +_two_step_curve() = + PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 5.0, 10.0], [10.0, 40.0]), + PSY.NU, + ) + +_bb_model(sys) = DecisionModel( + _vp_test_template(), sys; optimizer = HiGHS_optimizer, store_variable_names = true, +) + +function _bb_solved(sys) + model = _bb_model(sys) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + return model, OptimizationProblemOutputs(model) +end + +_bb_out(res) = read_variable( + res, "ActivePowerOutVariable__VirtualParticipant"; table_format = TableFormat.WIDE, +)[ + !, + "vp_supply", +] +_bb_z(res) = read_variable( + res, "BlockBidCommitmentVariable__VirtualParticipant__Out"; + table_format = TableFormat.WIDE, +)[ + !, + "vp_supply", +] +_bb_link_keys(container) = Set( + keys( + IOM.get_constraint( + container, BlockBidLinkConstraint, PSY.VirtualParticipant, "Out").data, + ), +) + +@testset "Block bids: FIXED clears each period on its own, the whole quantity or nothing (p = Q z)" begin + for (price, expect) in ((50.0, 1.0), (95.0, 0.0)) sys = _block_bid_test_system(; vp_supply_curve = _single_segment_curve(price), vp_supply_style = PSY.CurveStyles.FIXED, ) - model = DecisionModel( - _vp_test_template(), sys; - optimizer = HiGHS_optimizer, store_variable_names = true, - ) + model = _bb_model(sys) @test build!(model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT - container = get_optimization_container(model) + base = PSY.get_base_power(sys) z = IOM.get_variable( container, BlockBidCommitmentVariable, PSY.VirtualParticipant, "Out") + p_out = IOM.get_variable(container, ActivePowerOutVariable, PSY.VirtualParticipant) + # One binary and one award variable per period; the award, not the binary, settles. + @test z["vp_supply", 1] !== z["vp_supply", 2] !== z["vp_supply", 3] settlement_expr = IOM.get_expression(container, IOM.SettlementBalance, PSY.System) - base = PSY.get_base_power(sys) - - # Coefficient check: the FIXED variable is the SAME shared JuMP variable, entered - # into all three periods' settlement rows at coefficient max_supply/base. - @test z["vp_supply", 1] === z["vp_supply", 2] === z["vp_supply", 3] + quantity = IOM.get_constraint( + container, BlockBidQuantityConstraint, PSY.VirtualParticipant, "Out") for t in 1:3 - @test JuMP.coefficient(settlement_expr[1, t], z["vp_supply", t]) ≈ 10.0 / base + @test JuMP.coefficient(settlement_expr[1, t], p_out["vp_supply", t]) == 1.0 + @test JuMP.coefficient(settlement_expr[1, t], z["vp_supply", t]) == 0.0 + # p - Q z == 0 with Q = 10 MW in per-unit + @test JuMP.normalized_coefficient( + quantity["vp_supply", t], + p_out["vp_supply", t], + ) ≈ 1.0 + @test JuMP.normalized_coefficient(quantity["vp_supply", t], z["vp_supply", t]) ≈ + -10.0 / base end + @test !IOM.has_container_key( + container, + BlockBidLinkConstraint, + PSY.VirtualParticipant, + "Out", + ) @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED res = OptimizationProblemOutputs(model) - z_values = read_variable( - res, "BlockBidCommitmentVariable__VirtualParticipant__Out"; - table_format = TableFormat.WIDE, + @test all(isapprox.(_bb_z(res), expect; atol = 1e-6)) + @test all(isapprox.(_bb_out(res), 10.0 * expect; atol = 1e-6)) + end +end + +@testset "Block bids: FIXED MULTI_STEP clears one constant-offer block as a whole" begin + # Same offer every period, third period out of merit on its own ($45 vs a $20 margin): + # SINGLE_STEP rejects it, MULTI_STEP carries it (2 x 350 saved in periods 1-2 against + # 250 lost in period 3). + for (multistep, expect_z) in ( + (PSY.CurveMultiStep.SINGLE_STEP, [1.0, 1.0, 0.0]), + (PSY.CurveMultiStep.MULTI_STEP, [1.0, 1.0, 1.0]), + ) + sys = _block_bid_test_system(; + vp_supply_curve = _single_segment_curve(45.0), + vp_supply_style = PSY.CurveStyles.FIXED, + vp_supply_multistep = multistep, + demand_mw = [70.0, 70.0, 20.0], ) - cleared = 0.0 - if expect_commit - cleared = 1.0 - end - for t in 1:3 - @test z_values[t, "vp_supply"] ≈ cleared atol = 1e-6 - end + model, res = _bb_solved(sys) + @test _bb_z(res) ≈ expect_z atol = 1e-6 + @test _bb_out(res) ≈ 10.0 .* expect_z atol = 1e-6 + container = get_optimization_container(model) + linked = IOM.has_container_key( + container, BlockBidLinkConstraint, PSY.VirtualParticipant, "Out") + @test linked == (multistep == PSY.CurveMultiStep.MULTI_STEP) + linked || continue + @test _bb_link_keys(container) == Set([("vp_supply", 1), ("vp_supply", 2)]) end + + # A price change ends the block: periods 1-2 at $45 are one block, period 3 at $95 is + # its own and stays out of merit even under MULTI_STEP. + sys = _block_bid_test_system(; + vp_supply_ts = [(10.0, 45.0), (10.0, 45.0), (10.0, 95.0)], + vp_supply_style = PSY.CurveStyles.FIXED, + vp_supply_multistep = PSY.CurveMultiStep.MULTI_STEP, + ) + model, res = _bb_solved(sys) + @test _bb_z(res) ≈ [1.0, 1.0, 0.0] atol = 1e-6 + @test _bb_link_keys(get_optimization_container(model)) == Set([("vp_supply", 1)]) end -@testset "Block bids: VARIABLE clears one shared fraction; CURVE clears each period independently" begin - sys_variable = _block_bid_test_system(; +@testset "Block bids: VARIABLE SINGLE_STEP clears each period independently on the PWL path" begin + sys = _block_bid_test_system(; vp_supply_curve = _kinked_curve(), vp_supply_style = PSY.CurveStyles.VARIABLE, ) - model_variable = DecisionModel( - _vp_test_template(), sys_variable; - optimizer = HiGHS_optimizer, store_variable_names = true, + model, res = _bb_solved(sys) + container = get_optimization_container(model) + p_out = IOM.get_variable(container, ActivePowerOutVariable, PSY.VirtualParticipant) + @test p_out["vp_supply", 1] !== p_out["vp_supply", 2] !== p_out["vp_supply", 3] + @test !IOM.has_container_key( + container, + BlockBidCommitmentVariable, + PSY.VirtualParticipant, + "Out", ) - @test build!(model_variable; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - - container_variable = get_optimization_container(model_variable) - p_out_variable = - IOM.get_variable(container_variable, ActivePowerOutVariable, PSY.VirtualParticipant) - # VARIABLE reuses the SAME JuMP variable at every period: a single shared fraction of - # the block, cleared identically at every period. - @test p_out_variable["vp_supply", 1] === p_out_variable["vp_supply", 2] === - p_out_variable["vp_supply", 3] - - @test solve!(model_variable) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res_variable = OptimizationProblemOutputs(model_variable) - variable_values = read_variable( - res_variable, "ActivePowerOutVariable__VirtualParticipant"; - table_format = TableFormat.WIDE, + @test !IOM.has_container_key( + container, + BlockBidLinkConstraint, + PSY.VirtualParticipant, + "Out", ) # Only the cheap ($10) 5 MW half-segment is in merit against thermal's $80/MWh top # segment; the $250 half never is -> exactly half the block clears, every period. - for t in 1:3 - @test variable_values[t, "vp_supply"] ≈ 5.0 atol = 1e-6 + @test _bb_out(res) ≈ [5.0, 5.0, 5.0] atol = 1e-6 +end + +@testset "Block bids: VARIABLE MULTI_STEP clears the same MW in every period of a block" begin + # One step at $45: SINGLE_STEP drops the third period ($20 margin), MULTI_STEP keeps it. + for (multistep, expect) in ( + (PSY.CurveMultiStep.SINGLE_STEP, [10.0, 10.0, 0.0]), + (PSY.CurveMultiStep.MULTI_STEP, [10.0, 10.0, 10.0]), + ) + sys = _block_bid_test_system(; + vp_supply_curve = _single_segment_curve(45.0), + vp_supply_style = PSY.CurveStyles.VARIABLE, + vp_supply_multistep = multistep, + demand_mw = [70.0, 70.0, 20.0], + ) + model, res = _bb_solved(sys) + @test _bb_out(res) ≈ expect atol = 1e-6 + @test !IOM.has_container_key( + get_optimization_container(model), BlockBidCommitmentVariable, + PSY.VirtualParticipant, "Out") end - sys_curve = _block_bid_test_system(; + # Multi-segment curves link too: both steps clear against $80, only the $10 step + # against $20. SINGLE_STEP -> 5 MW in period 3; MULTI_STEP -> 10 MW everywhere, since + # 2 x 550 saved in periods 1-2 outweighs the 50 lost in period 3. + for (multistep, expect) in ( + (PSY.CurveMultiStep.SINGLE_STEP, [10.0, 10.0, 5.0]), + (PSY.CurveMultiStep.MULTI_STEP, [10.0, 10.0, 10.0]), + ) + sys = _block_bid_test_system(; + vp_supply_curve = _two_step_curve(), + vp_supply_style = PSY.CurveStyles.VARIABLE, + vp_supply_multistep = multistep, + demand_mw = [70.0, 70.0, 20.0], + ) + model, res = _bb_solved(sys) + @test _bb_out(res) ≈ expect atol = 1e-6 + end + + # A partial block: the kinked curve clears its fractional optimum, the same in every + # period, with the link rows in place. + sys = _block_bid_test_system(; vp_supply_curve = _kinked_curve(), - vp_supply_style = PSY.CurveStyles.CURVE, + vp_supply_style = PSY.CurveStyles.VARIABLE, + vp_supply_multistep = PSY.CurveMultiStep.MULTI_STEP, ) - model_curve = DecisionModel( - _vp_test_template(), sys_curve; - optimizer = HiGHS_optimizer, store_variable_names = true, + model, res = _bb_solved(sys) + @test _bb_out(res) ≈ [5.0, 5.0, 5.0] atol = 1e-6 + @test _bb_link_keys(get_optimization_container(model)) == + Set([("vp_supply", 1), ("vp_supply", 2)]) + + # A MW change ends the block: 10 MW in periods 1-2, 5 MW in period 3, all at $45. The + # 5 MW block stands alone against the $20 margin and is rejected. + sys = _block_bid_test_system(; + vp_supply_ts = [(10.0, 45.0), (10.0, 45.0), (5.0, 45.0)], + vp_supply_style = PSY.CurveStyles.VARIABLE, + vp_supply_multistep = PSY.CurveMultiStep.MULTI_STEP, + demand_mw = [70.0, 70.0, 20.0], ) - @test build!(model_curve; output_dir = mktempdir(; cleanup = true)) == + model, res = _bb_solved(sys) + @test _bb_out(res) ≈ [10.0, 10.0, 0.0] atol = 1e-6 + @test _bb_link_keys(get_optimization_container(model)) == Set([("vp_supply", 1)]) +end + +@testset "Block bids: periods with an empty curve pin the award and the binary to zero" begin + sys = _block_bid_test_system(; + vp_supply_ts = [(10.0, 50.0), (0.0, 0.0), (10.0, 50.0)], + vp_supply_style = PSY.CurveStyles.FIXED, + vp_supply_multistep = PSY.CurveMultiStep.MULTI_STEP, + ) + model = _bb_model(sys) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT + container = get_optimization_container(model) + z = IOM.get_variable( + container, + BlockBidCommitmentVariable, + PSY.VirtualParticipant, + "Out", + ) + # Checked before the solve: the dual pass fixes and unfixes every integer afterwards. + @test JuMP.is_fixed(z["vp_supply", 2]) + @test !JuMP.is_fixed(z["vp_supply", 1]) + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + res = OptimizationProblemOutputs(model) + @test _bb_z(res) ≈ [1.0, 0.0, 1.0] atol = 1e-6 + @test _bb_out(res) ≈ [10.0, 0.0, 10.0] atol = 1e-6 + # The empty period splits the run: two single-period blocks, no link rows at all. + @test !IOM.has_container_key( + container, + BlockBidLinkConstraint, + PSY.VirtualParticipant, + "Out", + ) +end - container_curve = get_optimization_container(model_curve) - p_out_curve = - IOM.get_variable(container_curve, ActivePowerOutVariable, PSY.VirtualParticipant) - # CURVE: a distinct JuMP variable per period, unlike VARIABLE's/FIXED's single shared - # decision. - @test p_out_curve["vp_supply", 1] !== p_out_curve["vp_supply", 2] - @test p_out_curve["vp_supply", 2] !== p_out_curve["vp_supply", 3] - - @test solve!(model_curve) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - res_curve = OptimizationProblemOutputs(model_curve) - curve_values = read_variable( - res_curve, "ActivePowerOutVariable__VirtualParticipant"; - table_format = TableFormat.WIDE, +@testset "Block bids: a FIXED curve with more than one segment is rejected" begin + # Static curves are refused by PSY at construction. + @test_throws Exception PSY.MarketBidCost(; + curve_style = PSY.CurveStyles.FIXED, incremental_offer_curves = _kinked_curve(), ) - for t in 1:3 - @test curve_values[t, "vp_supply"] ≈ 5.0 atol = 1e-6 - end + # Time-series curves are only resolved at build, so POM refuses them there. + sys = _block_bid_test_system(; vp_supply_style = PSY.CurveStyles.FIXED) + vp = PSY.get_component(PSY.VirtualParticipant, sys, "vp_supply") + _bb_ts_cost!( + sys, vp; + incremental = fill(IS.PiecewiseStepData([0.0, 5.0, 10.0], [10.0, 250.0]), 4), + style = PSY.CurveStyles.FIXED, + ) + PSY.transform_single_time_series!(sys, Dates.Hour(3), Dates.Hour(3)) + model = _bb_model(sys) + output_dir = mktempdir(; cleanup = true) + @test build!(model; output_dir = output_dir) == IOM.ModelBuildStatus.FAILED + @test _market_model_log_contains(output_dir, "vp_supply") + @test _market_model_log_contains(output_dir, "segment") end -@testset "Block bids: nonzero VOM on a FIXED/VARIABLE device is rejected loudly" begin +@testset "Block bids: nonzero VOM on a FIXED device is rejected loudly; VARIABLE carries it" begin _vom_test_vp(name, style) = PSY.VirtualParticipant(; name = name, available = true, @@ -724,23 +937,9 @@ end @test occursin("FIXED", err_fixed.msg) @test occursin("5.0", err_fixed.msg) - # VARIABLE: rejected the same way. + # VARIABLE: the same nonzero VOM is untouched, it flows through the PWL path. vp_variable = _vom_test_vp("vp_variable", PSY.CurveStyles.VARIABLE) - err_variable = nothing - try - POM._validate_block_bid_vom!(vp_variable, PSY.CurveStyles.VARIABLE) - catch e - err_variable = e - end - @test err_variable isa ErrorException - @test occursin("vp_variable", err_variable.msg) - @test occursin("VARIABLE", err_variable.msg) - @test occursin("5.0", err_variable.msg) - - # CURVE: the same nonzero VOM is untouched -- CURVE devices route through the - # standard `add_variable_cost!` path, which handles VOM correctly. - vp_curve = _vom_test_vp("vp_curve", PSY.CurveStyles.CURVE) - POM._validate_block_bid_vom!(vp_curve, PSY.CurveStyles.CURVE) + @test POM._validate_block_bid_vom!(vp_variable, PSY.CurveStyles.VARIABLE) === nothing # Zero VOM (the default) never throws, regardless of style. vp_zero_vom = PSY.VirtualParticipant(; @@ -775,14 +974,14 @@ end @test _market_model_log_contains(output_dir, "VOM cost") end -@testset "CURVE VP's VOM is per-variable direction (own curve's VOM, not the formulation default)" begin +@testset "VARIABLE VP's VOM is per-variable direction (own curve's VOM, not the formulation default)" begin # Unit-level: the dispatch table itself. @test IOM._vom_offer_direction(ActivePowerOutVariable, VirtualBidDispatch) == IOM.IncrementalOffer() @test IOM._vom_offer_direction(ActivePowerInVariable, VirtualBidDispatch) == IOM.DecrementalOffer() - # End-to-end, coefficient-level: a two-sided CURVE VP with DISTINCT nonzero VOM on + # End-to-end, coefficient-level: a two-sided VARIABLE VP with DISTINCT nonzero VOM on # each side. Resolving the direction from the formulation alone would make In's VOM read # Out's ($2) curve instead of its own ($7). VOM is the ONLY way either variable # enters the objective directly (the PWL delta machinery links p = Σδ_k via a @@ -797,7 +996,7 @@ end max_supply = 50.0, max_demand = 50.0, operation_cost = PSY.MarketBidCost(; - curve_style = PSY.CurveStyles.CURVE, + curve_style = PSY.CurveStyles.VARIABLE, incremental_offer_curves = PSY.CostCurve( PSY.PiecewiseIncrementalCurve(0.0, [0.0, 50.0], [10.0]), PSY.NU, @@ -851,83 +1050,18 @@ end end end -@testset "FIXED/VARIABLE offer curve span must match its envelope (max_supply/max_demand)" begin - _span_test_vp(name, style, max_supply, curve_span) = PSY.VirtualParticipant(; - name = name, - available = true, - max_supply = max_supply, - max_demand = 0.0, - operation_cost = PSY.MarketBidCost(; - curve_style = style, - incremental_offer_curves = PSY.CostCurve( - PSY.PiecewiseIncrementalCurve(0.0, [0.0, curve_span], [50.0]), - PSY.NU, - ), - ), - ) - - # Mismatch: curve spans [0, 5] but max_supply = 10 -> rejected, both values named. - vp_mismatch = _span_test_vp("vp_mismatch", PSY.CurveStyles.FIXED, 10.0, 5.0) - err = nothing - try - POM._validate_block_bid_span!(vp_mismatch, PSY.CurveStyles.FIXED) - catch e - err = e - end - @test err isa ErrorException - @test occursin("vp_mismatch", err.msg) - @test occursin("10.0", err.msg) - @test occursin("5.0", err.msg) - - # Match: curve spans [0, 10] and max_supply = 10 -> no error. - vp_match = _span_test_vp("vp_match", PSY.CurveStyles.FIXED, 10.0, 10.0) - @test POM._validate_block_bid_span!(vp_match, PSY.CurveStyles.FIXED) === nothing - - # VARIABLE: same rule applies. - vp_variable_mismatch = - _span_test_vp("vp_variable_mismatch", PSY.CurveStyles.VARIABLE, 10.0, 5.0) - err_var = nothing - try - POM._validate_block_bid_span!(vp_variable_mismatch, PSY.CurveStyles.VARIABLE) - catch e - err_var = e +@testset "Block bids: FIXED and VARIABLE settle the curve's own quantity, not the envelope" begin + # A curve spanning 5 MW under a 10 MW envelope clears 5 MW in merit, for both styles; + # nothing prices the envelope any more. + for style in (PSY.CurveStyles.FIXED, PSY.CurveStyles.VARIABLE) + sys = _block_bid_test_system(; + vp_supply_curve = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 5.0], [50.0]), PSY.NU), + vp_supply_style = style, + ) + model, res = _bb_solved(sys) + @test _bb_out(res) ≈ [5.0, 5.0, 5.0] atol = 1e-6 end - @test err_var isa ErrorException - - # CURVE is exempt (its own per-period variable is bounded directly by the curve, no - # separate envelope to mismatch). - vp_curve_mismatch = _span_test_vp("vp_curve_mismatch", PSY.CurveStyles.CURVE, 10.0, 5.0) - @test POM._validate_block_bid_span!(vp_curve_mismatch, PSY.CurveStyles.CURVE) === - nothing - - # End-to-end: a FIXED VP with a mismatched span fails the full build. - sys_mismatch = _block_bid_test_system(; - vp_supply_curve = PSY.CostCurve( - PSY.PiecewiseIncrementalCurve(0.0, [0.0, 5.0], [50.0]), - PSY.NU, - ), - vp_supply_style = PSY.CurveStyles.FIXED, - ) - model_mismatch = DecisionModel( - _vp_test_template(), sys_mismatch; - optimizer = HiGHS_optimizer, store_variable_names = true, - ) - output_dir = mktempdir(; cleanup = true) - @test build!(model_mismatch; output_dir = output_dir) == IOM.ModelBuildStatus.FAILED - @test _market_model_log_contains(output_dir, "envelope") - - # A matching-span build still succeeds (regression: `_block_bid_test_system`'s - # `_single_segment_curve` fixtures span [0, 10], matching its hardcoded max_supply = 10). - sys_match = _block_bid_test_system(; - vp_supply_curve = _single_segment_curve(50.0), - vp_supply_style = PSY.CurveStyles.FIXED, - ) - model_match = DecisionModel( - _vp_test_template(), sys_match; - optimizer = HiGHS_optimizer, store_variable_names = true, - ) - @test build!(model_match; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT end # One-bus MarketLoadBid fixture: a cheap thermal (`ThermalStandardUnitCommitment`, @@ -1238,54 +1372,47 @@ end end end - @testset "Block-bid identity: FIXED shared var / VARIABLE shared var / CURVE per-period" begin + @testset "Block-bid identity: per-period variables for every style, link rows only for MULTI_STEP" begin sys_fixed = _block_bid_test_system(; vp_supply_curve = _single_segment_curve(50.0), vp_supply_style = PSY.CurveStyles.FIXED, ) - model_fixed = - DecisionModel(_vp_test_template(), sys_fixed; optimizer = HiGHS_optimizer) + model_fixed = _bb_model(sys_fixed) @test build!(model_fixed; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT + container_fixed = get_optimization_container(model_fixed) z = IOM.get_variable( - get_optimization_container(model_fixed), - BlockBidCommitmentVariable, + container_fixed, BlockBidCommitmentVariable, PSY.VirtualParticipant, "Out") + p_fixed = IOM.get_variable( + container_fixed, + ActivePowerOutVariable, PSY.VirtualParticipant, - "Out", ) - @test z["vp_supply", 1] === z["vp_supply", 2] === z["vp_supply", 3] + @test z["vp_supply", 1] !== z["vp_supply", 2] !== z["vp_supply", 3] + @test p_fixed["vp_supply", 1] !== p_fixed["vp_supply", 2] !== + p_fixed["vp_supply", 3] + @test IOM.has_container_key( + container_fixed, BlockBidQuantityConstraint, PSY.VirtualParticipant, "Out") + @test !IOM.has_container_key( + container_fixed, BlockBidLinkConstraint, PSY.VirtualParticipant, "Out") sys_variable = _block_bid_test_system(; vp_supply_curve = _kinked_curve(), vp_supply_style = PSY.CurveStyles.VARIABLE, + vp_supply_multistep = PSY.CurveMultiStep.MULTI_STEP, ) - model_variable = - DecisionModel(_vp_test_template(), sys_variable; optimizer = HiGHS_optimizer) + model_variable = _bb_model(sys_variable) @test build!(model_variable; output_dir = mktempdir(; cleanup = true)) == IOM.ModelBuildStatus.BUILT - p_out_variable = IOM.get_variable( - get_optimization_container(model_variable), - ActivePowerOutVariable, - PSY.VirtualParticipant, - ) - @test p_out_variable["vp_supply", 1] === p_out_variable["vp_supply", 2] === - p_out_variable["vp_supply", 3] - - sys_curve = _block_bid_test_system(; - vp_supply_curve = _kinked_curve(), - vp_supply_style = PSY.CurveStyles.CURVE, - ) - model_curve = - DecisionModel(_vp_test_template(), sys_curve; optimizer = HiGHS_optimizer) - @test build!(model_curve; output_dir = mktempdir(; cleanup = true)) == - IOM.ModelBuildStatus.BUILT - p_out_curve = IOM.get_variable( - get_optimization_container(model_curve), - ActivePowerOutVariable, - PSY.VirtualParticipant, - ) - @test p_out_curve["vp_supply", 1] !== p_out_curve["vp_supply", 2] !== - p_out_curve["vp_supply", 3] + container_variable = get_optimization_container(model_variable) + p_variable = IOM.get_variable( + container_variable, ActivePowerOutVariable, PSY.VirtualParticipant) + @test p_variable["vp_supply", 1] !== p_variable["vp_supply", 2] !== + p_variable["vp_supply", 3] + @test !IOM.has_container_key( + container_variable, BlockBidCommitmentVariable, PSY.VirtualParticipant, "Out") + @test IOM.has_container_key( + container_variable, BlockBidLinkConstraint, PSY.VirtualParticipant, "Out") end @testset "Zero-cost load's market energy variable is fixed to zero at build time" begin diff --git a/test/test_nodal_distribution.jl b/test/test_nodal_distribution.jl index 2447a6c6..bc92c8f6 100644 --- a/test/test_nodal_distribution.jl +++ b/test/test_nodal_distribution.jl @@ -464,6 +464,82 @@ end IOM.ModelBuildStatus.FAILED end +# The settlement row holds market participants only and a spread bid is excluded from it, so +# a solvable system needs a demand virtual sized above the fleet's summed minimum power. +function _add_settlement_demand!(sys, zone) + vp = PSY.VirtualParticipant(; + name = "VD", available = true, max_supply = 0.0, max_demand = 1000.0, + settlement_point = zone, + operation_cost = PSY.MarketBidCost(; + decremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 1000.0], [1000.0]), + PSY.NU, + ), + ), + ) + PSY.add_component!(sys, vp) + return vp +end + +function _p2p_and_demand_template() + template = _p2p_template() + set_market_component_model!( + template, DeviceModel(PSY.VirtualParticipant, VirtualBidDispatch), + ) + return template +end + +@testset "SpreadBid MULTI_STEP links the periods of one constant-offer block" begin + step_curve = + PSY.CostCurve(PSY.PiecewiseIncrementalCurve(0.0, [0.0, 50.0], [3.0]), PSY.NU) + sys, zone, zone_buses = _build_zone_system() + _add_settlement_demand!(sys, zone) + hub, bid = _add_hub_and_p2p!( + sys, zone; + spread_bid = PSY.MarketBidCost(; + incremental_offer_curves = step_curve, + curve_multistep = PSY.CurveMultiStep.MULTI_STEP, + ), + ) + model = DecisionModel(_p2p_and_demand_template(), sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + container = get_optimization_container(model) + time_steps = get_time_steps(container) + q = IOM.get_variable(container, ClearedTransferVariable, PSY.PointToPointBid) + @test IOM.has_container_key(container, BlockBidLinkConstraint, PSY.PointToPointBid) + rows = IOM.get_constraint(container, BlockBidLinkConstraint, PSY.PointToPointBid) + # One row per consecutive pair: the whole horizon is one block for a constant offer. + @test Set(keys(rows.data)) == + Set(("P2P1", t) for t in first(time_steps):(last(time_steps) - 1)) + t1 = first(time_steps) + @test JuMP.normalized_coefficient(rows["P2P1", t1], q["P2P1", t1]) == 1.0 + @test JuMP.normalized_coefficient(rows["P2P1", t1], q["P2P1", t1 + 1]) == -1.0 + @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + res = OptimizationProblemOutputs(model) + cleared = read_variable( + res, "ClearedTransferVariable__PointToPointBid"; table_format = TableFormat.WIDE, + )[ + !, + "P2P1", + ] + @test maximum(cleared) - minimum(cleared) < 1e-6 + + # SINGLE_STEP, the default, adds no link rows. + sys2, zone2, _ = _build_zone_system() + _add_settlement_demand!(sys2, zone2) + _add_hub_and_p2p!( + sys2, + zone2; + spread_bid = PSY.MarketBidCost(; incremental_offer_curves = step_curve), + ) + model2 = DecisionModel(_p2p_and_demand_template(), sys2; optimizer = HiGHS_optimizer) + @test build!(model2; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + @test !IOM.has_container_key( + get_optimization_container(model2), BlockBidLinkConstraint, PSY.PointToPointBid) +end + @testset "SpreadBid rejects a decremental spread curve" begin sys, zone, zone_buses = _build_zone_system() hub, bid = _add_hub_and_p2p!( @@ -524,6 +600,46 @@ end end end +@testset "VirtualBidDispatch writes a FIXED participant's award into its settlement location" begin + sys, zone, zone_buses = _build_zone_system() + vp = PSY.VirtualParticipant(; + name = "VF", available = true, max_supply = 10.0, max_demand = 0.0, + settlement_point = zone, + operation_cost = PSY.MarketBidCost(; + curve_style = PSY.CurveStyles.FIXED, + incremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 10.0], [1000.0]), + PSY.NU, + ), + ), + ) + PSY.add_component!(sys, vp) + template = _ptdf_market_template() + set_market_component_model!(template, DeviceModel(PSY.LoadZone, NodalRedistribution)) + set_market_component_model!( + template, DeviceModel(PSY.VirtualParticipant, VirtualBidDispatch), + ) + model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + container = get_optimization_container(model) + t1 = first(get_time_steps(container)) + p_out = IOM.get_variable(container, ActivePowerOutVariable, PSY.VirtualParticipant) + z = IOM.get_variable( + container, + BlockBidCommitmentVariable, + PSY.VirtualParticipant, + "Out", + ) + zexpr = IOM.get_expression(container, AggregateClearedInjection, PSY.LoadZone) + # The award variable carries the position; the binary appears nowhere but its own row. + @test JuMP.coefficient(zexpr["LZ1", t1], p_out["VF", t1]) == 1.0 + @test JuMP.coefficient(zexpr["LZ1", t1], z["VF", t1]) == 0.0 + sexpr = IOM.get_expression(container, IOM.SettlementBalance, PSY.System) + @test JuMP.coefficient(sexpr[1, t1], p_out["VF", t1]) == 1.0 + @test JuMP.coefficient(sexpr[1, t1], z["VF", t1]) == 0.0 +end + @testset "VirtualBidDispatch at a single trading hub writes into the hub" begin sys, zone, zone_buses = _build_zone_system() buses = sort!(collect(PSY.get_components(PSY.ACBus, sys)); by = PSY.get_number) From fe47da2dc44acd4f94fcc807fbbde26418e9033a Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 10 Sep 2026 15:02:25 -0700 Subject: [PATCH 2/6] Pin PowerSystems to the #1784 merge commit and InfrastructureSystems before its OpenAPI 1.x move PowerSystems #1784 merged into psy6 and its rh/update_enums branch is gone, which broke the previous pin. The psy6 and IS4 heads then moved to OpenAPI 1.x against OpenAPI model packages that are not pushed yet, so neither head resolves from git. Until that alignment lands: PowerSystems at the #1784 merge commit, InfrastructureSystems at its last commit before the move, the PowerOpenAPIModels subpackages still at rh/update_enums. All marked temporary in Project.toml and test/Project.toml. --- Project.toml | 8 ++++++-- test/Project.toml | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Project.toml b/Project.toml index 4ef01a5e..21ecb3bd 100644 --- a/Project.toml +++ b/Project.toml @@ -36,12 +36,16 @@ PowerFlows = "94fada2c-fd9a-4e89-8d82-81405f5cb4f6" # LOCAL PATH CO-DEV PIN (temporary): POM's IS4 git pin resolved to an older commit # than PSY's local checkout expects (TimeSeriesKey UnionAll mismatch on load). # Revert to the git rev pin once PSY's local changes land on IS4. -InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} +# TEMPORARY: the last IS4 commit before its OpenAPI 1.x move (2026-09-10), which needs the same +# unpushed OpenAPI model packages. +InfrastructureSystems = {rev = "04af380f8a013b150ddf7e0d8ca812ad9e24812b", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} InfrastructureOptimizationModels = {rev = "jd/market_models2", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} # LOCAL PATH CO-DEV PIN (temporary): the variable -> variable_operation_cost cost-field # rename on PSY's generated cost types is uncommitted on disk, not on the psy6 branch yet. # Revert to the git rev pin once pushed/merged. -PowerSystems = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} +# TEMPORARY: psy6 as of the PowerSystems #1784 merge. The psy6 head (2026-09-10) moved to OpenAPI 1.x +# against unpushed OpenAPI model packages; follow it once those are pushed. +PowerSystems = {rev = "f75b4418c2928ff7efd6d110c6dce40c401f874e", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} # TEMPORARY: pinned to the arc-keyed-catalog branch (PNM #356 + #357) until they merge to # psy6. Revert to `rev = "psy6"` once they land. PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} diff --git a/test/Project.toml b/test/Project.toml index 26171203..8a63c7da 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -48,9 +48,13 @@ UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" # to apply). Drop the dep + entry once InfraStore is registered. [sources] PowerOperationsModels = {path = ".."} -InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} +# TEMPORARY: the last IS4 commit before its OpenAPI 1.x move (2026-09-10), which needs the same +# unpushed OpenAPI model packages. +InfrastructureSystems = {rev = "04af380f8a013b150ddf7e0d8ca812ad9e24812b", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} InfrastructureOptimizationModels = {rev = "jd/market_models2", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} -PowerSystems = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} +# TEMPORARY: psy6 as of the PowerSystems #1784 merge. The psy6 head (2026-09-10) moved to OpenAPI 1.x +# against unpushed OpenAPI model packages; follow it once those are pushed. +PowerSystems = {rev = "f75b4418c2928ff7efd6d110c6dce40c401f874e", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} PowerSystemCaseBuilder = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl"} # PSY psy6 depends on these unregistered packages; [sources] of non-root projects are # ignored by Pkg, so every environment resolving PSY must pin them itself (same rev as From 8b41fa1a429d82c24b38e3cbce19afef33d359e9 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 10 Sep 2026 15:05:40 -0700 Subject: [PATCH 3/6] Point the docs environment at jd/market_models2 and the interim PowerSystems and InfrastructureSystems pins The docs build resolved InfrastructureOptimizationModels from jd/market_model, a branch that no longer exists, and PowerSystems and InfrastructureSystems from heads that moved to OpenAPI 1.x against unpushed model packages. The docs project now mirrors the root Project.toml pins, marked temporary. --- docs/Project.toml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/Project.toml b/docs/Project.toml index ba0f8fd4..48d4d89c 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -19,21 +19,23 @@ PowerSystems = "bcd98974-b02a-5e2f-9ee0-a103f5c450dd" PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" [sources] -InfrastructureSystems = {rev = "IS4", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} -PowerSystems = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} +# TEMPORARY (mirrors the root Project.toml): last IS4 commit before its OpenAPI 1.x move. +InfrastructureSystems = {rev = "04af380f8a013b150ddf7e0d8ca812ad9e24812b", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} +# TEMPORARY (mirrors the root Project.toml): psy6 as of the PowerSystems #1784 merge. +PowerSystems = {rev = "f75b4418c2928ff7efd6d110c6dce40c401f874e", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} # Still-unmerged market-model work; not yet an ancestor of IOM's main. -InfrastructureOptimizationModels = {rev = "jd/market_model", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} +InfrastructureOptimizationModels = {rev = "jd/market_models2", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} # PSY psy6 depends on these unregistered packages; [sources] of non-root projects are # ignored by Pkg, so every environment resolving PSY must pin them itself (same rev as # PSY's own sources). Temporary until the OpenAPI packages are registered. -PowerCoreOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerCoreOpenAPIModels.jl"} -InfrastructureTimeSeriesOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} -InfrastructureCoreOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "InfrastructureCoreOpenAPIModels.jl"} -PowerDynamicsOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerDynamicsOpenAPIModels.jl"} -PowerInvestmentsOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerInvestmentsOpenAPIModels.jl"} -PowerOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerOpenAPIModels.jl"} -PowerOperationsOpenAPIModels = {rev = "main", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerOperationsOpenAPIModels.jl"} +PowerCoreOpenAPIModels = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerCoreOpenAPIModels.jl"} +InfrastructureTimeSeriesOpenAPIModels = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "InfrastructureTimeSeriesOpenAPIModels.jl"} +InfrastructureCoreOpenAPIModels = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "InfrastructureCoreOpenAPIModels.jl"} +PowerDynamicsOpenAPIModels = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerDynamicsOpenAPIModels.jl"} +PowerInvestmentsOpenAPIModels = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerInvestmentsOpenAPIModels.jl"} +PowerOpenAPIModels = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerOpenAPIModels.jl"} +PowerOperationsOpenAPIModels = {rev = "rh/update_enums", url = "https://github.com/Sienna-Platform/PowerOpenAPIModels.git", subdir = "PowerOperationsOpenAPIModels.jl"} [compat] Documenter = "^1.0" From fd51af2bfa88e246762d76ef2c4a39d2c728f8c7 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 10 Sep 2026 15:11:44 -0700 Subject: [PATCH 4/6] Say what the PowerSystems pin carries: the #1784 squash merge, which already includes #1783 --- Project.toml | 5 +++-- docs/Project.toml | 4 +++- test/Project.toml | 5 +++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Project.toml b/Project.toml index 21ecb3bd..07c4c472 100644 --- a/Project.toml +++ b/Project.toml @@ -43,8 +43,9 @@ InfrastructureOptimizationModels = {rev = "jd/market_models2", url = "https://gi # LOCAL PATH CO-DEV PIN (temporary): the variable -> variable_operation_cost cost-field # rename on PSY's generated cost types is uncommitted on disk, not on the psy6 branch yet. # Revert to the git rev pin once pushed/merged. -# TEMPORARY: psy6 as of the PowerSystems #1784 merge. The psy6 head (2026-09-10) moved to OpenAPI 1.x -# against unpushed OpenAPI model packages; follow it once those are pushed. +# TEMPORARY: psy6 as of the PowerSystems #1784 squash merge (2026-09-10). It already carries #1783 +# (must_run removed, status enum), which POM's feedforwards and the pushed OpenAPI model packages do +# not follow yet, and the psy6 head has since moved to OpenAPI 1.x against unpushed model packages. PowerSystems = {rev = "f75b4418c2928ff7efd6d110c6dce40c401f874e", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} # TEMPORARY: pinned to the arc-keyed-catalog branch (PNM #356 + #357) until they merge to # psy6. Revert to `rev = "psy6"` once they land. diff --git a/docs/Project.toml b/docs/Project.toml index 48d4d89c..938b326d 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -21,7 +21,9 @@ PrettyTables = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" [sources] # TEMPORARY (mirrors the root Project.toml): last IS4 commit before its OpenAPI 1.x move. InfrastructureSystems = {rev = "04af380f8a013b150ddf7e0d8ca812ad9e24812b", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} -# TEMPORARY (mirrors the root Project.toml): psy6 as of the PowerSystems #1784 merge. +# TEMPORARY: psy6 as of the PowerSystems #1784 squash merge (2026-09-10). It already carries #1783 +# (must_run removed, status enum), which POM's feedforwards and the pushed OpenAPI model packages do +# not follow yet, and the psy6 head has since moved to OpenAPI 1.x against unpushed model packages. PowerSystems = {rev = "f75b4418c2928ff7efd6d110c6dce40c401f874e", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} # Still-unmerged market-model work; not yet an ancestor of IOM's main. InfrastructureOptimizationModels = {rev = "jd/market_models2", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} diff --git a/test/Project.toml b/test/Project.toml index 8a63c7da..8ba9f515 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -52,8 +52,9 @@ PowerOperationsModels = {path = ".."} # unpushed OpenAPI model packages. InfrastructureSystems = {rev = "04af380f8a013b150ddf7e0d8ca812ad9e24812b", url = "https://github.com/Sienna-Platform/InfrastructureSystems.jl"} InfrastructureOptimizationModels = {rev = "jd/market_models2", url = "https://github.com/Sienna-Platform/InfrastructureOptimizationModels.jl"} -# TEMPORARY: psy6 as of the PowerSystems #1784 merge. The psy6 head (2026-09-10) moved to OpenAPI 1.x -# against unpushed OpenAPI model packages; follow it once those are pushed. +# TEMPORARY: psy6 as of the PowerSystems #1784 squash merge (2026-09-10). It already carries #1783 +# (must_run removed, status enum), which POM's feedforwards and the pushed OpenAPI model packages do +# not follow yet, and the psy6 head has since moved to OpenAPI 1.x against unpushed model packages. PowerSystems = {rev = "f75b4418c2928ff7efd6d110c6dce40c401f874e", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} PowerSystemCaseBuilder = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerSystemCaseBuilder.jl"} # PSY psy6 depends on these unregistered packages; [sources] of non-root projects are From 8211358d76cb3a395da1e88850753da636d6a488 Mon Sep 17 00:00:00 2001 From: rodrigomha Date: Thu, 10 Sep 2026 17:43:49 -0700 Subject: [PATCH 5/6] Read offer curves where they are needed instead of through a per-stage window The OfferWindow struct and its cache memoized IOM._get_pwl_data so each consumer read a curve once per construct stage. The saving was not measurable next to building the JuMP containers, so the quantity rows, objective terms, segment check and block detection now call the accessor at the period they need, which also removes four helpers. The one behavioural piece stays: a time-invariant curve is one block over the window with no per-period comparison, decided from the curve's own time variance. --- src/market_models/virtual_participant.jl | 210 +++++++++-------------- 1 file changed, 79 insertions(+), 131 deletions(-) diff --git a/src/market_models/virtual_participant.jl b/src/market_models/virtual_participant.jl index 4aeb51c3..615bf971 100644 --- a/src/market_models/virtual_participant.jl +++ b/src/market_models/virtual_participant.jl @@ -143,86 +143,52 @@ _get_award_variable(container::OptimizationContainer, dir::IOM.OfferDirection) = get_variable(container, _bid_award_variable_type(dir), PSY.VirtualParticipant) ################################################################################# -# Offer curves over the model window +# Offer curves period by period # -# Two paths need an offer curve period by period. A FIXED bid takes its quantity and its -# value from the curve at every period: `p = Q z`, the `z` objective term, and `z` fixed to -# zero where nothing is offered. A MULTI_STEP bid of either style takes its block boundaries -# from it: a block ends where consecutive periods stop carrying the same curve. An -# `OfferWindow` is that per-period view, read once per (component, direction) and construct -# stage and shared through an `OfferWindowCache`. The VARIABLE single-step path does not use -# it; its PWL delta terms read the parameters themselves. +# Two paths read an offer curve at each period through `IOM._get_pwl_data`, which resolves a +# static curve from the cost object and a time-series curve from the padded parameter +# arrays, both in system per-unit. A FIXED bid takes its quantity and value from it: `p = Q z`, +# the `z` objective term, and `z` fixed to zero where nothing is offered. A MULTI_STEP bid of +# either style takes its block boundaries from it: a block ends where consecutive periods +# stop carrying the same curve. The VARIABLE single-step path does not read it; its PWL +# delta terms read the parameters themselves. # -# The PWL parameter containers pad every curve to the widest segment count in the model, so -# a one-step curve comes back with zero-width segments appended. Real segments are the +# The parameter containers pad every curve to the widest segment count in the model, so a +# one-step curve comes back with zero-width segments appended. Real segments are the # positive-width ones; the quantity is the top breakpoint either way. ################################################################################# -""" -One offer side over the model window, in system per-unit: `(breakpoints, slopes)` per period -for a time-series curve, a single pair for a time-invariant one. Empty when the side carries -no curve. -""" -struct OfferWindow - periods::Vector{Tuple{Vector{Float64}, Vector{Float64}}} - time_variant::Bool -end -OfferWindow() = OfferWindow(Tuple{Vector{Float64}, Vector{Float64}}[], false) -Base.isempty(window::OfferWindow) = isempty(window.periods) - -"The curve at window position `i`; a time-invariant window answers from its one curve." -function _period(window::OfferWindow, i::Int) - window.time_variant && return window.periods[i] - return window.periods[1] -end - -"Windows read so far in one construct stage, keyed by (direction meta, component name)." -const OfferWindowCache = Dict{Tuple{String, String}, OfferWindow} - -""" - _offer_window(dir, container, d, time_steps) -> OfferWindow - -`d`'s `dir` curve over the window; empty when that side carries no curve. A time-invariant -curve is read once, since every period holds the same curve. -""" -function _offer_window( +"`(breakpoints, slopes)` of `d`'s `dir` curve at period `t`, in per-unit; empty when that side has no curve." +function _curve_at( dir::IOM.OfferDirection, container::OptimizationContainer, d::IS.InfrastructureSystemsComponent, - time_steps, + t::Int, ) - curve = get_offer_curves(dir, d) - IOM.is_nontrivial_offer(curve) || return OfferWindow() - time_variant = IOM.is_time_variant(curve) - read_periods = time_steps - time_variant || (read_periods = first(time_steps):first(time_steps)) - periods = Tuple{Vector{Float64}, Vector{Float64}}[] - for t in read_periods - breakpoints, slopes = IOM._get_pwl_data(dir, container, d, t) - push!(periods, (collect(Float64, breakpoints), collect(Float64, slopes))) - end - return OfferWindow(periods, time_variant) + IOM.is_nontrivial_offer(get_offer_curves(dir, d)) || return (Float64[], Float64[]) + breakpoints, slopes = IOM._get_pwl_data(dir, container, d, t) + return (breakpoints, slopes) end -"The cached window of `d` in direction `dir`, read on first use." -function _window!( - cache::OfferWindowCache, +"Quantity `d` offers in direction `dir` at period `t`, in per-unit; 0 when that side has no curve." +function _offer_quantity( dir::IOM.OfferDirection, container::OptimizationContainer, d::IS.InfrastructureSystemsComponent, - time_steps, + t::Int, ) - return get!(cache, (_bid_direction_meta(dir), PSY.get_name(d))) do - _offer_window(dir, container, d, time_steps) - end + breakpoints, _ = _curve_at(dir, container, d, t) + isempty(breakpoints) && return 0.0 + return Float64(maximum(breakpoints)) end -"Offered quantity at window position `i`, in per-unit." -_window_quantity(window::OfferWindow, i::Int) = Float64(maximum(_period(window, i)[1])) - -"Whether the window offers a positive quantity at some period." -_offers_quantity(window::OfferWindow) = - any(i -> _window_quantity(window, i) > 0.0, eachindex(window.periods)) +"Whether `d` offers a positive quantity in direction `dir` at some period." +_offers_quantity( + dir::IOM.OfferDirection, + container::OptimizationContainer, + d::IS.InfrastructureSystemsComponent, + time_steps, +) = any(t -> _offer_quantity(dir, container, d, t) > 0.0, time_steps) "Number of positive-width segments in a padded breakpoint vector." _real_segments(breakpoints) = @@ -302,16 +268,17 @@ quantity must have exactly one positive-width segment. Static curves are checked construction; time-series curves only resolve here. """ function _validate_block_bid_segments!( - name::String, dir::IOM.OfferDirection, - window::OfferWindow, + container::OptimizationContainer, + d::PSY.VirtualParticipant, time_steps, ) - for (i, t) in enumerate(time_steps) - _window_quantity(window, i) > 0.0 || continue - segments = _real_segments(_period(window, i)[1]) + for t in time_steps + breakpoints, _ = _curve_at(dir, container, d, t) + (isempty(breakpoints) || maximum(breakpoints) <= 0.0) && continue + segments = _real_segments(breakpoints) segments == 1 || error( - "VirtualParticipant $(name) has curve_style FIXED but its " * + "VirtualParticipant $(PSY.get_name(d)) has curve_style FIXED but its " * "$(_bid_direction_meta(dir)) offer curve at period $(t) has $(segments) " * "segments; a FIXED bid is a single segment per period.", ) @@ -330,25 +297,21 @@ function _add_block_bid_commitment_variables!( ) isempty(devices) && return time_steps = get_time_steps(container) - cache = OfferWindowCache() for dir in (IOM.IncrementalOffer(), IOM.DecrementalOffer()) - offering = [ - d for d in devices if - _offers_quantity(_window!(cache, dir, container, d, time_steps)) - ] + offering = [d for d in devices if _offers_quantity(dir, container, d, time_steps)] isempty(offering) && continue variable = add_variable_container!( container, BlockBidCommitmentVariable, PSY.VirtualParticipant, _bid_direction_meta(dir), PSY.get_name.(offering), time_steps, ) for d in offering + _validate_block_bid_segments!(dir, container, d, time_steps) name = PSY.get_name(d) - window = _window!(cache, dir, container, d, time_steps) - _validate_block_bid_segments!(name, dir, window, time_steps) - for (i, t) in enumerate(time_steps) + for t in time_steps z = _new_bid_jump_var!(container, BlockBidCommitmentVariable, d, t) variable[name, t] = z - _window_quantity(window, i) > 0.0 || JuMP.fix(z, 0.0; force = true) + _offer_quantity(dir, container, d, t) > 0.0 || + JuMP.fix(z, 0.0; force = true) end end end @@ -359,11 +322,7 @@ end `p[d, t] - Q[d, t] z[d, t] == 0` for every FIXED device, direction with an offer, and period. Where nothing is offered `Q = 0` and `z` is fixed, so the row pins the award to 0. """ -function _add_block_bid_quantity_rows!( - container::OptimizationContainer, - devices, - cache::OfferWindowCache, -) +function _add_block_bid_quantity_rows!(container::OptimizationContainer, devices) isempty(devices) && return time_steps = get_time_steps(container) jump_model = get_jump_model(container) @@ -378,14 +337,11 @@ function _add_block_bid_quantity_rows!( ) p = _get_award_variable(container, dir) z = _get_block_bid_variable(container, dir) - for name in names - window = _window!(cache, dir, container, by_name[name], time_steps) - for (i, t) in enumerate(time_steps) - quantity = _window_quantity(window, i) - rows[name, t] = JuMP.@constraint( - jump_model, p[name, t] - quantity * z[name, t] == 0, - ) - end + for name in names, t in time_steps + quantity = _offer_quantity(dir, container, by_name[name], t) + rows[name, t] = JuMP.@constraint( + jump_model, p[name, t] - quantity * z[name, t] == 0, + ) end end return @@ -427,7 +383,6 @@ priced decision, so no PWL delta variables are built for these devices; VOM is r function _add_block_bid_objective_terms!( container::OptimizationContainer, devices, - cache::OfferWindowCache, ) isempty(devices) && return time_steps = get_time_steps(container) @@ -439,10 +394,10 @@ function _add_block_bid_objective_terms!( z = _get_block_bid_variable(container, dir) sign = IOM._objective_sign(dir) for name in names - window = _window!(cache, dir, container, by_name[name], time_steps) - is_variant = window.time_variant - for (i, t) in enumerate(time_steps) - block_value = _pwl_curve_total(_period(window, i)...) + d = by_name[name] + is_variant = IOM.is_time_variant(get_offer_curves(dir, d)) + for t in time_steps + block_value = _pwl_curve_total(_curve_at(dir, container, d, t)...) iszero(block_value) && continue _add_block_bid_cost_term!( container, @@ -469,60 +424,55 @@ _same_offer(breakpoints_a, slopes_a, breakpoints_b, slopes_b) = isapprox(slopes_a, slopes_b; rtol = 1e-9, atol = 1e-9) """ - _block_runs(window, time_steps) -> Vector{UnitRange{Int}} + _block_runs(dir, container, d, time_steps) -> Vector{UnitRange{Int}} -The blocks of a MULTI_STEP offer side, as ranges of periods: a period with an offered -quantity belongs to a block, and consecutive such periods stay in one block while their -curves are identical. A change of curve or a period with no quantity ends the block. +The blocks of a MULTI_STEP component in direction `dir`, as ranges of periods: a period with +an offered quantity belongs to a block, and consecutive such periods stay in one block while +their curves are identical. A change of curve or a period with no quantity ends the block. Adjacent blocks with the same curve cannot be told apart and count as one. A time-invariant curve is one block over the whole window (or none), with no per-period comparison. """ -function _block_runs(window::OfferWindow, time_steps) +function _block_runs( + dir::IOM.OfferDirection, + container::OptimizationContainer, + d::IS.InfrastructureSystemsComponent, + time_steps, +) runs = UnitRange{Int}[] - isempty(window) && return runs - if !window.time_variant - _offers_quantity(window) && push!(runs, first(time_steps):last(time_steps)) + curve = get_offer_curves(dir, d) + IOM.is_nontrivial_offer(curve) || return runs + if !IOM.is_time_variant(curve) + offers = _offer_quantity(dir, container, d, first(time_steps)) > 0.0 + offers && push!(runs, first(time_steps):last(time_steps)) return runs end start = 0 - for (i, t) in enumerate(time_steps) - if _window_quantity(window, i) <= 0.0 + previous = (Float64[], Float64[]) + for t in time_steps + current = _curve_at(dir, container, d, t) + if maximum(current[1]) <= 0.0 start == 0 || push!(runs, start:(t - 1)) start = 0 continue end - continues = - start != 0 && _same_offer(_period(window, i)..., _period(window, i - 1)...) + continues = start != 0 && _same_offer(current..., previous...) if !continues start == 0 || push!(runs, start:(t - 1)) start = t end + previous = current end start == 0 || push!(runs, start:last(time_steps)) return runs end -"The blocks of `d` in direction `dir`, reading its window once (see the window method)." -function _block_runs( - dir::IOM.OfferDirection, - container::OptimizationContainer, - d::IS.InfrastructureSystemsComponent, - time_steps, -) - return _block_runs(_offer_window(dir, container, d, time_steps), time_steps) -end - """ Link rows for every MULTI_STEP device: `p[d, t] - p[d, t + 1] == 0` between consecutive periods of each block longer than one period, per direction with an offer. The same row serves FIXED and VARIABLE devices; for a FIXED block it is `z[t] = z[t + 1]` through the quantity row, since a block has one quantity. """ -function _add_block_bid_link_rows!( - container::OptimizationContainer, - devices, - cache::OfferWindowCache, -) +function _add_block_bid_link_rows!(container::OptimizationContainer, devices) linked = [d for d in devices if _is_multistep(d)] isempty(linked) && return time_steps = get_time_steps(container) @@ -530,7 +480,7 @@ function _add_block_bid_link_rows!( for dir in (IOM.IncrementalOffer(), IOM.DecrementalOffer()) blocks = Tuple{String, Vector{UnitRange{Int}}}[] for d in linked - runs = _block_runs(_window!(cache, dir, container, d, time_steps), time_steps) + runs = _block_runs(dir, container, d, time_steps) long = UnitRange{Int}[r for r in runs if length(r) > 1] isempty(long) || push!(blocks, (PSY.get_name(d), long)) end @@ -600,7 +550,6 @@ function _add_virtual_location_writes!( ) return end - """ Argument stage for `VirtualBidDispatch`: populates the MBC PWL parameters, creates a per-period `ActivePowerOutVariable`/`ActivePowerInVariable` for every device and adds them @@ -664,8 +613,8 @@ end """ Model stage for `VirtualBidDispatch`: the PWL delta objective for VARIABLE devices (`add_variable_cost!`), the quantity rows and `z`-priced objective for FIXED devices, and -the link rows of MULTI_STEP devices, all reading each offer curve once through one window -cache. No range or budget constraints — bounds are set directly on the variables at creation. +the link rows of MULTI_STEP devices. No range or budget constraints — bounds are set +directly on the variables at creation. """ function construct_market_component!( container::OptimizationContainer, @@ -683,9 +632,8 @@ function construct_market_component!( add_variable_cost!(container, ActivePowerOutVariable, wrapped, VirtualBidDispatch) add_variable_cost!(container, ActivePowerInVariable, wrapped, VirtualBidDispatch) end - cache = OfferWindowCache() - _add_block_bid_quantity_rows!(container, fixed_devices, cache) - _add_block_bid_objective_terms!(container, fixed_devices, cache) - _add_block_bid_link_rows!(container, devices, cache) + _add_block_bid_quantity_rows!(container, fixed_devices) + _add_block_bid_objective_terms!(container, fixed_devices) + _add_block_bid_link_rows!(container, devices) return end From 7d1fe9030be59beea8fc2f9d3ba15db0efd69c43 Mon Sep 17 00:00:00 2001 From: m-bossart Date: Thu, 10 Sep 2026 21:40:38 -0700 Subject: [PATCH 6/6] fix p2p sign bug and shortened horizon bug --- src/common_models/market_bid_plumbing.jl | 22 ++++ src/core/market_formulations.jl | 15 +++ src/market_models/point_to_point_bid.jl | 124 +++++++++++++++++------ test/test_nodal_distribution.jl | 113 ++++++++++++++++++--- 4 files changed, 228 insertions(+), 46 deletions(-) diff --git a/src/common_models/market_bid_plumbing.jl b/src/common_models/market_bid_plumbing.jl index a9a6aa84..4ee88aa8 100644 --- a/src/common_models/market_bid_plumbing.jl +++ b/src/common_models/market_bid_plumbing.jl @@ -120,6 +120,28 @@ get_offer_curves(::IOM.DecrementalOffer, op_cost::PSY.OfferCurveCost) = get_offer_curves(::IOM.IncrementalOffer, op_cost::PSY.OfferCurveCost) = get_output_offer_curves(op_cost) +# `IncrementalBidOffer` reads the same incremental side as `IncrementalOffer`; only the +# objective sign and the expected curvity differ (see the type's docstring). +get_offer_curves( + ::IncrementalBidOffer, + device::OFFER_CURVE_COMPONENTS, +) = get_output_offer_curves(IOM.get_operation_cost(device)) +get_offer_curves(::IncrementalBidOffer, op_cost::PSY.OfferCurveCost) = + get_output_offer_curves(op_cost) + +# The rest of IOM's OfferDirection dispatch table for the bid direction: incremental +# parameter/variable/constraint types, a benefit sign, and the concavity the negative sign +# requires of the curve. +Base.string(::IncrementalBidOffer) = "incremental bid" +IOM._slope_param(::IncrementalBidOffer) = IncrementalPiecewiseLinearSlopeParameter +IOM._breakpoint_param(::IncrementalBidOffer) = IncrementalPiecewiseLinearBreakpointParameter +IOM._block_offer_var(::IncrementalBidOffer) = PiecewiseLinearBlockIncrementalOffer +IOM._block_offer_constraint(::IncrementalBidOffer) = + PiecewiseLinearBlockIncrementalOfferConstraint +IOM._objective_sign(::IncrementalBidOffer) = OBJECTIVE_FUNCTION_NEGATIVE +IOM.curvity_check(::IncrementalBidOffer, x) = IS.is_concave(x) +IOM.expected_curvity(::IncrementalBidOffer) = "concave" + # direction and operating reserve demand curve (ORDC) reserve service: the curve is on the # `variable` field (a CostCurve, static or time-series-backed) rather than split # across incremental/decremental sides. Direction is irrelevant to the lookup; the diff --git a/src/core/market_formulations.jl b/src/core/market_formulations.jl index cf82e053..6e7db440 100644 --- a/src/core/market_formulations.jl +++ b/src/core/market_formulations.jl @@ -7,3 +7,18 @@ balance. The physical balance keeps its `StaticPowerLoad` parameters and gains z settlement equality — not the physical slack — is the binding accounting identity. """ struct SettlementMarket <: IOM.AbstractMarketModel end + +""" +Offer direction for a willingness-to-pay instrument whose curve is authored on the +incremental side. It reads the incremental parameters, variables and constraints exactly as +`IOM.IncrementalOffer` does, but enters the minimized objective with a **negative** sign: +the curve is a benefit, not a cost, so the instrument clears up to the quantity where its +marginal bid price meets the cleared price rather than only when it relieves congestion. + +A spread bid is the motivating case. PSY stores `spread_bid` on the incremental side only, +so the side is fixed by the data contract while the sign is fixed by the economics, and the +two differ. Because the sign is negative, the delta PWL relaxation is exact only for a +non-increasing bid curve, so the expected curvity is concave (a single-segment curve, the +usual shape for a point-to-point bid, satisfies both). +""" +struct IncrementalBidOffer <: IOM.OfferDirection end diff --git a/src/market_models/point_to_point_bid.jl b/src/market_models/point_to_point_bid.jl index 602f7c12..9f8a19f8 100644 --- a/src/market_models/point_to_point_bid.jl +++ b/src/market_models/point_to_point_bid.jl @@ -13,13 +13,49 @@ get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::PSY """ A spread bid prices a single award, so the offer direction is a pure function of the -formulation and the one-argument trait suffices. Incremental is the economically correct -side: the cleared quantity `q` injects at `to` and withdraws at `from`, so the system saving -from a marginal MW is the `to`-minus-`from` price spread, and a convex increasing cost term -`C(q)` clears the bid up to `C'(q) = spread`. That is exactly a willingness-to-pay curve on -the spread, and it matches PSY's "incremental side only" contract for `spread_bid`. +formulation and the one-argument trait suffices. The curve sits on the incremental side, +which is PSY's contract for `spread_bid`, but it is a willingness to pay rather than an +offer to supply: the bid must clear while the cleared `to`-minus-`from` spread is at or +below its bid price, which is the marginal condition of a **benefit** term `- p * q`. +[`IncrementalBidOffer`](@ref) is that pairing — the incremental side with a negative +objective sign. """ -IOM._vom_offer_direction(::Type{SpreadBid}) = IOM.IncrementalOffer() +IOM._vom_offer_direction(::Type{SpreadBid}) = IncrementalBidOffer() + +""" +Route the spread bid's objective term through [`IncrementalBidOffer`](@ref) rather than the +generic incremental path, so the bid price enters the minimized objective as a benefit. +Mirrors the load and storage specializations in `market_bid_overrides.jl`, which pass their +own direction the same way. No demand-side guard is needed here: `_validate_spread_bid!` +has already rejected a decremental curve in the argument stage. +""" +function IOM.add_variable_cost_to_objective!( + container::OptimizationContainer, + ::Type{T}, + component::PSY.PointToPointBid, + cost_function::PSY.OfferCurveCost, + ::Type{SpreadBid}, +) where {T <: VariableType} + @debug "Spread Bid" _group = LOG_GROUP_COST_FUNCTIONS PSY.get_name(component) + IOM.add_pwl_term_delta!( + IncrementalBidOffer(), + container, + component, + cost_function, + T, + SpreadBid, + ) + return +end + +# Curvity is validated per parameter type, and the incremental breakpoint parameter defaults +# to the convexity an incremental *offer* needs. A spread bid is priced with the opposite +# sign, so its curve must be concave (non-increasing bid prices) for the delta PWL +# relaxation to be exact. +IOM.validate_occ_component( + ::Type{<:IncrementalPiecewiseLinearBreakpointParameter}, + device::PSY.PointToPointBid, +) = IOM.validate_occ_breakpoints_slopes(device, IncrementalBidOffer()) """ Reject a `spread_bid` that cannot mean what a spread bid means. @@ -30,8 +66,8 @@ divisible quantity per period, so a block style would be priced here as if divis A willingness-to-pay on the `to`-minus-`from` spread lives on the incremental side (PSY documents `spread_bid` as "incremental side only", and see `_vom_offer_direction` for why that is the economically correct side). A curve authored on the decremental side would -otherwise leave the bid looking unpriced and clearing free, so it is rejected rather than -skipped. Both mirror `VirtualBidDispatch`'s `_validate_block_bid_vom!` idiom. +otherwise leave the bid looking unpriced, so it is rejected rather than silently held at +zero. Both mirror `VirtualBidDispatch`'s `_validate_block_bid_vom!` idiom. """ function _validate_spread_bid!(container::OptimizationContainer, bid::PSY.PointToPointBid) cost = IOM.get_operation_cost(bid) @@ -55,25 +91,45 @@ function _validate_spread_bid!(container::OptimizationContainer, bid::PSY.PointT end """ -Warn on a spread bid whose incremental curve is the `MarketBidCost(nothing)` placeholder (or -a stored-but-inert time series). Such a bid carries no willingness-to-pay, so it contributes -no objective term and clears its full `max_active_power` envelope whenever the spread is -favourable — free optionality that is almost never intended. It is a warning rather than an -error because an unpriced envelope is a representable instrument, but it must not pass -silently. +Is the bid's incremental curve priced over the model horizon? False for the +`MarketBidCost(nothing)` placeholder and for a stored-but-inert time series — including the +common case of a block bid whose priced hours all fall outside a partial-day horizon. """ -function _spread_bid_is_priced( +_spread_bid_is_priced(container::OptimizationContainer, bid::PSY.PointToPointBid) = + IOM.is_nontrivial_offer( + container, + bid, + get_output_offer_curves(IOM.get_operation_cost(bid)), + ) + +""" +Hold an unpriced bid's transfer at zero for the whole horizon. + +An unpriced bid gets no objective term and therefore no piecewise delta variables, so +nothing but the `max_active_power` envelope would bound its `ClearedTransferVariable`: it +would move its full peak MW from source to sink, in every hour, at zero cost. Zeroing the +upper bound is the same bound a priced bid already gets in its own inert hours, where the +piecewise constraint holds it to the curve's top breakpoint of zero. + +One summary warning rather than one per bid: on a partial-day run the unpriced bids number +in the thousands, and a per-bid line is not read. +""" +function _zero_unpriced_transfers!( container::OptimizationContainer, - bid::PSY.PointToPointBid, + bids::Vector{PSY.PointToPointBid}, ) - curve = get_output_offer_curves(IOM.get_operation_cost(bid)) - if !IOM.is_nontrivial_offer(container, bid, curve) - @warn "PointToPointBid $(PSY.get_name(bid)) has no incremental spread_bid curve. " * - "It contributes no objective term and will clear its full " * - "$(PSY.get_max_active_power(bid)) MW envelope up to congestion." - return false + isempty(bids) && return + variable = get_variable(container, ClearedTransferVariable, PSY.PointToPointBid) + for bid in bids, t in get_time_steps(container) + JuMP.set_upper_bound(variable[PSY.get_name(bid), t], 0.0) end - return true + shown = PSY.get_name.(bids[1:min(end, 5)]) + @warn "No incremental spread_bid curve over the model horizon for " * + "$(length(bids)) PointToPointBid(s), $(sum(PSY.get_max_active_power, bids)) MW " * + "of envelope in total. They carry no willingness-to-pay, so their cleared " * + "transfer is held at zero rather than clearing free: " * + "$(join(shown, ", "))$(length(bids) > length(shown) ? ", ..." : "")." + return end """ @@ -150,8 +206,12 @@ end """ Model stage for `SpreadBid`: the willingness-to-pay curve on the `to`-minus-`from` spread (`PSY.get_spread_bid`, reached through `IOM.get_operation_cost`) becomes the bid's objective -term through the standard incremental PWL path, so the bid clears up to the quantity where -its marginal bid price meets the cleared spread instead of clearing free. +term through the incremental PWL path, signed as a benefit ([`IncrementalBidOffer`](@ref)), +so the bid clears up to the quantity where its bid price meets the cleared spread. + +A bid whose curve is inert over the whole horizon has no objective term and so no piecewise +bound; `_zero_unpriced_transfers!` holds it at zero instead of letting it clear its full +envelope free. Two independent quantities bound the award and the tighter one binds: the `max_active_power` envelope, which bounds the variable directly, and the offer curve's own @@ -170,12 +230,14 @@ function construct_market_component!( ::IOM.MarketModel, ::NetworkModel{<:AbstractNetworkModel}, ) - bids = [ - b for b in get_available_components(model, sys) - if _spread_bid_is_priced(container, b) - ] - isempty(bids) && return - wrapped = IS.FlattenIteratorWrapper(PSY.PointToPointBid, [bids]) + priced = PSY.PointToPointBid[] + unpriced = PSY.PointToPointBid[] + for bid in get_available_components(model, sys) + push!(_spread_bid_is_priced(container, bid) ? priced : unpriced, bid) + end + _zero_unpriced_transfers!(container, unpriced) + isempty(priced) && return + wrapped = IS.FlattenIteratorWrapper(PSY.PointToPointBid, [priced]) add_variable_cost!(container, ClearedTransferVariable, wrapped, SpreadBid) return end diff --git a/test/test_nodal_distribution.jl b/test/test_nodal_distribution.jl index 072848e6..be0ad704 100644 --- a/test/test_nodal_distribution.jl +++ b/test/test_nodal_distribution.jl @@ -185,6 +185,15 @@ function _add_hub_and_p2p!(sys, zone; spread_bid = PSY.MarketBidCost(nothing)) return hub, bid end +# A single-segment willingness-to-pay curve at a flat `price` (\$/MWh, natural units) +# across the bid's whole envelope. +_flat_spread_bid(price, max_mw = 50.0) = PSY.MarketBidCost(; + incremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, max_mw], [price]), + PSY.NU, + ), +) + # A spread bid template with both terminals' location models registered. function _p2p_template() template = _ptdf_market_template() @@ -196,7 +205,8 @@ end @testset "SpreadBid: two signed writes, nets to zero across all buses" begin sys, zone, zone_buses = _build_zone_system() - hub, bid = _add_hub_and_p2p!(sys, zone) + # Priced, so the `max_active_power` envelope is what bounds the award. + hub, bid = _add_hub_and_p2p!(sys, zone; spread_bid = _flat_spread_bid(3.0)) template = _ptdf_market_template() set_market_component_model!(template, DeviceModel(PSY.LoadZone, NodalRedistribution)) set_market_component_model!(template, DeviceModel(PSY.TradingHub, NodalRedistribution)) @@ -288,9 +298,10 @@ end @testset "SpreadBid prices its incremental curve into the objective" begin sys, zone, zone_buses = _build_zone_system() - # Two segments with distinct, increasing marginal prices in natural units (\$/MWh), - # spanning the bid's full 50 MW envelope. - slopes = [3.0, 8.0] + # Two segments with distinct, decreasing marginal prices in natural units (\$/MWh), + # spanning the bid's full 50 MW envelope. A willingness-to-pay curve is concave: the + # bid pays less for each additional MW. + slopes = [8.0, 3.0] hub, bid = _add_hub_and_p2p!( sys, zone; spread_bid = PSY.MarketBidCost(; @@ -316,15 +327,15 @@ end @test JuMP.coefficient(objective, q["P2P1", t1]) == 0.0 # Independent reference: a curve authored in natural units prices a per-unit award at - # slope * base_power, per hour of the model resolution. The sign is POSITIVE -- the - # spread bid is an incremental offer, so a higher bid price makes the transfer more - # expensive to clear rather than more attractive. + # slope * base_power, per hour of the model resolution. The sign is NEGATIVE -- the bid + # price is a willingness to pay, so a higher price makes the transfer more attractive + # to clear, and the bid clears while the cleared spread stays at or below it. base_power = IOM.get_model_base_power(container) dt = Dates.value(IOM.get_resolution(container)) / POM.MILLISECONDS_IN_HOUR for t in time_steps, (segment, slope) in enumerate(slopes) @test isapprox( JuMP.coefficient(objective, delta[("P2P1", segment, t)]), - slope * base_power * dt; + -slope * base_power * dt; atol = 1e-9, ) end @@ -396,7 +407,7 @@ end @testset "SpreadBid prices a time-series-backed spread curve identically" begin sys, zone, zone_buses = _build_zone_system() - slopes = [3.0, 8.0] + slopes = [8.0, 3.0] hub, bid = _add_hub_and_p2p!(sys, zone) _promote_spread_bid_to_ts!(sys, bid, [0.0, 20.0, 50.0], slopes) model = DecisionModel(_p2p_template(), sys; optimizer = HiGHS_optimizer) @@ -416,7 +427,7 @@ end for t in time_steps, (segment, slope) in enumerate(slopes) @test isapprox( JuMP.coefficient(objective, delta[("P2P1", segment, t)]), - slope * base_power * dt; + -slope * base_power * dt; atol = 1e-9, ) end @@ -427,24 +438,96 @@ end ) end -@testset "SpreadBid without a curve warns and stays unpriced" begin +# Objective coefficient on a flat-priced bid's single offer segment, per unit per period. +function _flat_price_objective_coefficient(price) + sys, zone, zone_buses = _build_zone_system() + hub, bid = _add_hub_and_p2p!(sys, zone; spread_bid = _flat_spread_bid(price)) + model = DecisionModel(_p2p_template(), sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + container = get_optimization_container(model) + t1 = first(get_time_steps(container)) + delta = IOM.get_variable( + container, PiecewiseLinearBlockIncrementalOffer, PSY.PointToPointBid, + ) + objective = JuMP.objective_function(get_jump_model(container)) + dt = Dates.value(IOM.get_resolution(container)) / POM.MILLISECONDS_IN_HOUR + base_power = IOM.get_model_base_power(container) + return JuMP.coefficient(objective, delta[("P2P1", 1, t1)]) / (base_power * dt) +end + +@testset "SpreadBid enters the objective on the sign of its bid price" begin + # The economic content of the sign. A minimizing model clears the bid while its + # benefit outweighs the congestion it causes, so a willingness to pay must carry a + # negative coefficient and a willingness to be paid a positive one. Under a cost sign + # both invert, and the awards invert with them. + @test isapprox(_flat_price_objective_coefficient(30.0), -30.0; atol = 1e-9) + @test isapprox(_flat_price_objective_coefficient(-30.0), 30.0; atol = 1e-9) +end + +@testset "SpreadBid without a curve warns and clears zero" begin sys, zone, zone_buses = _build_zone_system() hub, bid = _add_hub_and_p2p!(sys, zone) model = DecisionModel(_p2p_template(), sys; optimizer = HiGHS_optimizer) output_dir = mktempdir(; cleanup = true) @test build!(model; output_dir = output_dir) == IOM.ModelBuildStatus.BUILT container = get_optimization_container(model) - t1 = first(get_time_steps(container)) + time_steps = get_time_steps(container) q = IOM.get_variable(container, ClearedTransferVariable, PSY.PointToPointBid) @test JuMP.coefficient(JuMP.objective_function(get_jump_model(container)), - q["P2P1", t1]) == 0.0 + q["P2P1", first(time_steps)]) == 0.0 @test !IOM.has_container_key( container, PiecewiseLinearBlockIncrementalOffer, PSY.PointToPointBid, ) - # Free optionality must be loud. Build warnings land in the build log, not at the + # No willingness-to-pay, no transfer: without the zeroed bound the bid would move its + # full 50 MW envelope in every hour at zero cost. + for t in time_steps + @test JuMP.upper_bound(q["P2P1", t]) == 0.0 + end + # An unpriced envelope must be loud. Build warnings land in the build log, not at the # call site, so assert by reading the file. log = read(joinpath(output_dir, "operation_problem.log"), String) - @test occursin("P2P1 has no incremental spread_bid curve", log) + @test occursin("No incremental spread_bid curve over the model horizon", log) + @test occursin("P2P1", log) +end + +@testset "SpreadBid inert over the horizon clears zero" begin + # A block bid whose priced hours all fall outside the modeled window stores a real + # time series whose every hour has zero span. It must be held at zero exactly as the + # curveless bid is, not left with a free envelope. + sys, zone, zone_buses = _build_zone_system() + hub, bid = _add_hub_and_p2p!(sys, zone) + _promote_spread_bid_to_ts!(sys, bid, [0.0, 0.0], [0.0]) + model = DecisionModel(_p2p_template(), sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.BUILT + container = get_optimization_container(model) + q = IOM.get_variable(container, ClearedTransferVariable, PSY.PointToPointBid) + for t in get_time_steps(container) + @test JuMP.upper_bound(q["P2P1", t]) == 0.0 + end + @test !IOM.has_container_key( + container, PiecewiseLinearBlockIncrementalOffer, PSY.PointToPointBid, + ) +end + +@testset "SpreadBid rejects an increasing bid curve" begin + # A negatively signed curve is only exact in the delta PWL relaxation when it is + # concave: increasing bid prices would let the model buy a high-priced MW without + # first taking the cheaper ones below it. + sys, zone, zone_buses = _build_zone_system() + hub, bid = _add_hub_and_p2p!( + sys, zone; + spread_bid = PSY.MarketBidCost(; + incremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 20.0, 50.0], [3.0, 8.0]), + PSY.NU, + ), + ), + ) + model = DecisionModel(_p2p_template(), sys; optimizer = HiGHS_optimizer) + @test build!(model; output_dir = mktempdir(; cleanup = true)) == + IOM.ModelBuildStatus.FAILED end @testset "SpreadBid rejects block-bid curve styles" begin