diff --git a/Project.toml b/Project.toml index 8b6b7177..07c4c472 100644 --- a/Project.toml +++ b/Project.toml @@ -36,12 +36,17 @@ 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 = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} +# 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. PowerNetworkMatrices = {rev = "psy6", url = "https://github.com/Sienna-Platform/PowerNetworkMatrices.jl"} @@ -50,13 +55,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/docs/Project.toml b/docs/Project.toml index ba0f8fd4..938b326d 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -19,21 +19,25 @@ 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: 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_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" 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..f09ce89c 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 @@ -172,9 +194,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/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/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..b243ce8e 100644 --- a/src/market_models/point_to_point_bid.jl +++ b/src/market_models/point_to_point_bid.jl @@ -13,35 +13,72 @@ 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. -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 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) 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)) @@ -55,25 +92,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 +207,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 @@ -159,8 +220,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, @@ -170,12 +233,47 @@ 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) + _add_spread_bid_link_rows!(container, priced) + 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..615bf971 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,91 @@ _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 period by period +# +# 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 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. +################################################################################# + +"`(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, + t::Int, +) + 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 + +"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, + t::Int, +) + breakpoints, _ = _curve_at(dir, container, d, t) + isempty(breakpoints) && return 0.0 + return Float64(maximum(breakpoints)) +end + +"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) = + 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 +262,34 @@ function _populate_per_period_bid_variable!( return end -function _populate_shared_bid_variable!( +""" +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!( + dir::IOM.OfferDirection, container::OptimizationContainer, - variable, - ::Type{T}, d::PSY.VirtualParticipant, 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 + breakpoints, _ = _curve_at(dir, container, d, t) + (isempty(breakpoints) || maximum(breakpoints) <= 0.0) && continue + segments = _real_segments(breakpoints) + segments == 1 || error( + "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.", + ) 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 +297,56 @@ function _add_block_bid_commitment_variables!( ) isempty(devices) && return time_steps = get_time_steps(container) - names = PSY.get_name.(devices) for dir in (IOM.IncrementalOffer(), IOM.DecrementalOffer()) - meta = _bid_direction_meta(dir) + 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, 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 + _validate_block_bid_segments!(dir, container, d, time_steps) + name = PSY.get_name(d) + for t in time_steps + z = _new_bid_jump_var!(container, BlockBidCommitmentVariable, d, t) + variable[name, t] = z + _offer_quantity(dir, container, d, t) > 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!( - container::OptimizationContainer, - devices, - settlement_expr, - time_steps, -) +function _add_block_bid_quantity_rows!(container::OptimizationContainer, devices) 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, 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 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,15 +375,10 @@ 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, @@ -389,23 +387,17 @@ function _add_block_bid_objective_terms!( 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 name in names + d = by_name[name] + is_variant = IOM.is_time_variant(get_offer_curves(dir, d)) for t in time_steps - block_value = static_value - if is_variant - block_value = _block_bid_curve_value(dir, container, d, t) - end + block_value = _pwl_curve_total(_curve_at(dir, container, d, t)...) iszero(block_value) && continue _add_block_bid_cost_term!( container, @@ -421,18 +413,103 @@ 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(dir, container, d, time_steps) -> Vector{UnitRange{Int}} + +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( + dir::IOM.OfferDirection, + container::OptimizationContainer, + d::IS.InfrastructureSystemsComponent, + time_steps, +) + runs = UnitRange{Int}[] + 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 + 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(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 + +""" +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) + 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(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 + 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, @@ -473,15 +550,13 @@ function _add_virtual_location_writes!( ) return 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,72 +568,52 @@ 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 +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. No range or budget constraints — bounds are set directly on the variables at creation. """ function construct_market_component!( @@ -577,6 +632,8 @@ function construct_market_component!( add_variable_cost!(container, ActivePowerOutVariable, wrapped, VirtualBidDispatch) add_variable_cost!(container, ActivePowerInVariable, wrapped, VirtualBidDispatch) end + _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 diff --git a/test/Project.toml b/test/Project.toml index 768f42d4..8ba9f515 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -48,23 +48,28 @@ 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 = "psy6", url = "https://github.com/Sienna-Platform/PowerSystems.jl"} +# 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 # 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 072848e6..6e92d31e 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 @@ -464,6 +547,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 +683,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)