diff --git a/ext/PowerFlowsExt/PowerFlowsExt.jl b/ext/PowerFlowsExt/PowerFlowsExt.jl index 132e85e..9401806 100644 --- a/ext/PowerFlowsExt/PowerFlowsExt.jl +++ b/ext/PowerFlowsExt/PowerFlowsExt.jl @@ -2,7 +2,7 @@ module PowerFlowsExt using InfrastructureOptimizationModels using PowerFlows -import InfrastructureOptimizationModels: IS, PNM, PSY +import InfrastructureOptimizationModels: IS import InfrastructureOptimizationModels: OptimizationContainerKey, AbstractPowerFlowEvaluationData diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index c9a0957..2c06446 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -106,7 +106,7 @@ import InfrastructureOptimizationModels: get_default_time_series_names, # proportional cost proportional_cost, - is_time_variant_term, + is_time_variant_proportional, add_proportional_cost!, add_proportional_cost_maybe_time_variant!, skip_proportional_cost, @@ -134,7 +134,6 @@ import InfrastructureOptimizationModels: # Market bid cost: import IOM functions that POM extends with device-specific methods import InfrastructureOptimizationModels: - _has_market_bid_cost, _consider_parameter, validate_occ_component, _include_min_gen_power_in_constraint, @@ -143,7 +142,6 @@ import InfrastructureOptimizationModels: _vom_offer_direction, add_pwl_constraint_delta!, add_pwl_term_delta!, - get_output_offer_curves, # Internal utilities used by market bid overrides and proportional_cost is_time_variant, apply_maybe_across_time_series, @@ -154,7 +152,6 @@ import InfrastructureOptimizationModels: has_service_model, IncrementalOffer, DecrementalOffer, - get_input_offer_curves, add_constraint_dual!, assign_dual_variable!, _calculate_dual_variable_value!, @@ -191,6 +188,7 @@ using InfrastructureOptimizationModels # TODO: use explicit imports. ################################################################################# include("core/definitions.jl") include("core/interfaces.jl") +include("core/default_interface_methods.jl") include("core/physical_constant_definitions.jl") include("core/variables.jl") include("core/expressions.jl") @@ -213,6 +211,10 @@ include("common_models/add_parameters.jl") include("common_models/make_system_expressions.jl") include("common_models/reserve_range_constraints.jl") +# Market bid cost plumbing (PSY orchestration moved out of IOM). Must be included +# before device-specific files that reference MBC_TYPES / IEC_TYPES. +include("common_models/market_bid_plumbing.jl") + # Initial Conditions include("initial_conditions/add_initial_condition.jl") include("initial_conditions/device_initial_conditions.jl") @@ -237,7 +239,7 @@ include("static_injector_models/hydrogeneration_constructor.jl") include("energy_storage_models/storage_models.jl") include("energy_storage_models/storage_constructor.jl") -# Market bid cost: device-specific overloads for IOM's generic market_bid.jl +# POM market bid cost overrides (plumbing is included earlier, before device files) include("common_models/market_bid_overrides.jl") # AC Transmission Models diff --git a/src/common_models/add_parameters.jl b/src/common_models/add_parameters.jl index 5ccb0db..0850946 100644 --- a/src/common_models/add_parameters.jl +++ b/src/common_models/add_parameters.jl @@ -247,7 +247,7 @@ function _add_time_series_parameters!( for (name, (arc, reduction)) in PNM.get_name_to_arc_map(net_reduction_data, D) reduction_entry = all_branch_maps_by_type[reduction][D][arc] device_with_time_series = - get_device_with_time_series(reduction_entry, ts_type, ts_name) + IOM.get_branch_with_time_series(reduction_entry, ts_type, ts_name) if device_with_time_series === nothing continue end @@ -307,25 +307,56 @@ end _get_time_series_name(::T, ::PSY.Component, model::DeviceModel) where {T <: ParameterType} = get_time_series_names(model)[T] -_get_time_series_name(::StartupCostParameter, device::PSY.Component, ::DeviceModel) = - IS.get_name(PSY.get_start_up(PSY.get_operation_cost(device))) +# The fact that we're seeing these parameters means that we should +# have a time-varying MBC/IEC, so the `get_time_series_key` call should be valid. -_get_time_series_name(::ShutdownCostParameter, device::PSY.Component, ::DeviceModel) = - IS.get_name(PSY.get_shut_down(PSY.get_operation_cost(device))) +function _get_time_series_name( + ::StartupCostParameter, + device::PSY.Component, + ::DeviceModel, +) + op_cost = PSY.get_operation_cost(device) + IS.@assert_op op_cost isa TS_OFFER_CURVE_COST_TYPES + return IS.get_name(IS.get_time_series_key(PSY.get_start_up(op_cost))) +end + +function _get_time_series_name( + ::ShutdownCostParameter, + device::PSY.Component, + ::DeviceModel, +) + op_cost = PSY.get_operation_cost(device) + IS.@assert_op op_cost isa TS_OFFER_CURVE_COST_TYPES + return IS.get_name(IS.get_time_series_key(PSY.get_shut_down(op_cost))) +end -_get_time_series_name( +function _get_time_series_name( ::IncrementalCostAtMinParameter, device::PSY.Device, ::DeviceModel, -) = - IS.get_name(PSY.get_incremental_initial_input(PSY.get_operation_cost(device))) +) + op_cost = PSY.get_operation_cost(device) + IS.@assert_op op_cost isa TS_OFFER_CURVE_COST_TYPES + return IS.get_name( + IS.get_initial_input( + PSY.get_value_curve(PSY.get_incremental_offer_curves(op_cost)), + ), + ) +end -_get_time_series_name( +function _get_time_series_name( ::DecrementalCostAtMinParameter, device::PSY.Device, ::DeviceModel, -) = - IS.get_name(PSY.get_decremental_initial_input(PSY.get_operation_cost(device))) +) + op_cost = PSY.get_operation_cost(device) + IS.@assert_op op_cost isa TS_OFFER_CURVE_COST_TYPES + return IS.get_name( + IS.get_initial_input( + PSY.get_value_curve(PSY.get_decremental_offer_curves(op_cost)), + ), + ) +end ################################################################################# # _get_expected_time_series_eltype — for ObjectiveFunctionParameter diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index 97f3df4..27ed2ef 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -13,7 +13,49 @@ _has_market_bid_cost(::PSY.RenewableNonDispatch) = false _has_market_bid_cost(::PSY.PowerLoad) = false _has_market_bid_cost(device::PSY.ControllableLoad) = - PSY.get_operation_cost(device) isa PSY.MarketBidCost + PSY.get_operation_cost(device) isa MBC_TYPES + +################################################################################# +# Section 1b: Generic MarketBidCost OnVariable proportional cost +# +# Shared between thermals, hydros, and interruptible loads. The OnVariable cost +# for MBC is the offer curve's `initial_input` (cost at minimum generation). The +# only per-device variation is whether that comes from the incremental side +# (generators) or the decremental side (controllable loads). Direction is set +# by the `_onvar_offer_direction` trait. +################################################################################# + +_onvar_offer_direction(::PSY.Generator) = IncrementalOffer() +_onvar_offer_direction(::PSY.ControllableLoad) = DecrementalOffer() + +_cost_at_min_param(::IncrementalOffer) = IncrementalCostAtMinParameter() +_cost_at_min_param(::DecrementalOffer) = DecrementalCostAtMinParameter() + +# Static MarketBidCost: read initial_input directly from the offer curve. +proportional_cost( + ::OptimizationContainer, + ::PSY.MarketBidCost, + ::Type{OnVariable}, + comp::Union{PSY.Generator, PSY.ControllableLoad}, + ::Type{<:AbstractDeviceFormulation}, + ::Int, +) = IOM.get_initial_input(_onvar_offer_direction(comp), comp) + +# Time-series MarketBidCost: read from parameter container populated by add_parameters!. +function proportional_cost( + container::OptimizationContainer, + ::PSY.MarketBidTimeSeriesCost, + ::Type{OnVariable}, + comp::T, + ::Type{<:AbstractDeviceFormulation}, + t::Int, +) where {T <: Union{PSY.Generator, PSY.ControllableLoad}} + param = _cost_at_min_param(_onvar_offer_direction(comp)) + name = get_name(comp) + param_arr = get_parameter_array(container, param, T) + param_mult = get_parameter_multiplier_array(container, param, T) + return param_arr[name, t] * param_mult[name, t] +end ################################################################################# # Section 2: _consider_parameter — compact commitment startup @@ -32,14 +74,16 @@ _consider_parameter( # Section 3: Device-specific validate_occ_component ################################################################################# -# ThermalMultiStart: accept NTuple{3, Float64} and StartUpStages without warning -function validate_occ_component( +# ThermalMultiStart: accept NTuple{3, Float64} and PSY.StartUpStages without warning +function IOM.validate_occ_component( ::Type{StartupCostParameter}, device::PSY.ThermalMultiStart, ) startup = PSY.get_start_up(PSY.get_operation_cost(device)) + # TupleTimeSeries{PSY.StartUpStages} guarantees NTuple{3, Float64} values at construction + startup isa IS.TupleTimeSeries && return _validate_eltype( - Union{Float64, NTuple{3, Float64}, StartUpStages}, + Union{Float64, NTuple{3, Float64}, PSY.StartUpStages}, device, startup, " startup cost", @@ -48,55 +92,55 @@ end # Renewable / Storage: warn on nonzero startup, shutdown, and no-load costs -function validate_occ_component( +function IOM.validate_occ_component( ::Type{StartupCostParameter}, device::Union{PSY.RenewableDispatch, PSY.Storage}, ) startup = PSY.get_start_up(PSY.get_operation_cost(device)) apply_maybe_across_time_series(device, startup) do x - if x != PSY.single_start_up_to_stages(0.0) + # x may be Float64 (TGC), PSY.StartUpStages (static MBC), or NTuple{3, Float64} + # (TupleTimeSeries elements). `values` normalizes both NamedTuple and Tuple. + if any(!iszero, x isa Number ? (x,) : values(x)) @warn "Nonzero startup cost detected for renewable generation or storage device $(get_name(device))." end end end -function validate_occ_component( +# LinearCurve (static) and TimeSeriesLinearCurve (TS) are the only types carried in +# MBC/ImportExportCost shutdown and no-load fields. Only the static case is meaningfully +# comparable to zero at validation time — for TS we'd need to iterate the series, which +# the time-series store may not even have populated yet. +# FIXME better solution? +_scalar_if_static(x::IS.LinearCurve) = IS.get_proportional_term(x) +_scalar_if_static(::IS.TimeSeriesLinearCurve) = nothing + +function IOM.validate_occ_component( ::Type{ShutdownCostParameter}, device::Union{PSY.RenewableDispatch, PSY.Storage}, ) - shutdown = PSY.get_shut_down(PSY.get_operation_cost(device)) - apply_maybe_across_time_series(device, shutdown) do x - if x != 0.0 - @warn "Nonzero shutdown cost detected for renewable generation or storage device $(get_name(device))." - end + x = _scalar_if_static(PSY.get_shut_down(PSY.get_operation_cost(device))) + if !isnothing(x) && x != 0.0 + @warn "Nonzero shutdown cost detected for renewable generation or storage device $(get_name(device))." end end -function validate_occ_component( +function IOM.validate_occ_component( ::Type{IncrementalCostAtMinParameter}, device::Union{PSY.RenewableDispatch, PSY.Storage}, ) - no_load_cost = PSY.get_no_load_cost(PSY.get_operation_cost(device)) - if !isnothing(no_load_cost) - apply_maybe_across_time_series(device, no_load_cost) do x - if x != 0.0 - @warn "Nonzero no-load cost detected for renewable generation or storage device $(get_name(device))." - end - end + x = _scalar_if_static(PSY.get_no_load_cost(PSY.get_operation_cost(device))) + if !isnothing(x) && x != 0.0 + @warn "Nonzero no-load cost detected for renewable generation or storage device $(get_name(device))." end end -function validate_occ_component( +function IOM.validate_occ_component( ::Type{DecrementalCostAtMinParameter}, device::PSY.Storage, ) - no_load_cost = PSY.get_no_load_cost(PSY.get_operation_cost(device)) - if !isnothing(no_load_cost) - apply_maybe_across_time_series(device, no_load_cost) do x - if x != 0.0 - @warn "Nonzero no-load cost detected for storage device $(get_name(device))." - end - end + x = _scalar_if_static(PSY.get_no_load_cost(PSY.get_operation_cost(device))) + if !isnothing(x) && x != 0.0 + @warn "Nonzero no-load cost detected for storage device $(get_name(device))." end end @@ -167,11 +211,16 @@ _include_constant_min_gen_power_in_constraint( # Section 6: Source ImportExport — both incremental and decremental offers ################################################################################# +# FIXME behavior change: we now always add PWL terms for both import and export. The +# previous `isnothing(...)` guard is dead in the new PSY (offer curves default to +# `ZERO_OFFER_CURVE`, not nothing), and we don't yet have a way to introspect TS-backed +# curves to decide "trivially empty". Skipping when the curve is trivial (one-directional +# source) would be the better behavior — revisit once we have a cheap emptiness check. function add_variable_cost_to_objective!( container::OptimizationContainer, ::Type{ActivePowerOutVariable}, component::PSY.Source, - cost_function::PSY.ImportExportCost, + cost_function::IEC_TYPES, ::Type{ImportExportSourceModel}, ) isnothing(get_output_offer_curves(cost_function)) && return @@ -190,7 +239,7 @@ function add_variable_cost_to_objective!( container::OptimizationContainer, ::Type{ActivePowerInVariable}, component::PSY.Source, - cost_function::PSY.ImportExportCost, + cost_function::IEC_TYPES, ::Type{ImportExportSourceModel}, ) isnothing(get_input_offer_curves(cost_function)) && return @@ -218,8 +267,12 @@ function add_variable_cost_to_objective!( ) where {T <: VariableType, U <: AbstractControllablePowerLoadFormulation} component_name = PSY.get_name(component) @debug "Market Bid" _group = LOG_GROUP_COST_FUNCTIONS component_name - if !(isnothing(get_output_offer_curves(cost_function))) - error("Component $(component_name) is not allowed to participate as a supply.") + if IOM.is_nontrivial_offer(get_output_offer_curves(cost_function)) + throw( + ArgumentError( + "Component $(component_name) is not allowed to participate as a supply.", + ), + ) end add_pwl_term_delta!( DecrementalOffer(), diff --git a/src/common_models/market_bid_plumbing.jl b/src/common_models/market_bid_plumbing.jl new file mode 100644 index 0000000..7829f02 --- /dev/null +++ b/src/common_models/market_bid_plumbing.jl @@ -0,0 +1,600 @@ +################################################################################# +# Market Bid / Import-Export Cost Plumbing +# +# PSY-specific plumbing moved out of IOM's `objective_function/value_curve_cost.jl`. +# Responsibilities: +# * Accessor wrappers that resolve MBC / IEC offer curves (static + time-series). +# * Cost detection predicates (_has_market_bid_cost / _has_import_export_cost). +# * Parameter-field dispatch tables over PSY getter functions. +# * Component-level validation (validate_occ_component, curvity checks). +# * Parameter processing orchestration (process_market_bid_parameters!, +# process_import_export_parameters!). +# * Static PWL data retrieval (_get_raw_pwl_data for CostCurve{PiecewiseIncrementalCurve}). +# * The static add_pwl_term_delta! / add_variable_cost_to_objective! / VOM cost path. +# +# IOM owns the generic OfferDirection dispatch table, _consider_parameter, the +# TS-backed add_variable_cost_to_objective! path, and the delta PWL primitives +# (add_pwl_variables_delta!, add_pwl_constraint_delta!, get_pwl_cost_expression_delta). +################################################################################# + +################################################################################# +# Union aliases for MBC / IEC / TS offer curve cost types +################################################################################# + +const MBC_TYPES = Union{PSY.MarketBidCost, PSY.MarketBidTimeSeriesCost} +const IEC_TYPES = Union{PSY.ImportExportCost, PSY.ImportExportTimeSeriesCost} +const TS_OFFER_CURVE_COST_TYPES = + Union{PSY.MarketBidTimeSeriesCost, PSY.ImportExportTimeSeriesCost} + +################################################################################# +# Section 1: Offer Curve Accessor Wrappers +# Map PSY cost types (MarketBidCost, ImportExportCost) to a unified interface. +################################################################################# + +####################### get_{output/input}_offer_curves ######################### +# 1-argument getters: straight getfield calls (same PSY getter for static and TS variants) +get_output_offer_curves(cost::IEC_TYPES) = PSY.get_import_offer_curves(cost) +get_output_offer_curves(cost::MBC_TYPES) = PSY.get_incremental_offer_curves(cost) +get_input_offer_curves(cost::IEC_TYPES) = PSY.get_export_offer_curves(cost) +get_input_offer_curves(cost::MBC_TYPES) = PSY.get_decremental_offer_curves(cost) + +# 2-argument getters: resolve time series if needed, return static curve(s). +# Static types: delegate to 1-arg getter (no resolution needed). +get_output_offer_curves( + ::IS.InfrastructureSystemsComponent, + cost::PSY.ImportExportCost; + kwargs..., +) = PSY.get_import_offer_curves(cost) +get_output_offer_curves( + ::IS.InfrastructureSystemsComponent, + cost::PSY.MarketBidCost; + kwargs..., +) = PSY.get_incremental_offer_curves(cost) +get_input_offer_curves( + ::IS.InfrastructureSystemsComponent, + cost::PSY.ImportExportCost; + kwargs..., +) = PSY.get_export_offer_curves(cost) +get_input_offer_curves( + ::IS.InfrastructureSystemsComponent, + cost::PSY.MarketBidCost; + kwargs..., +) = PSY.get_decremental_offer_curves(cost) +# TS types: resolve via PSY's 2-arg getters. +get_output_offer_curves( + component::IS.InfrastructureSystemsComponent, + cost::PSY.ImportExportTimeSeriesCost; + kwargs..., +) = PSY.get_import_variable_cost(component, cost; kwargs...) +get_output_offer_curves( + component::IS.InfrastructureSystemsComponent, + cost::PSY.MarketBidTimeSeriesCost; + kwargs..., +) = PSY.get_incremental_variable_cost(component, cost; kwargs...) +get_input_offer_curves( + component::IS.InfrastructureSystemsComponent, + cost::PSY.ImportExportTimeSeriesCost; + kwargs..., +) = PSY.get_export_variable_cost(component, cost; kwargs...) +get_input_offer_curves( + component::IS.InfrastructureSystemsComponent, + cost::PSY.MarketBidTimeSeriesCost; + kwargs..., +) = PSY.get_decremental_variable_cost(component, cost; kwargs...) + +######################### get_offer_curves(direction, ...) ############################## + +# direction and device: +get_offer_curves(::IOM.DecrementalOffer, device::PSY.StaticInjection) = + get_input_offer_curves(PSY.get_operation_cost(device)) +get_offer_curves(::IOM.IncrementalOffer, device::PSY.StaticInjection) = + get_output_offer_curves(PSY.get_operation_cost(device)) +IOM.get_initial_input(::IOM.DecrementalOffer, device::PSY.StaticInjection) = + IS.get_initial_input( + IS.get_value_curve(get_input_offer_curves(PSY.get_operation_cost(device))), + ) +IOM.get_initial_input(::IOM.IncrementalOffer, device::PSY.StaticInjection) = + IS.get_initial_input( + IS.get_value_curve(get_output_offer_curves(PSY.get_operation_cost(device))), + ) + +# direction and cost curve (needed for VOM code path): +get_offer_curves(::IOM.DecrementalOffer, op_cost::PSY.OfferCurveCost) = + get_input_offer_curves(op_cost) +get_offer_curves(::IOM.IncrementalOffer, op_cost::PSY.OfferCurveCost) = + get_output_offer_curves(op_cost) + +################################################################################# +# Section 3: _get_parameter_field Dispatch Table +# Maps parameter types to PSY getter functions. +################################################################################# + +IOM._get_parameter_field(::Type{<:StartupCostParameter}, op_cost) = + PSY.get_start_up(op_cost) +IOM._get_parameter_field(::Type{<:ShutdownCostParameter}, op_cost) = + PSY.get_shut_down(op_cost) +IOM._get_parameter_field(::Type{<:IncrementalCostAtMinParameter}, op_cost) = + IS.get_initial_input(IS.get_value_curve(get_output_offer_curves(op_cost))) +IOM._get_parameter_field(::Type{<:DecrementalCostAtMinParameter}, op_cost) = + IS.get_initial_input(IS.get_value_curve(get_input_offer_curves(op_cost))) +IOM._get_parameter_field( + ::Type{ + <:Union{ + IncrementalPiecewiseLinearSlopeParameter, + IncrementalPiecewiseLinearBreakpointParameter, + }, + }, + op_cost, +) = get_output_offer_curves(op_cost) +IOM._get_parameter_field( + ::Type{ + <:Union{ + DecrementalPiecewiseLinearSlopeParameter, + DecrementalPiecewiseLinearBreakpointParameter, + }, + }, + op_cost, +) = get_input_offer_curves(op_cost) + +################################################################################# +# Section 4: Device Cost Detection Predicates (generic) +################################################################################# + +_has_market_bid_cost(device::PSY.StaticInjection) = + _has_market_bid_cost(PSY.get_operation_cost(device)) +_has_market_bid_cost(::MBC_TYPES) = true +_has_market_bid_cost(::PSY.OperationalCost) = false + +_has_import_export_cost(::PSY.StaticInjection) = false +_has_import_export_cost(device::PSY.Source) = + _has_import_export_cost(PSY.get_operation_cost(device)) +_has_import_export_cost(::IEC_TYPES) = true +_has_import_export_cost(::PSY.OperationalCost) = false + +_has_offer_curve_cost(device::IS.InfrastructureSystemsComponent) = + _has_market_bid_cost(device) || _has_import_export_cost(device) + +# With the static/TS type split, time-series parameters are determined by cost type: +# TS cost types always have time-series parameters; static types never do. +_has_parameter_time_series(device::PSY.StaticInjection) = + _has_parameter_time_series(PSY.get_operation_cost(device)) + +_has_parameter_time_series(::TS_OFFER_CURVE_COST_TYPES) = true +_has_parameter_time_series(::PSY.OperationalCost) = false + +# Mirrors IOM's TS-cost predicate so validate_occ_component can short-circuit on TS types. +IOM._is_time_series_cost(::PSY.MarketBidTimeSeriesCost) = true + +# MBC / IEC cleanly split static vs TS by type, so `is_time_variant_proportional` is a flat +# type dispatch — no instance lookup (unlike FuelCurve-backed ThermalGenerationCost). +IOM.is_time_variant_proportional(::PSY.MarketBidCost) = false +IOM.is_time_variant_proportional(::PSY.MarketBidTimeSeriesCost) = true +IOM.is_time_variant_proportional(::PSY.ImportExportCost) = false +IOM.is_time_variant_proportional(::PSY.ImportExportTimeSeriesCost) = true + +################################################################################# +# Section 6: Validation +################################################################################# + +function IOM.validate_occ_breakpoints_slopes( + device::PSY.StaticInjection, + dir::IOM.OfferDirection, +) + offer_curves = get_offer_curves(dir, device) + _validate_occ_curves(device, dir, offer_curves) +end + +# Static: validate convexity/concavity and cost-type-specific constraints +function _validate_occ_curves( + device::PSY.StaticInjection, + dir::IOM.OfferDirection, + cost_curve::IS.CostCurve{IS.PiecewiseIncrementalCurve}, +) + device_name = IS.get_name(device) + cost_curve_name = nameof(typeof(PSY.get_operation_cost(device))) + IOM.curvity_check(dir, cost_curve) || + throw( + ArgumentError( + "$(uppercasefirst(string(dir))) $cost_curve_name for component $(device_name) is non-$(IOM.expected_curvity(dir))", + ), + ) + _validate_occ_subtype(PSY.get_operation_cost(device), dir, cost_curve, device_name) +end + +# TS-backed: validated at parameter population time, not here +_validate_occ_curves(::PSY.StaticInjection, ::IOM.OfferDirection, + ::IS.CostCurve{IS.TimeSeriesPiecewiseIncrementalCurve}) = nothing + +_validate_occ_subtype(::PSY.MarketBidCost, ::IOM.OfferDirection, ::IS.CostCurve, args...) = + nothing + +function _validate_occ_subtype( + ::PSY.ImportExportCost, + ::IOM.OfferDirection, + curve::IS.CostCurve, + args..., +) + !iszero(IS.get_vom_cost(curve)) && throw( + ArgumentError( + "For ImportExportCost, VOM cost must be zero.", + ), + ) + !iszero(IS.get_initial_input(curve)) && throw( + ArgumentError( + "For ImportExportCost, initial input must be zero.", + ), + ) + fd = IS.get_function_data(IS.get_value_curve(curve)) + if !iszero(first(IS.get_x_coords(fd))) + throw( + ArgumentError( + "For ImportExportCost, the first breakpoint must be zero.", + ), + ) + end +end + +function IOM.validate_occ_component( + ::Type{<:StartupCostParameter}, + device::PSY.StaticInjection, +) + op_cost = PSY.get_operation_cost(device) + # TS types are validated at parameter population time + IOM._is_time_series_cost(op_cost) && return + startup = PSY.get_start_up(op_cost) + if startup isa Union{NTuple{3, Float64}, PSY.StartUpStages} + @warn "Multi-start costs detected for non-multi-start unit $(IS.get_name(device)), will take the maximum" + elseif !(startup isa Float64) + throw( + ArgumentError( + "Expected Float64, NTuple{3, Float64}, or StartUpStages startup cost but got $(typeof(startup)) for $(IS.get_name(device))", + ), + ) + end + return +end + +function IOM.validate_occ_component( + ::Type{<:ShutdownCostParameter}, + device::PSY.StaticInjection, +) + op_cost = PSY.get_operation_cost(device) + # TS types are validated at parameter population time + IOM._is_time_series_cost(op_cost) && return + # Static MBC: shut_down is LinearCurve; ThermalGenerationCost: shut_down is Float64 + shutdown = PSY.get_shut_down(op_cost) + if shutdown isa IS.LinearCurve + return # valid + elseif shutdown isa Float64 + return # valid (e.g. ThermalGenerationCost) + else + throw( + ArgumentError( + "Expected Float64 or LinearCurve shutdown cost but got $(typeof(shutdown)) for $(IS.get_name(device))", + ), + ) + end +end + +# Consistency of initial_input vs offer curves is guaranteed by the static/TS type split +IOM.validate_occ_component(::Type{<:AbstractCostAtMinParameter}, ::PSY.StaticInjection) = + nothing + +IOM.validate_occ_component( + ::Type{<:IncrementalPiecewiseLinearBreakpointParameter}, + device::PSY.StaticInjection, +) = IOM.validate_occ_breakpoints_slopes(device, IOM.IncrementalOffer()) + +IOM.validate_occ_component( + ::Type{<:DecrementalPiecewiseLinearBreakpointParameter}, + device::PSY.StaticInjection, +) = IOM.validate_occ_breakpoints_slopes(device, IOM.DecrementalOffer()) + +# Slope and breakpoint validations are done together, nothing to do here +IOM.validate_occ_component( + ::Type{<:AbstractPiecewiseLinearSlopeParameter}, + device::PSY.StaticInjection, +) = nothing + +################################################################################# +# Section 7: Parameter Processing Orchestration +################################################################################# + +function _process_occ_parameters_helper( + ::Type{P}, + container::OptimizationContainer, + model, + devices, +) where {P <: ParameterType} + for device in devices + IOM.validate_occ_component(P, device) + end + if IOM._consider_parameter(P, container, model) + ts_devices = + filter(device -> _has_parameter_time_series(device), devices) + (length(ts_devices) > 0) && add_parameters!(container, P, ts_devices, model) + end +end + +"Validate ImportExportCosts and add the appropriate parameters" +function process_import_export_parameters!( + container::OptimizationContainer, + devices_in, + model::DeviceModel, +) + devices = [d for d in devices_in if _has_import_export_cost(d)] + + for param in ( + IncrementalPiecewiseLinearSlopeParameter, + IncrementalPiecewiseLinearBreakpointParameter, + DecrementalPiecewiseLinearSlopeParameter, + DecrementalPiecewiseLinearBreakpointParameter, + ) + _process_occ_parameters_helper(param, container, model, devices) + end +end + +"Validate MarketBidCosts and add the appropriate parameters" +function process_market_bid_parameters!( + container::OptimizationContainer, + devices_in, + model::DeviceModel, + incremental::Bool = true, + decremental::Bool = false, +) + devices = [d for d in devices_in if _has_market_bid_cost(d)] + isempty(devices) && return + + for param in ( + StartupCostParameter, + ShutdownCostParameter, + ) + _process_occ_parameters_helper(param, container, model, devices) + end + if incremental + for param in ( + IncrementalCostAtMinParameter, + IncrementalPiecewiseLinearSlopeParameter, + IncrementalPiecewiseLinearBreakpointParameter, + ) + _process_occ_parameters_helper(param, container, model, devices) + end + end + if decremental + for param in ( + DecrementalCostAtMinParameter, + DecrementalPiecewiseLinearSlopeParameter, + DecrementalPiecewiseLinearBreakpointParameter, + ) + _process_occ_parameters_helper(param, container, model, devices) + end + end +end + +################################################################################# +# Section 10: Static-curve PWL Data Retrieval +# (The TS-curve branch lives in IOM because it only uses IS types.) +################################################################################# + +function IOM._get_pwl_data( + dir::IOM.OfferDirection, + container::OptimizationContainer, + component::T, + time::Int, +) where {T <: IS.InfrastructureSystemsComponent} + name = IS.get_name(component) + cost_data = get_offer_curves(dir, component) + breakpoint_cost_component, slope_cost_component, unit_system = + IOM._get_raw_pwl_data(dir, container, T, name, cost_data, time) + + breakpoints, slopes = IOM.get_piecewise_curve_per_system_unit( + breakpoint_cost_component, + slope_cost_component, + unit_system, + get_model_base_power(container), + PSY.get_base_power(component), + ) + return breakpoints, slopes +end + +# static curve: read directly from the cost curve +function IOM._get_raw_pwl_data( + ::IOM.OfferDirection, + ::OptimizationContainer, + ::Type{<:IS.InfrastructureSystemsComponent}, + ::String, + cost_data::IS.CostCurve{IS.PiecewiseIncrementalCurve}, + ::Int, +) + cost_component = IS.get_function_data(IS.get_value_curve(cost_data)) + return IS.get_x_coords(cost_component), + IS.get_y_coords(cost_component), + IS.get_power_units(cost_data) +end + +################################################################################# +# Section 11: Static PSY.OfferCurveCost objective entry points +################################################################################# + +""" +Add PWL objective terms using the **delta (incremental/block-offer) formulation** for +static (non-time-series-backed) PSY.OfferCurveCost cost functions. +""" +function IOM.add_pwl_term_delta!( + dir::IOM.OfferDirection, + container::OptimizationContainer, + component::T, + ::PSY.OfferCurveCost, + ::Type{U}, + ::Type{V}, +) where { + T <: IS.InfrastructureSystemsComponent, + U <: VariableType, + V <: AbstractDeviceFormulation, +} + W = IOM._block_offer_var(dir) + X = IOM._block_offer_constraint(dir) + + name = IS.get_name(component) + resolution = get_resolution(container) + dt = Dates.value(resolution) / MILLISECONDS_IN_HOUR + time_steps = get_time_steps(container) + is_variant = IOM.is_time_variant(get_offer_curves(dir, component)) + # Static offer curves are time-invariant: compute breakpoints/slopes once. + static_breakpoints, static_slopes = if is_variant + (Float64[], Float64[]) + else + IOM._get_pwl_data(dir, container, component, first(time_steps)) + end + for t in time_steps + breakpoints, slopes = if is_variant + IOM._get_pwl_data(dir, container, component, t) + else + (static_breakpoints, static_slopes) + end + pwl_vars = + add_pwl_variables_delta!( + container, + W, + T, + name, + t, + length(slopes); + upper_bound = Inf, + ) + add_pwl_constraint_delta!( + container, + component, + U, + V, + breakpoints, + pwl_vars, + t, + X, + ) + pwl_cost = + get_pwl_cost_expression_delta(pwl_vars, slopes, IOM._objective_sign(dir) * dt) + + add_cost_to_expression!( + container, + ProductionCostExpression, + pwl_cost, + T, + name, + t, + ) + + if is_variant + IOM.add_to_objective_variant_expression!(container, pwl_cost) + else + IOM.add_to_objective_invariant_expression!(container, pwl_cost) + end + end +end + +function IOM.add_variable_cost_to_objective!( + container::OptimizationContainer, + ::Type{T}, + component::IS.InfrastructureSystemsComponent, + cost_function::PSY.OfferCurveCost, + ::Type{U}, +) where {T <: VariableType, U <: AbstractDeviceFormulation} + component_name = IS.get_name(component) + @debug "Market Bid" _group = LOG_GROUP_COST_FUNCTIONS component_name + if IOM.is_nontrivial_offer(get_input_offer_curves(cost_function)) + throw( + ArgumentError( + "Component $(component_name) is not allowed to participate as a demand.", + ), + ) + end + IOM.add_pwl_term_delta!( + IOM.IncrementalOffer(), + container, + component, + cost_function, + T, + U, + ) + return +end + +# Default: most formulations use incremental offers +IOM._vom_offer_direction(::Type{<:AbstractDeviceFormulation}) = IOM.IncrementalOffer() + +function IOM._add_vom_cost_to_objective!( + container::OptimizationContainer, + ::Type{T}, + component::IS.InfrastructureSystemsComponent, + op_cost::PSY.OfferCurveCost, + ::Type{U}, +) where {T <: VariableType, U <: AbstractDeviceFormulation} + dir = IOM._vom_offer_direction(U) + cost_curves = get_offer_curves(dir, op_cost) + if IOM.is_time_variant(cost_curves) + @warn "$(typeof(dir)) curves are time variant, there is no VOM cost source. Skipping VOM cost." + return + end + _add_vom_cost_to_objective_helper!( + container, T, component, op_cost, cost_curves, U) + return +end + +function _add_vom_cost_to_objective_helper!( + container::OptimizationContainer, + ::Type{T}, + component::IS.InfrastructureSystemsComponent, + ::PSY.OfferCurveCost, + cost_data::IS.CostCurve{IS.PiecewiseIncrementalCurve}, + ::Type{U}, +) where {T <: VariableType, U <: AbstractDeviceFormulation} + power_units = IS.get_power_units(cost_data) + cost_term = IS.get_proportional_term(IS.get_vom_cost(cost_data)) + IOM.add_proportional_cost_invariant!(container, T, component, cost_term, power_units) + return +end + +################################################################################# +# Section 12: IOM extension-point bridges +# IOM declares `get_base_power`, `get_operation_cost`, etc. as abstract stubs so +# it doesn't depend on PowerSystems. POM provides the methods that forward to +# the corresponding PSY getters for PSY component and cost types. +################################################################################# + +IOM.get_base_power(sys::PSY.System) = PSY.get_base_power(sys) +IOM.get_base_power(c::PSY.Component) = PSY.get_base_power(c) +IOM.get_operation_cost(c::PSY.Component) = PSY.get_operation_cost(c) +IOM.get_must_run(c::PSY.Component) = PSY.get_must_run(c) +IOM.get_active_power_limits(c::PSY.Component) = PSY.get_active_power_limits(c) +IOM.get_max_active_power(c::PSY.Component) = PSY.get_max_active_power(c) +IOM.get_ramp_limits(c::PSY.Component) = PSY.get_ramp_limits(c) +IOM.get_start_up(op_cost) = PSY.get_start_up(op_cost) +IOM.get_shut_down(op_cost) = PSY.get_shut_down(op_cost) +IOM.get_dc_bus(c::PSY.Component) = PSY.get_dc_bus(c) +IOM.get_bustype(c::PSY.ACBus) = PSY.get_bustype(c) +IOM.has_service(c::PSY.Component, args...) = PSY.has_service(c, args...) +IOM.set_units_base_system!(sys::PSY.System, base) = PSY.set_units_base_system!(sys, base) + +# PSY.System override for unit-system / forecast-initial-timestamp adapters that +# IOM uses in init_optimization_container! +IOM.temp_set_units_base_system!(sys::PSY.System, base::String) = + PSY.set_units_base_system!(sys, base) +IOM.temp_get_forecast_initial_timestamp(sys::PSY.System) = + PSY.get_forecast_initial_timestamp(sys) + +# PSY.System override for things in decision_model.jl +# most just forward to sys.data. + +#= +IOM.stores_time_series_in_memory(sys::PSY.System) = PSY.stores_time_series_in_memory(sys) +IOM.get_time_series_counts_by_type(sys::PSY.System) = PSY.get_time_series_counts_by_type(sys) +IOM.get_time_series_counts(sys::PSY.System) = PSY.get_time_series_counts(sys) +IOM.get_forecast_interval(sys::PSY.System) = PSY.get_forecast_interval(sys) +IOM.get_time_series_resolutions(sys::PSY.System) = PSY.get_time_series_resolutions(sys) +IOM.get_forecast_horizon(sys::PSY.System) = PSY.get_forecast_horizon(sys) + +IOM.get_uuid(sys::PSY.System) = PSY.get_uuid(sys) +=# + +# PSY cost-type dispatches for variable-cost and get_variable_cost: +IOM.get_variable_cost(cost) = PSY.get_variable(cost) diff --git a/src/core/default_interface_methods.jl b/src/core/default_interface_methods.jl index a63c3a0..f602ed1 100644 --- a/src/core/default_interface_methods.jl +++ b/src/core/default_interface_methods.jl @@ -2,7 +2,8 @@ get_variable_key(variabletype, d) = error("Not Implemented") #! format: off -# FIXME: do we need these? We define a default method in IOM too. +# Defaults for the OCC `ObjectiveFunctionParameter` types. Needed because POM's catch-all +# in `core/interfaces.jl` errors for any parameter type that isn't a `TimeSeriesParameter`. get_multiplier_value(::Type{StartupCostParameter}, ::PSY.Device, ::Type{<:AbstractDeviceFormulation}) = 1.0 get_multiplier_value(::Type{ShutdownCostParameter}, ::PSY.Device, ::Type{<:AbstractDeviceFormulation}) = 1.0 get_multiplier_value(::Type{<:AbstractCostAtMinParameter}, ::PSY.Device, ::Type{<:AbstractDeviceFormulation}) = 1.0 @@ -13,8 +14,6 @@ get_multiplier_value(::Type{<:AbstractPiecewiseLinearBreakpointParameter}, ::PSY get_expression_type_for_reserve(_, y::Type{<:PSY.Component}, z) = error("`get_expression_type_for_reserve` must be implemented for $y and $z") -requires_initialization(::AbstractDeviceFormulation) = false - does_subcomponent_exist(T::PSY.Component, S::Type{<:PSY.Component}) = error("`does_subcomponent_exist` must be implemented for $T and subcomponent type $S") diff --git a/src/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index ee1cafe..2630d76 100644 --- a/src/energy_storage_models/storage_models.jl +++ b/src/energy_storage_models/storage_models.jl @@ -852,7 +852,7 @@ function add_energybalance_with_reserves!( ) for ic in initial_conditions - device = get_component(ic) + device = IOM.get_component(ic) efficiency = PSY.get_efficiency(device) name = PSY.get_name(device) constraint[name, 1] = JuMP.@constraint( @@ -913,7 +913,7 @@ function add_energybalance_without_reserves!( ) for ic in initial_conditions - device = get_component(ic) + device = IOM.get_component(ic) efficiency = PSY.get_efficiency(device) name = PSY.get_name(device) constraint[name, 1] = JuMP.@constraint( @@ -1055,7 +1055,7 @@ function add_constraints!( services_set = Set() for ic in initial_conditions - storage = get_component(ic) + storage = IOM.get_component(ic) union!(services_set, PSY.get_services(storage)) end @@ -1079,7 +1079,7 @@ function add_constraints!( end for ic in initial_conditions - storage = get_component(ic) + storage = IOM.get_component(ic) ci_name = PSY.get_name(storage) inv_efficiency = 1.0 / PSY.get_efficiency(storage).out eff_in = PSY.get_efficiency(storage).in @@ -1196,7 +1196,7 @@ function add_constraints!( services_set = Set() for ic in initial_conditions - storage = get_component(ic) + storage = IOM.get_component(ic) union!(services_set, PSY.get_services(storage)) end @@ -1221,7 +1221,7 @@ function add_constraints!( end for ic in initial_conditions - storage = get_component(ic) + storage = IOM.get_component(ic) ci_name = PSY.get_name(storage) inv_efficiency = 1.0 / PSY.get_efficiency(storage).out eff_in = PSY.get_efficiency(storage).in @@ -1869,7 +1869,7 @@ function _add_variable_cost_to_objective!( container::OptimizationContainer, ::T, component::PSY.Component, - cost_function::PSY.MarketBidCost, + cost_function::MBC_TYPES, ::U, ) where { T <: Union{ActivePowerOutVariable, StorageRegularizationVariableDischarge}, @@ -1895,7 +1895,7 @@ function _add_variable_cost_to_objective!( container::OptimizationContainer, ::T, component::PSY.Component, - cost_function::PSY.MarketBidCost, + cost_function::MBC_TYPES, ::U, ) where { T <: Union{ActivePowerInVariable, StorageRegularizationVariableCharge}, diff --git a/src/network_models/pm_translator.jl b/src/network_models/pm_translator.jl index 2ba1ae6..09f999e 100644 --- a/src/network_models/pm_translator.jl +++ b/src/network_models/pm_translator.jl @@ -1,3 +1,20 @@ +function check_hvdc_line_limits_unidirectional(d::PSY.TwoTerminalHVDC) + from_min = PSY.get_active_power_limits_from(d).min + to_min = PSY.get_active_power_limits_to(d).min + from_max = PSY.get_active_power_limits_from(d).max + to_max = PSY.get_active_power_limits_to(d).max + + if from_min < 0 || to_min < 0 || from_max < 0 || to_max < 0 + throw( + IS.ConflictingInputsError( + "Changing flow direction on HVDC Line $(PSY.get_name(d)) is not compatible with non-linear network formulations. \ + Bi-directional models with losses are only compatible with linear network models like DCPPowerModel.", + ), + ) + end + return +end + const PM_MAP_TUPLE = NamedTuple{(:from_to, :to_from), Tuple{Tuple{Int, Int, Int}, Tuple{Int, Int, Int}}} diff --git a/src/operation/template_validation.jl b/src/operation/template_validation.jl index 7f6a003..00fbe63 100644 --- a/src/operation/template_validation.jl +++ b/src/operation/template_validation.jl @@ -1,3 +1,5 @@ +const _TEMPLATE_VALIDATION_EXCLUSIONS = [PSY.Arc, PSY.Area, PSY.ACBus, PSY.LoadZone] + function validate_template_impl!(model::IOM.OperationModel) template = get_template(model) settings = get_settings(model) @@ -8,7 +10,7 @@ function validate_template_impl!(model::IOM.OperationModel) modeled_types = IOM.get_component_types(template) system_component_types = PSY.get_existing_component_types(system) network_model = get_network_model(template) - valid_device_types = union(modeled_types, IOM._TEMPLATE_VALIDATION_EXCLUSIONS) + valid_device_types = union(modeled_types, _TEMPLATE_VALIDATION_EXCLUSIONS) unmodeled_branch_types = DataType[] for m in setdiff(system_component_types, valid_device_types) diff --git a/src/static_injector_models/electric_loads.jl b/src/static_injector_models/electric_loads.jl index c8c779b..70441b3 100644 --- a/src/static_injector_models/electric_loads.jl +++ b/src/static_injector_models/electric_loads.jl @@ -228,42 +228,9 @@ function onvar_cost( return _onvar_cost(container, PSY.get_variable(cost), d, t) end -is_time_variant_term( - ::OptimizationContainer, - ::PSY.LoadCost, - ::Type{OnVariable}, - ::Type{<:PSY.ControllableLoad}, - ::Type{<:AbstractLoadFormulation}, - ::Int, -) = false - -is_time_variant_term( - ::OptimizationContainer, - cost::PSY.MarketBidCost, - ::Type{OnVariable}, - ::Type{<:PSY.ControllableLoad}, - ::Type{PowerLoadInterruption}, - ::Int, -) = - is_time_variant(PSY.get_decremental_initial_input(cost)) +# LoadCost has no FuelCurve-backed `_onvar_cost` path; the OnVariable proportional +# term's rate (vom_constant + fixed + onvar_cost) is always static here. +IOM.is_time_variant_proportional(::PSY.LoadCost) = false -function proportional_cost( - container::OptimizationContainer, - cost::PSY.MarketBidCost, - ::Type{OnVariable}, - comp::T, - ::Type{PowerLoadInterruption}, - t::Int, -) where {T <: PSY.ControllableLoad} - if is_time_variant(PSY.get_decremental_initial_input(cost)) - name = get_name(comp) - param_arr = get_parameter_array(container, DecrementalCostAtMinParameter, T) - param_mult = - get_parameter_multiplier_array(container, DecrementalCostAtMinParameter, T) - return param_arr[name, t] * param_mult[name, t] - else - return PSY.get_initial_input( - PSY.get_decremental_offer_curves(PSY.get_operation_cost(comp)), - ) - end -end +# MarketBidCost (static + time-series) proportional_cost/is_time_variant_proportional are generic — +# see common_models/market_bid_overrides.jl. diff --git a/src/static_injector_models/hydro_generation.jl b/src/static_injector_models/hydro_generation.jl index 31886f7..a51884f 100644 --- a/src/static_injector_models/hydro_generation.jl +++ b/src/static_injector_models/hydro_generation.jl @@ -826,7 +826,7 @@ function add_constraints!( get_parameter_multiplier_array(container, InflowTimeSeriesParameter, V) for ic in initial_conditions - device = get_component(ic) + device = IOM.get_component(ic) name = PSY.get_name(device) param = get_parameter_column_values(param_container, name) if get_use_slacks(model) @@ -1542,7 +1542,7 @@ function add_constraints!( ) for ic in initial_conditions - d = get_component(ic) + d = IOM.get_component(ic) name = PSY.get_name(d) inflow = get_parameter_column_refs(param_container, name) @@ -2220,67 +2220,23 @@ proportional_cost( ) where {U <: OnVariable, V <: AbstractHydroUnitCommitment} = proportional_cost(cost, U, comp, V) -# copy-paste from PSI, just with types changed (HydroFoo => ThermalFoo): -is_time_variant_term( - ::OptimizationContainer, - ::PSY.HydroGenerationCost, - ::Type{OnVariable}, - ::Type{<:PSY.HydroGen}, - ::Type{<:AbstractHydroFormulation}, - t::Int, -) = false +# HydroGenerationCost uses CostCurves (no FuelCurve path), so the OnVariable +# proportional term's rate is always static — `is_time_variant` on the variable +# curve would be answering a broader question than this trait asks. +IOM.is_time_variant_proportional(::PSY.HydroGenerationCost) = false -function add_proportional_cost!( +skip_proportional_cost(d::PSY.HydroPumpTurbine) = PSY.get_must_run(d) + +add_proportional_cost!( container::OptimizationContainer, ::Type{U}, devices::IS.FlattenIteratorWrapper{T}, ::Type{V}, -) where {T <: PSY.HydroGen, U <: OnVariable, V <: AbstractHydroUnitCommitment} - multiplier = objective_function_multiplier(U, V) - for d in devices - op_cost_data = PSY.get_operation_cost(d) - for t in get_time_steps(container) - cost_term = proportional_cost(container, op_cost_data, U, d, V, t) - add_as_time_variant = - is_time_variant_term(container, op_cost_data, U, T, V, t) - iszero(cost_term) && continue - cost_term *= multiplier - exp = if d isa PSY.HydroPumpTurbine && PSY.get_must_run(d) - cost_term # note we do not add this to the objective function - else - _add_proportional_term_maybe_variant!( - Val(add_as_time_variant), container, U, d, cost_term, t) - end - add_to_expression!(container, ProductionCostExpression, exp, d, t) - end - end - return -end - -proportional_cost( - container::OptimizationContainer, - cost::PSY.MarketBidCost, - ::Type{OnVariable}, - comp::PSY.HydroGen, - ::Type{<:AbstractHydroUnitCommitment}, - t::Int, -) = - _lookup_maybe_time_variant_param(container, comp, t, - Val(is_time_variant(PSY.get_incremental_initial_input(cost))), - PSY.get_initial_input ∘ PSY.get_incremental_offer_curves ∘ PSY.get_operation_cost, - IncrementalCostAtMinParameter()) - -is_time_variant_term( - ::OptimizationContainer, - cost::PSY.MarketBidCost, - ::Type{OnVariable}, - ::Type{<:PSY.HydroGen}, - ::Type{<:AbstractHydroUnitCommitment}, - t::Int, -) = - is_time_variant(PSY.get_incremental_initial_input(cost)) +) where {U <: OnVariable, T <: PSY.HydroGen, V <: AbstractHydroUnitCommitment} = + add_proportional_cost_maybe_time_variant!(container, U, devices, V) -# end copy-paste +# MarketBidCost (static + time-series) proportional_cost/is_time_variant_proportional are generic — +# see common_models/market_bid_overrides.jl. # These _include_{constant}_min_gen_power functions are needed for MarketBidCost. # Commitment has an on/off choice, so add OnVariable * breakpoint1 to power constraint. diff --git a/src/static_injector_models/thermal_generation.jl b/src/static_injector_models/thermal_generation.jl index 85ca712..4bee2e8 100644 --- a/src/static_injector_models/thermal_generation.jl +++ b/src/static_injector_models/thermal_generation.jl @@ -1,3 +1,27 @@ +function create_temporary_cost_function_in_system_per_unit( + original_cost_function::PSY.CostCurve, + new_data::PSY.PiecewiseLinearData, +) + return PSY.CostCurve( + PSY.PiecewisePointCurve(new_data), + PSY.UnitSystem.SYSTEM_BASE, + PSY.get_vom_cost(original_cost_function), + ) +end + +function create_temporary_cost_function_in_system_per_unit( + original_cost_function::PSY.FuelCurve, + new_data::PSY.PiecewiseLinearData, +) + return PSY.FuelCurve( + PSY.PiecewisePointCurve(new_data), + PSY.UnitSystem.SYSTEM_BASE, + PSY.get_fuel_cost(original_cost_function), + IS.LinearCurve(0.0), # setting fuel offtake cost to default value of 0 + PSY.get_vom_cost(original_cost_function), + ) +end + #! format: off requires_initialization(::AbstractThermalFormulation) = false @@ -94,26 +118,28 @@ initial_condition_variable(::InitialTimeDurationOff, d::PSY.ThermalGen, ::Abstra function proportional_cost(container::OptimizationContainer, cost::PSY.ThermalGenerationCost, S::Type{OnVariable}, T::PSY.ThermalGen, U::Type{<:AbstractThermalFormulation}, t::Int) return onvar_cost(container, cost, S, T, U, t) + PSY.get_constant_term(PSY.get_vom_cost(PSY.get_variable(cost))) + PSY.get_fixed(cost) end +# Is the OnVariable proportional term's *rate* time-varying? For ThermalGenerationCost +# that rate is `onvar_cost + vom_constant + fixed`; only `onvar_cost` can vary, and +# only for FuelCurve{Linear/Quadratic} (static or TS), where it equals +# `constant_term * fuel_cost_at_t`. PWL FuelCurves have `onvar_cost ≡ 0`, and +# CostCurves have no `_onvar_cost` overload — both statically invariant here. +IOM.is_time_variant_proportional(cost::PSY.ThermalGenerationCost) = + _onvar_is_time_variant(PSY.get_variable(cost)) +_onvar_is_time_variant(::PSY.ProductionVariableCostCurve) = false +_onvar_is_time_variant( + curve::PSY.FuelCurve{<:Union{ + PSY.LinearCurve, PSY.QuadraticCurve, + PSY.TimeSeriesLinearCurve, PSY.TimeSeriesQuadraticCurve, + }}, +) = IS.is_time_series_backed(curve) -is_time_variant_term(::OptimizationContainer, ::PSY.ThermalGenerationCost, ::Type{OnVariable}, ::Type{<:PSY.ThermalGen}, ::Type{<:AbstractThermalFormulation}, t::Int) = false +IOM.uses_commitment_variables(::Type{<:PSY.ThermalGen}) = true -function proportional_cost(container::OptimizationContainer, cost::PSY.MarketBidCost, ::Type{OnVariable}, comp::T, ::Type{<:AbstractThermalFormulation}, t::Int) where {T <: PSY.ThermalGen} - if is_time_variant(PSY.get_incremental_initial_input(cost)) - name = get_name(comp) - # inelegant: an iterator wrapping either param_array[name, :] .* param_mult[name, :] - # (load values lazily) or repeat(constant_value) would be closer to what we want. - param_arr = get_parameter_array(container, IncrementalCostAtMinParameter, T) - param_mult = get_parameter_multiplier_array(container, IncrementalCostAtMinParameter, T) - return param_arr[name, t] * param_mult[name, t] - else - return PSY.get_initial_input(PSY.get_incremental_offer_curves(PSY.get_operation_cost(comp))) - end -end -is_time_variant_term(::OptimizationContainer, cost::PSY.MarketBidCost, ::Type{OnVariable}, ::Type{<:PSY.ThermalGen}, ::Type{<:AbstractThermalFormulation}, t::Int) = - is_time_variant(PSY.get_incremental_initial_input(cost)) +# MarketBidCost (static + time-series) proportional_cost/is_time_variant_proportional are generic — +# see common_models/market_bid_overrides.jl. -proportional_cost(::Union{PSY.MarketBidCost, PSY.ThermalGenerationCost}, ::Type{<:Union{RateofChangeConstraintSlackUp, RateofChangeConstraintSlackDown}}, ::PSY.ThermalGen, ::Type{<:AbstractThermalFormulation}) = CONSTRAINT_VIOLATION_SLACK_COST +proportional_cost(::Union{MBC_TYPES, PSY.ThermalGenerationCost}, ::Type{<:Union{RateofChangeConstraintSlackUp, RateofChangeConstraintSlackDown}}, ::PSY.ThermalGen, ::Type{<:AbstractThermalFormulation}) = CONSTRAINT_VIOLATION_SLACK_COST has_multistart_variables(::PSY.ThermalGen, ::AbstractThermalFormulation)=false @@ -128,7 +154,7 @@ start_up_cost(cost, ::Type{<:PSY.ThermalGen}, ::Type{T}, ::Type{<:Union{Abstract start_up_cost(cost, ::Type{<:PSY.ThermalMultiStart}, ::Type{T}, ::Type{ThermalMultiStartUnitCommitment} = ThermalMultiStartUnitCommitment) where {T <: MultiStartVariable} = start_up_cost(cost, T) -# Implementations: given a single number, tuple, or StartUpStages and a variable, do the right thing +# Implementations: given a single number, tuple, or PSY.StartUpStages and a variable, do the right thing # Single number to anything start_up_cost(cost::Float64, ::Type{StartVariable}) = cost # TODO in the case where we have a single number startup cost and we're modeling a multi-start, do we set all the values to that number? @@ -137,14 +163,14 @@ start_up_cost(cost::Float64, ::Type{T}) where {T <: MultiStartVariable} = # 3-tuple to anything start_up_cost(cost::NTuple{3, Float64}, ::Type{T}) where {T <: VariableType} = - start_up_cost(StartUpStages(cost), T) + start_up_cost(PSY.StartUpStages(cost), T) -# `StartUpStages` to anything -start_up_cost(cost::StartUpStages, ::Type{ColdStartVariable}) = cost.cold -start_up_cost(cost::StartUpStages, ::Type{WarmStartVariable}) = cost.warm -start_up_cost(cost::StartUpStages, ::Type{HotStartVariable}) = cost.hot +# `PSY.StartUpStages` to anything +start_up_cost(cost::PSY.StartUpStages, ::Type{ColdStartVariable}) = cost.cold +start_up_cost(cost::PSY.StartUpStages, ::Type{WarmStartVariable}) = cost.warm +start_up_cost(cost::PSY.StartUpStages, ::Type{HotStartVariable}) = cost.hot # TODO in the opposite case, do we want to get the maximum or the hot? -start_up_cost(cost::StartUpStages, ::Type{StartVariable}) = maximum(cost) +start_up_cost(cost::PSY.StartUpStages, ::Type{StartVariable}) = maximum(cost) uses_compact_power(::PSY.ThermalGen, ::AbstractThermalFormulation)=false uses_compact_power(::PSY.ThermalGen, ::AbstractCompactUnitCommitment )=true @@ -452,8 +478,8 @@ function _get_data_for_range_ic( ini_conds = Matrix{InitialCondition}(undef, lenght_devices_power, 2) idx = 0 for (ix, ic) in enumerate(initial_conditions_power) - g = get_component(ic) - IS.@assert_op g == get_component(initial_conditions_status[ix]) + g = IOM.get_component(ic) + IS.@assert_op g == IOM.get_component(initial_conditions_status[ix]) idx += 1 ini_conds[idx, 1] = ic ini_conds[idx, 2] = initial_conditions_status[ix] @@ -703,8 +729,8 @@ function add_constraints!( ) for (ix, ic) in enumerate(ini_conds[:, 1]) - name = get_component_name(ic) - device = get_component(ic) + name = IOM.get_component_name(ic) + device = IOM.get_component(ic) limits = PSY.get_active_power_limits(device) lag_ramp_limits = PSY.get_power_trajectory(device) val = max(limits.max - lag_ramp_limits.shutdown, 0) @@ -769,8 +795,8 @@ function add_constraints!( ) for ic in initial_conditions - name = PSY.get_name(PSY.get_component(ic)) - if !PSY.get_must_run(PSY.get_component(ic)) + name = IOM.get_component_name(ic) + if !PSY.get_must_run(IOM.get_component(ic)) constraint[name, 1] = JuMP.@constraint( get_jump_model(container), varon[name, 1] == get_value(ic) + varstart[name, 1] - varstop[name, 1] @@ -783,10 +809,10 @@ function add_constraints!( end for ic in initial_conditions - if PSY.get_must_run(PSY.get_component(ic)) + if PSY.get_must_run(IOM.get_component(ic)) continue else - name = get_component_name(ic) + name = IOM.get_component_name(ic) for t in time_steps[2:end] constraint[name, t] = JuMP.@constraint( get_jump_model(container), @@ -877,7 +903,7 @@ function calculate_aux_variable_value!( if isnothing(get_value(ini_cond[ix])) sum_on_var = time_steps[end] else - on_var_name = get_component_name(ini_cond[ix]) + on_var_name = IOM.get_component_name(ini_cond[ix]) ini_cond_value = get_condition(ini_cond[ix]) # On Var doesn't exist for a unit that has must_run = true on_var = jump_value.(on_variable_output[on_var_name, :]) @@ -922,7 +948,7 @@ function calculate_aux_variable_value!( if isnothing(get_value(ini_cond[ix])) sum_on_var = 0.0 else - on_var_name = get_component_name(ini_cond[ix]) + on_var_name = IOM.get_component_name(ini_cond[ix]) # On Var doesn't exist for a unit that has must run on_var = jump_value.(on_variable_output[on_var_name, :]) ini_cond_value = get_condition(ini_cond[ix]) @@ -1090,14 +1116,14 @@ end ########################### start up trajectory constraints ###################################### function _convert_hours_to_timesteps( - start_times_hr::StartUpStages, + start_times_hr::PSY.StartUpStages, resolution::Dates.TimePeriod, ) _start_times_ts = ( round((hr * MINUTES_IN_HOUR) / Dates.value(Dates.Minute(resolution)), RoundUp) for hr in start_times_hr ) - start_times_ts = StartUpStages(_start_times_ts) + start_times_ts = PSY.StartUpStages(_start_times_ts) return start_times_ts end @@ -1246,7 +1272,7 @@ function add_constraints!( get_initial_condition(container, InitialTimeDurationOff(), PSY.ThermalMultiStart) time_steps = get_time_steps(container) - device_name_set = [get_component_name(ic) for ic in initial_conditions_offtime] + device_name_set = [IOM.get_component_name(ic) for ic in initial_conditions_offtime] varbin = get_variable(container, OnVariable, T) varstarts = [ get_variable(container, HotStartVariable, T), @@ -1271,10 +1297,10 @@ function add_constraints!( ) for t in time_steps, (ix, ic) in enumerate(initial_conditions_offtime) - name = PSY.get_name(PSY.get_component(ic)) - startup_types = PSY.get_start_types(PSY.get_component(ic)) + name = IOM.get_component_name(ic) + startup_types = PSY.get_start_types(IOM.get_component(ic)) time_limits = _convert_hours_to_timesteps( - PSY.get_start_time_limits(get_component(ic)), + PSY.get_start_time_limits(IOM.get_component(ic)), resolution, ) ic = initial_conditions_offtime[ix] @@ -1321,8 +1347,8 @@ function _get_data_for_tdc( ini_conds = Matrix{InitialCondition}(undef, lenght_devices_on, 2) idx = 0 for (ix, ic) in enumerate(initial_conditions_on) - g = get_component(ic) - IS.@assert_op g == get_component(initial_conditions_off[ix]) + g = IOM.get_component(ic) + IS.@assert_op g == IOM.get_component(initial_conditions_off[ix]) time_limits = PSY.get_time_limits(g) name = PSY.get_name(g) if time_limits !== nothing @@ -1600,7 +1626,7 @@ function IOM.add_pwl_term_lambda!( break_points = PSY.get_x_coords(data) sos_val = IOM._get_sos_value(container, V, component) temp_cost_function = - IOM.create_temporary_cost_function_in_system_per_unit(cost_function, data) + create_temporary_cost_function_in_system_per_unit(cost_function, data) for t in time_steps IOM.add_pwl_variables_lambda!(container, T, name, t, data) power_var = IOM.get_variable(container, U, T)[name, t] @@ -1625,3 +1651,33 @@ function IOM.add_pwl_term_lambda!( end return pwl_cost_expressions end + +# ThermalGen range-constraint specialization: checks must_run to decide whether to use binary variable. +# Overrides the generic IS.InfrastructureSystemsComponent version in IOM. +function IOM._add_semicontinuous_bound_range_constraints_impl!( + container::OptimizationContainer, + ::Type{T}, + dir::IOM.BoundDirection, + array, + devices::Union{Vector{V}, IS.FlattenIteratorWrapper{V}}, + ::DeviceModel{V, W}, +) where {T <: ConstraintType, V <: PSY.ThermalGen, W <: AbstractDeviceFormulation} + time_steps = IOM.get_time_steps(container) + names = IS.get_name.(devices) + jump_model = IOM.get_jump_model(container) + con = IOM.add_constraints_container!( + container, T, V, names, time_steps; meta = IOM.constraint_meta(dir)) + varbin = IOM.get_variable(container, OnVariable, V) + + for device in devices + ci_name = IS.get_name(device) + limits = IOM.get_min_max_limits(device, T, W) + for t in time_steps + bin = PSY.get_must_run(device) ? 1.0 : varbin[ci_name, t] + IOM.add_range_bound_constraint!( + dir, jump_model, con, ci_name, t, + array[ci_name, t], IOM.get_bound(dir, limits), bin) + end + end + return +end diff --git a/src/twoterminal_hvdc_models/TwoTerminalDC_branches.jl b/src/twoterminal_hvdc_models/TwoTerminalDC_branches.jl index e9b24d4..5422b6f 100644 --- a/src/twoterminal_hvdc_models/TwoTerminalDC_branches.jl +++ b/src/twoterminal_hvdc_models/TwoTerminalDC_branches.jl @@ -1,3 +1,27 @@ +function check_hvdc_line_limits_consistency( + d::Union{PSY.TwoTerminalHVDC, PSY.TModelHVDCLine}, +) + from_min = PSY.get_active_power_limits_from(d).min + to_min = PSY.get_active_power_limits_to(d).min + from_max = PSY.get_active_power_limits_from(d).max + to_max = PSY.get_active_power_limits_to(d).max + + if from_max < to_min + throw( + IS.ConflictingInputsError( + "From Max $(from_max) can't be a smaller value than To Min $(to_min)", + ), + ) + elseif to_max < from_min + throw( + IS.ConflictingInputsError( + "To Max $(to_max) can't be a smaller value than From Min $(from_min)", + ), + ) + end + return +end + #################################### Branch Variables ################################################## #! format: off get_variable_binary(::Type{FlowActivePowerSlackUpperBound}, ::Type{<:PSY.TwoTerminalHVDC}, ::Type{<:AbstractTwoTerminalDCLineFormulation}) = false diff --git a/test/Project.toml b/test/Project.toml index c6f7fa6..12187fc 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -31,6 +31,11 @@ TimeSeries = "9e3dc215-6440-5c97-bce1-76c03772f85e" TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +[sources] +InfrastructureOptimizationModels = {path = "../../InfrastructureOptimizationModels.jl"} +InfrastructureSystems = {path = "../../InfrastructureSystems.jl"} +PowerSystems = {path = "../../PowerSystems.jl"} + [compat] HiGHS = "1" Ipopt = "=1.4.0" diff --git a/test/includes.jl b/test/includes.jl index 53b87fa..eb05508 100644 --- a/test/includes.jl +++ b/test/includes.jl @@ -54,6 +54,7 @@ include("test_utils/add_components_to_system.jl") include("test_utils/add_dlr_ts.jl") include("test_utils/add_market_bid_cost.jl") include("test_utils/mbc_system_utils.jl") +include("test_utils/mbc_math_helpers.jl") include("test_utils/iec_test_systems.jl") include("test_utils/hydro_testing_utils.jl") diff --git a/test/test_device_hydro_constructors.jl b/test/test_device_hydro_constructors.jl index a04fdf6..2f918fa 100644 --- a/test/test_device_hydro_constructors.jl +++ b/test/test_device_hydro_constructors.jl @@ -185,6 +185,7 @@ end end end +# currently broken due to PSB lagging behind. @testset "Test Reserves from Hydro with RunOfRiver" begin template = OperationsProblemTemplate(CopperPlatePowerModel) set_device_model!(template, PowerLoad, StaticPowerLoad) diff --git a/test/test_device_load_constructors.jl b/test/test_device_load_constructors.jl index 38309fa..c14453f 100644 --- a/test/test_device_load_constructors.jl +++ b/test/test_device_load_constructors.jl @@ -71,9 +71,9 @@ end set_operation_cost!( iloadbus4, MarketBidCost(; - no_load_cost = 0.0, + no_load_cost = LinearCurve(0.0), start_up = (hot = 0.0, warm = 0.0, cold = 0.0), - shut_down = 0.0, + shut_down = LinearCurve(0.0), incremental_offer_curves = make_market_bid_curve( [0.0, 100.0, 200.0, 300.0, 400.0, 500.0, 600.0], [25.0, 25.5, 26.0, 27.0, 28.0, 30.0], @@ -95,9 +95,9 @@ end set_operation_cost!( iloadbus4, MarketBidCost(; - no_load_cost = 0.0, + no_load_cost = LinearCurve(0.0), start_up = (hot = 0.0, warm = 0.0, cold = 0.0), - shut_down = 0.0, + shut_down = LinearCurve(0.0), decremental_offer_curves = make_market_bid_curve( [0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0], [90.0, 85.0, 75.0, 70.0, 60.0, 50.0, 45.0, 40.0, 30.0, 25.0], diff --git a/test/test_import_export_cost.jl b/test/test_import_export_cost.jl new file mode 100644 index 0000000..28cfd4c --- /dev/null +++ b/test/test_import_export_cost.jl @@ -0,0 +1,165 @@ +""" +Unit tests for POM's ImportExportCost objective-function construction. + +Same conventions as `test_market_bid_cost.jl`: system base == device base == 100, curves +in `SYSTEM_BASE` power units, hourly resolution, one time step — so translated slopes +arrive at the objective unchanged. Scaling is covered by its own testset. + +Sign convention for a Source with `ImportExportSourceModel`: +- Import (`ActivePowerOutVariable`, `IncrementalOffer`) → `OBJECTIVE_FUNCTION_POSITIVE`. +- Export (`ActivePowerInVariable`, `DecrementalOffer`) → `OBJECTIVE_FUNCTION_NEGATIVE`. +""" + +const _SOURCE_NAME = "source1" + +_static_iec(import_xs, import_ys, export_xs, export_ys) = PSY.ImportExportCost(; + import_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, import_xs, import_ys), + PSY.UnitSystem.SYSTEM_BASE, + ), + export_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, export_xs, export_ys), + PSY.UnitSystem.SYSTEM_BASE, + ), +) + +@testset "Source + ImportExportSourceModel + static IEC" begin + # Distinct slopes for import vs export so a swap is visible. + cost = _static_iec( + [0.0, 0.25, 1.0], [2.0, 5.0], # import side + [0.0, 0.40, 0.9], [4.0, 8.0], # export side — distinct breakpoints & slopes + ) + sys = one_bus_one_source(cost; name = _SOURCE_NAME) + source = PSY.get_component(PSY.Source, sys, _SOURCE_NAME) + + container = build_test_container(sys, 1:1) + add_jump_var!(container, IOM.ActivePowerOutVariable, PSY.Source, _SOURCE_NAME, 1) + add_jump_var!(container, IOM.ActivePowerInVariable, PSY.Source, _SOURCE_NAME, 1) + + POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerOutVariable, source, cost, POM.ImportExportSourceModel, + ) + POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerInVariable, source, cost, POM.ImportExportSourceModel, + ) + + # Import side: IncrementalOffer sign = +1. + @test pwl_delta_coefs( + container, IOM.IncrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [2.0, 5.0] + @test pwl_delta_widths( + container, IOM.IncrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [0.25, 0.75] + # Export side: DecrementalOffer sign = -1, distinct breakpoints. + @test pwl_delta_coefs( + container, IOM.DecrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [-4.0, -8.0] + @test pwl_delta_widths( + container, IOM.DecrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [0.4, 0.5] +end + +@testset "Source + ImportExportSourceModel: dt and unit conversion" begin + # NATURAL_UNITS + 15-minute resolution. Slope scaling: y × sys_base × dt. + # Break scaling: x / sys_base. + cost = PSY.ImportExportCost(; + import_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 200.0], [6.0]), + PSY.UnitSystem.NATURAL_UNITS, + ), + export_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 200.0], [9.0]), + PSY.UnitSystem.NATURAL_UNITS, + ), + ) + sys = one_bus_one_source(cost; name = _SOURCE_NAME) + source = PSY.get_component(PSY.Source, sys, _SOURCE_NAME) + + container = build_test_container(sys, 1:1; resolution = Dates.Minute(15)) + add_jump_var!(container, IOM.ActivePowerOutVariable, PSY.Source, _SOURCE_NAME, 1) + add_jump_var!(container, IOM.ActivePowerInVariable, PSY.Source, _SOURCE_NAME, 1) + + POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerOutVariable, source, cost, POM.ImportExportSourceModel, + ) + POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerInVariable, source, cost, POM.ImportExportSourceModel, + ) + + # Import slope coefficient = +(6 × 100) × 0.25 = +150. + @test pwl_delta_coefs( + container, IOM.IncrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [150.0] + # Export slope coefficient = -(9 × 100) × 0.25 = -225. + @test pwl_delta_coefs( + container, IOM.DecrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [-225.0] + # Breakpoint widths = 200 / 100 = 2.0 for both directions. + @test pwl_delta_widths( + container, IOM.IncrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [2.0] + @test pwl_delta_widths( + container, IOM.DecrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [2.0] +end + +@testset "Source + ImportExportSourceModel + TS IEC" begin + cost = stub_ts_import_export_cost() + sys = one_bus_one_source(cost; name = _SOURCE_NAME) + source = PSY.get_component(PSY.Source, sys, _SOURCE_NAME) + + container = build_test_container(sys, 1:2) + for t in 1:2 + add_jump_var!(container, IOM.ActivePowerOutVariable, PSY.Source, _SOURCE_NAME, t) + add_jump_var!(container, IOM.ActivePowerInVariable, PSY.Source, _SOURCE_NAME, t) + end + + # Distinct values per direction AND per time step. + setup_delta_pwl_parameters!( + container, PSY.Source, [_SOURCE_NAME], + reshape([[2.0, 5.0], [11.0, 15.0]], 1, 2), + reshape([[0.0, 0.25, 1.0], [0.0, 0.35, 0.8]], 1, 2), + 1:2; + dir = IOM.IncrementalOffer()) + setup_delta_pwl_parameters!( + container, PSY.Source, [_SOURCE_NAME], + reshape([[4.0, 8.0], [14.0, 18.0]], 1, 2), + reshape([[0.0, 0.40, 0.9], [0.0, 0.50, 0.8]], 1, 2), + 1:2; + dir = IOM.DecrementalOffer()) + + POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerOutVariable, source, cost, POM.ImportExportSourceModel, + ) + POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerInVariable, source, cost, POM.ImportExportSourceModel, + ) + + variant = IOM.get_variant_terms(IOM.get_objective_expression(container)) + incr_pwl = IOM.get_variable( + container, IOM.PiecewiseLinearBlockIncrementalOffer, PSY.Source) + decr_pwl = IOM.get_variable( + container, IOM.PiecewiseLinearBlockDecrementalOffer, PSY.Source) + + @test [JuMP.coefficient(variant, incr_pwl[(_SOURCE_NAME, s, 1)]) for s in 1:2] ≈ + [2.0, 5.0] + @test [JuMP.coefficient(variant, incr_pwl[(_SOURCE_NAME, s, 2)]) for s in 1:2] ≈ + [11.0, 15.0] + @test [JuMP.coefficient(variant, decr_pwl[(_SOURCE_NAME, s, 1)]) for s in 1:2] ≈ + [-4.0, -8.0] + @test [JuMP.coefficient(variant, decr_pwl[(_SOURCE_NAME, s, 2)]) for s in 1:2] ≈ + [-14.0, -18.0] + + @test pwl_delta_widths( + container, IOM.IncrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [0.25, 0.75] + @test pwl_delta_widths( + container, IOM.IncrementalOffer(), PSY.Source, _SOURCE_NAME, 2, + ) ≈ [0.35, 0.45] + @test pwl_delta_widths( + container, IOM.DecrementalOffer(), PSY.Source, _SOURCE_NAME, 1, + ) ≈ [0.4, 0.5] + @test pwl_delta_widths( + container, IOM.DecrementalOffer(), PSY.Source, _SOURCE_NAME, 2, + ) ≈ [0.5, 0.3] +end diff --git a/test/test_is_time_variant_proportional.jl b/test/test_is_time_variant_proportional.jl new file mode 100644 index 0000000..81f425d --- /dev/null +++ b/test/test_is_time_variant_proportional.jl @@ -0,0 +1,94 @@ +""" +Unit tests for `is_time_variant_proportional` — the trait used by +`IOM.add_proportional_cost_maybe_time_variant!` to decide whether the *specific +proportional cost term* for a given (variable × cost object) combination belongs on +the variant or invariant objective expression. + +Not to be confused with `IOM.is_time_variant`, which asks whether *any* field on +the cost object is time-varying. `is_time_variant_proportional` is narrower: it asks +about the one field that feeds into this particular term. + +For `ThermalGenerationCost`, the OnVariable proportional term rate is +`onvar_cost + vom_constant + fixed`. The part that can be time-varying in current +POM is `onvar_cost`, which for Linear/Quadratic FuelCurves equals +`constant_term × fuel_cost` — so it varies if the value curve is TS-backed +(constant_term varies) OR if `fuel_cost::TimeSeriesKey` (price varies). + +The non-obvious case this test guards: `FuelCurve.fuel_cost::Union{Float64, +TimeSeriesKey}` is type-invariant but *instance*-dependent, so the trait must +look at the value, not just the type parameters. +""" + +const _FORECAST_KEY = IS.ForecastKey(; + time_series_type = IS.Deterministic, + name = "fuel_price", + initial_timestamp = Dates.DateTime("2020-01-01"), + resolution = Dates.Hour(1), + horizon = Dates.Hour(24), + interval = Dates.Hour(24), + count = 1, + features = Dict{String, Any}(), +) + +_linear_vc() = PSY.LinearCurve(2.0, 3.0) +_quadratic_vc() = PSY.QuadraticCurve(1.0, 2.0, 3.0) +_pwl_vc() = PSY.PiecewisePointCurve([(x = 0.0, y = 0.0), (x = 1.0, y = 2.0)]) +_ts_linear_vc() = PSY.TimeSeriesLinearCurve(_FORECAST_KEY) +_ts_quadratic_vc() = PSY.TimeSeriesQuadraticCurve(_FORECAST_KEY) + +_tgc(variable) = PSY.ThermalGenerationCost(; + variable = variable, + fixed = 0.0, + start_up = 0.0, + shut_down = 0.0, +) + +@testset "is_time_variant_proportional: ThermalGenerationCost" begin + # CostCurve has no `_onvar_cost` overload — the OnVariable term never depends on + # its value curve, so the trait is false regardless. + @test POM.is_time_variant_proportional(_tgc(PSY.CostCurve(_linear_vc()))) == false + + # FuelCurve{PWL}: `_onvar_cost ≡ 0` regardless of fuel_cost — term is static. + @test POM.is_time_variant_proportional(_tgc(PSY.FuelCurve(_pwl_vc(), 4.0))) == false + @test POM.is_time_variant_proportional(_tgc(PSY.FuelCurve(_pwl_vc(), _FORECAST_KEY))) == + false + + # FuelCurve{Linear/Quadratic}: `_onvar_cost = constant_term * fuel_cost_at_t`, + # so the term varies if either the value curve is TS-backed (constant_term + # varies) or fuel_cost is a TimeSeriesKey (price varies). 2x2 for each shape: + # static vs TS value curve × Float64 vs TimeSeriesKey fuel_cost. + + # LinearCurve + @test POM.is_time_variant_proportional(_tgc(PSY.FuelCurve(_linear_vc(), 4.0))) == false + @test POM.is_time_variant_proportional( + _tgc(PSY.FuelCurve(_linear_vc(), _FORECAST_KEY)), + ) == true + @test POM.is_time_variant_proportional( + _tgc(PSY.FuelCurve(_ts_linear_vc(), 4.0)), + ) == true + @test POM.is_time_variant_proportional( + _tgc(PSY.FuelCurve(_ts_linear_vc(), _FORECAST_KEY)), + ) == true + + # QuadraticCurve + @test POM.is_time_variant_proportional(_tgc(PSY.FuelCurve(_quadratic_vc(), 4.0))) == + false + @test POM.is_time_variant_proportional( + _tgc(PSY.FuelCurve(_quadratic_vc(), _FORECAST_KEY)), + ) == true + @test POM.is_time_variant_proportional( + _tgc(PSY.FuelCurve(_ts_quadratic_vc(), 4.0)), + ) == true + @test POM.is_time_variant_proportional( + _tgc(PSY.FuelCurve(_ts_quadratic_vc(), _FORECAST_KEY)), + ) == true +end + +@testset "is_time_variant_proportional: MarketBidCost / ImportExportCost static vs TS" begin + # Offer-curve cost types are cleanly split static vs TS by type — so the trait + # is decided purely by type dispatch, no instance lookup needed. + @test POM.is_time_variant_proportional(PSY.MarketBidCost()) == false + @test POM.is_time_variant_proportional(stub_ts_market_bid_cost()) == true + @test POM.is_time_variant_proportional(PSY.ImportExportCost()) == false + @test POM.is_time_variant_proportional(stub_ts_import_export_cost()) == true +end diff --git a/test/test_market_bid_cost.jl b/test/test_market_bid_cost.jl new file mode 100644 index 0000000..df7ce87 --- /dev/null +++ b/test/test_market_bid_cost.jl @@ -0,0 +1,341 @@ +""" +Unit tests for POM's MarketBidCost objective-function construction. + +One testset per (device × formulation × cost-type) combo, each built on a single fixture +with multiple dials set to distinct values. Assertions address one observable at a time. +Scaling-sensitive behavior (dt, power-unit conversion, base_power mismatches) lives in +separate "scaling" testsets — that's where dials actually interact. + +Underlying PWL math is covered by IOM — here we only verify POM's translations: that the +numbers put on a cost curve reach the container's objective coefficients as expected. +""" + +const _LOAD_NAME = "load1" +const _THERMAL_NAME = "thermal1" + +# A static MBC with a decremental offer curve only (incremental stays at the default +# ZERO_OFFER_CURVE, which the load-side supply check treats as "absent"). +_decr_mbc(initial_input::Float64, xs::Vector{Float64}, slopes::Vector{Float64}) = + PSY.MarketBidCost(; + decremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(initial_input, xs, slopes), + PSY.UnitSystem.SYSTEM_BASE, + ), + ) + +@testset "InterruptiblePowerLoad + PowerLoadDispatch + static MBC" begin + # Pick distinct slope values so any swap between segments is visible. + cost = _decr_mbc(0.0, [0.0, 0.5, 1.0], [3.0, 7.0]) + sys = one_bus_one_interruptible_load(cost) + load = PSY.get_component(PSY.InterruptiblePowerLoad, sys, _LOAD_NAME) + + container = build_test_container(sys, 1:1) + add_jump_var!( + container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) + + POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerVariable, load, cost, POM.PowerLoadDispatch) + + # Decremental sign = -1, dt = 1 hr, SYSTEM_BASE ⇒ coefficient == -slope. + @test pwl_delta_coefs( + container, IOM.DecrementalOffer(), PSY.InterruptiblePowerLoad, _LOAD_NAME, 1, + ) ≈ [-3.0, -7.0] +end + +@testset "InterruptiblePowerLoad + PowerLoadDispatch: dt and unit conversion" begin + # NATURAL_UNITS + 15-minute resolution. + # slope: 3 $/MWh x 100 MW/p.u. x 0.25 hr/period = 75 $/(p.u. period) + # x breakpoint: 200 MW x 1 p.u./100 MW = 2.0 + cost = PSY.MarketBidCost(; + decremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 200.0], [3.0]), + PSY.UnitSystem.NATURAL_UNITS, + ), + ) + sys = one_bus_one_interruptible_load(cost; system_base_power = 100.0) + load = PSY.get_component(PSY.InterruptiblePowerLoad, sys, _LOAD_NAME) + + container = build_test_container(sys, 1:1; resolution = Dates.Minute(15)) + add_jump_var!( + container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) + + POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerVariable, load, cost, POM.PowerLoadDispatch) + + @test pwl_delta_coefs( + container, IOM.DecrementalOffer(), PSY.InterruptiblePowerLoad, _LOAD_NAME, 1, + ) ≈ [-75.0] + @test pwl_delta_widths( + container, IOM.DecrementalOffer(), PSY.InterruptiblePowerLoad, _LOAD_NAME, 1, + ) ≈ [2.0] +end + +@testset "InterruptiblePowerLoad + PowerLoadDispatch + TS MBC" begin + # TS MBC dispatches through the parameter-container path. We skip `add_parameters!` + # (which would require real time series on the system) and populate the Decremental + # slope/breakpoint parameters directly with distinct-per-timestep values. + cost = stub_ts_market_bid_cost() + sys = one_bus_one_interruptible_load(cost) + load = PSY.get_component(PSY.InterruptiblePowerLoad, sys, _LOAD_NAME) + + container = build_test_container(sys, 1:2) + add_jump_var!( + container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) + add_jump_var!( + container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 2) + + # Slopes and breakpoints vary over time so a wiring that reads the wrong t is visible. + slopes_mat = reshape([[3.0, 7.0], [13.0, 17.0]], 1, 2) + breakpoints_mat = reshape([[0.0, 0.5, 1.0], [0.0, 0.2, 0.7]], 1, 2) + setup_delta_pwl_parameters!( + container, PSY.InterruptiblePowerLoad, [_LOAD_NAME], + slopes_mat, breakpoints_mat, 1:2; + dir = IOM.DecrementalOffer()) + + POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerVariable, load, cost, POM.PowerLoadDispatch) + + variant = IOM.get_variant_terms(IOM.get_objective_expression(container)) + pwl = IOM.get_variable( + container, IOM.PiecewiseLinearBlockDecrementalOffer, + PSY.InterruptiblePowerLoad) + @test [JuMP.coefficient(variant, pwl[(_LOAD_NAME, s, 1)]) for s in 1:2] ≈ [-3.0, -7.0] + @test [JuMP.coefficient(variant, pwl[(_LOAD_NAME, s, 2)]) for s in 1:2] ≈ [-13.0, -17.0] + + @test pwl_delta_widths( + container, IOM.DecrementalOffer(), PSY.InterruptiblePowerLoad, _LOAD_NAME, 1, + ) ≈ [0.5, 0.5] + @test pwl_delta_widths( + container, IOM.DecrementalOffer(), PSY.InterruptiblePowerLoad, _LOAD_NAME, 2, + ) ≈ [0.2, 0.5] +end + +@testset "InterruptiblePowerLoad + PowerLoadInterruption + static MBC" begin + # initial_input = 2 (OnVariable coef dial), plus distinct slopes for PWL. + cost = _decr_mbc(2.0, [0.0, 0.5, 1.0], [3.0, 7.0]) + sys = one_bus_one_interruptible_load(cost) + devs = PSY.get_components(PSY.InterruptiblePowerLoad, sys) + + container = build_test_container(sys, 1:1) + add_jump_var!( + container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) + add_jump_var!( + container, IOM.OnVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) + + IOM.add_variable_cost!( + container, IOM.ActivePowerVariable, devs, POM.PowerLoadInterruption) + POM.add_proportional_cost!( + container, IOM.OnVariable, devs, POM.PowerLoadInterruption) + + # OnVariable: coefficient = initial_input × OBJECTIVE_FUNCTION_NEGATIVE = -2.0. + @test obj_coef( + container, IOM.OnVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1, + ) ≈ -2.0 + + # PWL decremental: slope × sign × dt = -slope (dt=1, SYSTEM_BASE). + @test pwl_delta_coefs( + container, IOM.DecrementalOffer(), PSY.InterruptiblePowerLoad, _LOAD_NAME, 1, + ) ≈ [-3.0, -7.0] +end + +@testset "InterruptiblePowerLoad + PowerLoadInterruption + TS MBC" begin + cost = stub_ts_market_bid_cost() + sys = one_bus_one_interruptible_load(cost) + devs = PSY.get_components(PSY.InterruptiblePowerLoad, sys) + + container = build_test_container(sys, 1:2) + for t in 1:2 + add_jump_var!( + container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, t) + add_jump_var!( + container, IOM.OnVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, t) + end + + # Params vary over t so reading-wrong-t bugs are visible. + setup_delta_pwl_parameters!( + container, PSY.InterruptiblePowerLoad, [_LOAD_NAME], + reshape([[3.0, 7.0], [13.0, 17.0]], 1, 2), + reshape([[0.0, 0.5, 1.0], [0.0, 0.2, 0.7]], 1, 2), + 1:2; + dir = IOM.DecrementalOffer()) + add_test_parameter!( + container, IOM.DecrementalCostAtMinParameter, PSY.InterruptiblePowerLoad, + [_LOAD_NAME], 1:2, reshape([2.5, 4.5], 1, 2)) + + IOM.add_variable_cost!( + container, IOM.ActivePowerVariable, devs, POM.PowerLoadInterruption) + POM.add_proportional_cost!( + container, IOM.OnVariable, devs, POM.PowerLoadInterruption) + + variant = IOM.get_variant_terms(IOM.get_objective_expression(container)) + on_var = IOM.get_variable(container, IOM.OnVariable, PSY.InterruptiblePowerLoad) + pwl = IOM.get_variable( + container, IOM.PiecewiseLinearBlockDecrementalOffer, + PSY.InterruptiblePowerLoad) + + # OnVariable: param × OBJECTIVE_FUNCTION_NEGATIVE. + @test JuMP.coefficient(variant, on_var[_LOAD_NAME, 1]) ≈ -2.5 + @test JuMP.coefficient(variant, on_var[_LOAD_NAME, 2]) ≈ -4.5 + + # PWL slopes in variant terms, one set per time step. + @test [JuMP.coefficient(variant, pwl[(_LOAD_NAME, s, 1)]) for s in 1:2] ≈ [-3.0, -7.0] + @test [JuMP.coefficient(variant, pwl[(_LOAD_NAME, s, 2)]) for s in 1:2] ≈ [-13.0, -17.0] +end + +@testset "InterruptiblePowerLoad + PowerLoadDispatch: supply-side rejection" begin + # Non-trivial incremental curve on a load should throw. + cost = PSY.MarketBidCost(; + incremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 1.0], [5.0]), + PSY.UnitSystem.SYSTEM_BASE, + ), + ) + sys = one_bus_one_interruptible_load(cost) + load = PSY.get_component(PSY.InterruptiblePowerLoad, sys, _LOAD_NAME) + + container = build_test_container(sys, 1:1) + add_jump_var!( + container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) + + @test_throws ArgumentError POM.add_variable_cost_to_objective!( + container, IOM.ActivePowerVariable, load, cost, POM.PowerLoadDispatch) +end + +@testset "ThermalStandard + ThermalBasicUnitCommitment + static MBC" begin + # Distinct values on every dial so a wiring swap is visible. + mbc = PSY.MarketBidCost(; + no_load_cost = PSY.LinearCurve(10.0), # unused by thermal objective; kept as a + # canary for accidental wiring into obj. + start_up = (hot = 50.0, warm = 80.0, cold = 100.0), + shut_down = PSY.LinearCurve(30.0), + incremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(2.5, [0.1, 0.5, 1.0], [3.0, 7.0]), + PSY.UnitSystem.SYSTEM_BASE, + ), + ) + sys = one_bus_one_thermal(mbc; name = _THERMAL_NAME) + devs = PSY.get_components(PSY.ThermalStandard, sys) + + container = build_test_container(sys, 1:1) + for V in (IOM.ActivePowerVariable, IOM.OnVariable, IOM.StartVariable, IOM.StopVariable) + add_jump_var!(container, V, PSY.ThermalStandard, _THERMAL_NAME, 1) + end + + IOM.add_variable_cost!( + container, IOM.ActivePowerVariable, devs, POM.ThermalBasicUnitCommitment) + IOM.add_start_up_cost!( + container, IOM.StartVariable, devs, POM.ThermalBasicUnitCommitment) + IOM.add_shut_down_cost!( + container, IOM.StopVariable, devs, POM.ThermalBasicUnitCommitment) + POM.add_proportional_cost!( + container, IOM.OnVariable, devs, POM.ThermalBasicUnitCommitment) + + # StartVariable takes the max over StartUpStages for basic UC formulations. + @test obj_coef( + container, IOM.StartVariable, PSY.ThermalStandard, _THERMAL_NAME, 1, + ) ≈ 100.0 + # StopVariable gets the LinearCurve shut_down's proportional term. + @test obj_coef( + container, IOM.StopVariable, PSY.ThermalStandard, _THERMAL_NAME, 1, + ) ≈ 30.0 + # OnVariable picks up the incremental curve's initial_input (cost-at-min-gen). + @test obj_coef( + container, IOM.OnVariable, PSY.ThermalStandard, _THERMAL_NAME, 1, + ) ≈ 2.5 + # Incremental PWL slopes (positive sign for supply, dt=1, SYSTEM_BASE). + @test pwl_delta_coefs( + container, IOM.IncrementalOffer(), PSY.ThermalStandard, _THERMAL_NAME, 1, + ) ≈ [3.0, 7.0] +end + +@testset "ThermalStandard + ThermalBasicUnitCommitment + TS MBC" begin + cost = stub_ts_market_bid_cost() + sys = one_bus_one_thermal(cost; name = _THERMAL_NAME) + devs = PSY.get_components(PSY.ThermalStandard, sys) + + container = build_test_container(sys, 1:2) + for V in (IOM.ActivePowerVariable, IOM.OnVariable, IOM.StartVariable, IOM.StopVariable), + t in 1:2 + + add_jump_var!(container, V, PSY.ThermalStandard, _THERMAL_NAME, t) + end + + # All param values differ between t=1 and t=2 to catch off-by-t wiring. + setup_delta_pwl_parameters!( + container, PSY.ThermalStandard, [_THERMAL_NAME], + reshape([[3.0, 7.0], [13.0, 17.0]], 1, 2), + reshape([[0.1, 0.5, 1.0], [0.1, 0.3, 0.9]], 1, 2), + 1:2; + dir = IOM.IncrementalOffer()) + add_test_parameter!( + container, IOM.IncrementalCostAtMinParameter, PSY.ThermalStandard, + [_THERMAL_NAME], 1:2, reshape([2.5, 4.5], 1, 2)) + # Scalar Float64 startup — covers the basic path. A separate testset below covers + # the Tuple-valued path used by multi-start formulations. + add_test_parameter!( + container, IOM.StartupCostParameter, PSY.ThermalStandard, + [_THERMAL_NAME], 1:2, reshape([100.0, 110.0], 1, 2)) + add_test_parameter!( + container, IOM.ShutdownCostParameter, PSY.ThermalStandard, + [_THERMAL_NAME], 1:2, reshape([30.0, 45.0], 1, 2)) + + IOM.add_variable_cost!( + container, IOM.ActivePowerVariable, devs, POM.ThermalBasicUnitCommitment) + IOM.add_start_up_cost!( + container, IOM.StartVariable, devs, POM.ThermalBasicUnitCommitment) + IOM.add_shut_down_cost!( + container, IOM.StopVariable, devs, POM.ThermalBasicUnitCommitment) + POM.add_proportional_cost!( + container, IOM.OnVariable, devs, POM.ThermalBasicUnitCommitment) + + variant = IOM.get_variant_terms(IOM.get_objective_expression(container)) + for (V, expected_t1, expected_t2) in ( + (IOM.StartVariable, 100.0, 110.0), + (IOM.StopVariable, 30.0, 45.0), + (IOM.OnVariable, 2.5, 4.5), + ) + var = IOM.get_variable(container, V, PSY.ThermalStandard) + @test JuMP.coefficient(variant, var[_THERMAL_NAME, 1]) ≈ expected_t1 + @test JuMP.coefficient(variant, var[_THERMAL_NAME, 2]) ≈ expected_t2 + end + pwl = IOM.get_variable( + container, IOM.PiecewiseLinearBlockIncrementalOffer, PSY.ThermalStandard) + @test [JuMP.coefficient(variant, pwl[(_THERMAL_NAME, s, 1)]) for s in 1:2] ≈ [3.0, 7.0] + @test [JuMP.coefficient(variant, pwl[(_THERMAL_NAME, s, 2)]) for s in 1:2] ≈ + [13.0, 17.0] +end + +@testset "ThermalMultiStart + ThermalMultiStartUnitCommitment + TS MBC (Tuple startup)" begin + # Multi-start UC splits the startup cost across three variables (hot/warm/cold), + # each reading one field of the Tuple-valued `StartupCostParameter`. This exercises + # `param .* mult` with a Tuple cell, plus the per-stage dispatch in `start_up_cost`. + cost = stub_ts_market_bid_cost() + ms_name = "thermal_ms1" + sys = one_bus_one_thermal_multistart(cost; name = ms_name) + devs = PSY.get_components(PSY.ThermalMultiStart, sys) + + container = build_test_container(sys, 1:1) + for V in (POM.HotStartVariable, POM.WarmStartVariable, POM.ColdStartVariable) + add_jump_var!(container, V, PSY.ThermalMultiStart, ms_name, 1) + end + + # (hot, warm, cold) = (50, 100, 150). Each stage's variable should see its own field. + add_test_parameter!( + container, IOM.StartupCostParameter, PSY.ThermalMultiStart, + [ms_name], 1:1, reshape([(50.0, 100.0, 150.0)], 1, 1)) + + for V in (POM.HotStartVariable, POM.WarmStartVariable, POM.ColdStartVariable) + IOM.add_start_up_cost!( + container, V, devs, POM.ThermalMultiStartUnitCommitment) + end + + variant = IOM.get_variant_terms(IOM.get_objective_expression(container)) + for (V, expected) in ( + (POM.HotStartVariable, 50.0), + (POM.WarmStartVariable, 100.0), + (POM.ColdStartVariable, 150.0), + ) + var = IOM.get_variable(container, V, PSY.ThermalMultiStart) + @test JuMP.coefficient(variant, var[ms_name, 1]) ≈ expected + end +end diff --git a/test/test_mbc_parameter_population.jl b/test/test_mbc_parameter_population.jl new file mode 100644 index 0000000..c16ca79 --- /dev/null +++ b/test/test_mbc_parameter_population.jl @@ -0,0 +1,307 @@ +""" +Tests for the parameter-population half of the MBC TS pipeline. + +The MBC objective-construction tests in `test_market_bid_cost.jl` call +`add_test_parameter!` / `setup_delta_pwl_parameters!` to poke known values directly into +parameter containers, then verify the downstream objective. That leaves the *other* half +untested: the `add_parameters!` path that pulls values out of real PSY time series and +deposits them into those same containers. + +These tests attach real Deterministic time series to a minimal PSY system, call +`add_parameters!` directly, and assert the resulting parameter arrays contain the +expected per-timestep values. +""" + +# Minimal System with a ThermalStandard carrying a MarketBidTimeSeriesCost, every field +# backed by a real Deterministic time series sharing the same forecast metadata. `*_incr` +# dials drive per-period drift so an off-by-t wiring is visible. +function _build_mbtsc_thermal_system(; + name::String = "thermal1", + init_time::DateTime = DateTime("2020-01-01"), + horizon::Period = Hour(3), + interval::Period = Hour(3), + count::Int = 1, + resolution::Period = Hour(1), + start_up_base::NTuple{3, Float64} = (100.0, 150.0, 200.0), + start_up_incr::Number = 10.0, + shut_down_base::Float64 = 50.0, + shut_down_incr::Number = 5.0, + no_load_base::Float64 = 5.0, + incr_init_base::Float64 = 10.0, + incr_init_incr::Number = 2.0, + decr_init_base::Float64 = 8.0, + decr_init_incr::Number = 1.0, + incr_pwl_base::PiecewiseStepData = PiecewiseStepData([0.0, 50.0, 100.0], [25.0, 30.0]), + decr_pwl_base::PiecewiseStepData = PiecewiseStepData([0.0, 50.0, 100.0], [30.0, 25.0]), +) + sys = PSY.System(100.0) + bus = _add_simple_bus!(sys) + # Placeholder static MBC; replaced once TS are attached. + static_mbc = PSY.MarketBidCost(; + no_load_cost = PSY.LinearCurve(0.0), + start_up = (hot = 0.0, warm = 0.0, cold = 0.0), + shut_down = PSY.LinearCurve(0.0), + incremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 1.0], [1.0])), + decremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 1.0], [1.0])), + ) + gen = _add_simple_thermal_standard!(sys, bus, static_mbc; name = name) + + common = (init_time, horizon, interval, count, resolution) + startup_ts = + make_deterministic_ts("start_up", start_up_base, start_up_incr, 0.0, common...) + shutdown_ts = + make_deterministic_ts("shut_down", shut_down_base, shut_down_incr, 0.0, common...) + noload_ts = make_deterministic_ts("no_load", no_load_base, 0.0, 0.0, common...) + incr_init_ts = make_deterministic_ts( + "initial_input incremental", incr_init_base, incr_init_incr, 0.0, common...) + decr_init_ts = make_deterministic_ts( + "initial_input decremental", decr_init_base, decr_init_incr, 0.0, common...) + incr_pwl_ts = make_deterministic_ts( + "variable_cost incremental", incr_pwl_base, + (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), common...) + decr_pwl_ts = make_deterministic_ts( + "variable_cost decremental", decr_pwl_base, + (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), common...) + + su_key = add_time_series!(sys, gen, startup_ts) + sd_key = add_time_series!(sys, gen, shutdown_ts) + nl_key = add_time_series!(sys, gen, noload_ts) + ii_incr_key = add_time_series!(sys, gen, incr_init_ts) + ii_decr_key = add_time_series!(sys, gen, decr_init_ts) + pwl_incr_key = add_time_series!(sys, gen, incr_pwl_ts) + pwl_decr_key = add_time_series!(sys, gen, decr_pwl_ts) + + new_cost = PSY.MarketBidTimeSeriesCost(; + no_load_cost = PSY.TimeSeriesLinearCurve(nl_key), + start_up = IS.TupleTimeSeries{PSY.StartUpStages}(su_key), + shut_down = PSY.TimeSeriesLinearCurve(sd_key), + incremental_offer_curves = PSY.make_market_bid_ts_curve(pwl_incr_key, ii_incr_key), + decremental_offer_curves = PSY.make_market_bid_ts_curve(pwl_decr_key, ii_decr_key), + ) + PSY.set_operation_cost!(gen, new_cost) + return sys, gen +end + +const _PP_THERMAL_NAME = "thermal1" +const _PP_INITIAL_TIME = DateTime("2020-01-01") +const _PP_MODEL = + IOM.DeviceModel(PSY.ThermalStandard, POM.ThermalBasicUnitCommitment) + +# `build_test_container` leaves `initial_time` at its sentinel default; the TS we attach +# start at 2020-01-01, so align the container's initial_time before reading values. +function _pp_build_container(sys::PSY.System, time_steps::UnitRange{Int}) + container = build_test_container(sys, time_steps) + IOM.set_initial_time!(IOM.get_settings(container), _PP_INITIAL_TIME) + return container +end + +@testset "StartupCostParameter populated from TupleTimeSeries" begin + # Drift by 10 per hour so each timestep is distinct: (100,150,200), (110,160,210), ... + sys, gen = _build_mbtsc_thermal_system(; + name = _PP_THERMAL_NAME, + start_up_base = (100.0, 150.0, 200.0), + start_up_incr = 10.0, + horizon = Hour(3), + ) + devs = PSY.get_components(PSY.ThermalStandard, sys) + + container = _pp_build_container(sys, 1:3) + POM.add_parameters!(container, IOM.StartupCostParameter, devs, _PP_MODEL) + + param_arr = IOM.get_parameter_array( + container, IOM.StartupCostParameter, PSY.ThermalStandard) + @test param_arr[_PP_THERMAL_NAME, 1] == (100.0, 150.0, 200.0) + @test param_arr[_PP_THERMAL_NAME, 2] == (110.0, 160.0, 210.0) + @test param_arr[_PP_THERMAL_NAME, 3] == (120.0, 170.0, 220.0) +end + +@testset "ShutdownCostParameter populated from TimeSeriesLinearCurve" begin + sys, gen = _build_mbtsc_thermal_system(; + name = _PP_THERMAL_NAME, + shut_down_base = 50.0, + shut_down_incr = 5.0, + horizon = Hour(3), + ) + devs = PSY.get_components(PSY.ThermalStandard, sys) + + container = _pp_build_container(sys, 1:3) + POM.add_parameters!(container, IOM.ShutdownCostParameter, devs, _PP_MODEL) + + param_arr = IOM.get_parameter_array( + container, IOM.ShutdownCostParameter, PSY.ThermalStandard) + @test param_arr[_PP_THERMAL_NAME, 1] ≈ 50.0 + @test param_arr[_PP_THERMAL_NAME, 2] ≈ 55.0 + @test param_arr[_PP_THERMAL_NAME, 3] ≈ 60.0 +end + +@testset "IncrementalCostAtMinParameter populated from initial_input TS" begin + sys, gen = _build_mbtsc_thermal_system(; + name = _PP_THERMAL_NAME, + incr_init_base = 10.0, + incr_init_incr = 2.0, + horizon = Hour(3), + ) + devs = PSY.get_components(PSY.ThermalStandard, sys) + + container = _pp_build_container(sys, 1:3) + POM.add_parameters!(container, IOM.IncrementalCostAtMinParameter, devs, _PP_MODEL) + + param_arr = IOM.get_parameter_array( + container, IOM.IncrementalCostAtMinParameter, PSY.ThermalStandard) + @test param_arr[_PP_THERMAL_NAME, 1] ≈ 10.0 + @test param_arr[_PP_THERMAL_NAME, 2] ≈ 12.0 + @test param_arr[_PP_THERMAL_NAME, 3] ≈ 14.0 +end + +@testset "DecrementalCostAtMinParameter populated from initial_input TS" begin + sys, gen = _build_mbtsc_thermal_system(; + name = _PP_THERMAL_NAME, + decr_init_base = 8.0, + decr_init_incr = 1.0, + horizon = Hour(3), + ) + devs = PSY.get_components(PSY.ThermalStandard, sys) + + container = _pp_build_container(sys, 1:3) + POM.add_parameters!(container, IOM.DecrementalCostAtMinParameter, devs, _PP_MODEL) + + param_arr = IOM.get_parameter_array( + container, IOM.DecrementalCostAtMinParameter, PSY.ThermalStandard) + @test param_arr[_PP_THERMAL_NAME, 1] ≈ 8.0 + @test param_arr[_PP_THERMAL_NAME, 2] ≈ 9.0 + @test param_arr[_PP_THERMAL_NAME, 3] ≈ 10.0 +end + +@testset "process_market_bid_parameters! filters static-cost devices" begin + # Two thermals on the same system: one with TS MBC (values driven by time series), + # one with static MBC (scalar cost object). The orchestrator is expected to add + # parameter entries only for the TS device; the static device should be filtered + # out by IOM's `_has_parameter_time_series` gate without firing the OCC assertion. + # + # Scope limited to `incremental = false, decremental = false` so only the scalar + # Startup/Shutdown params run: PWL slope/breakpoint population isn't wired up in + # POM yet (`_unwrap_for_param` / `calc_additional_axes` overloads live in PSI). + sys, gen_ts = _build_mbtsc_thermal_system(; name = "thermal_ts") + bus2 = _add_simple_bus!(sys; number = 2, name = "bus2", + bustype = PSY.ACBusTypes.PQ) + static_mbc = PSY.MarketBidCost(; + no_load_cost = PSY.LinearCurve(0.0), + start_up = (hot = 999.0, warm = 999.0, cold = 999.0), + shut_down = PSY.LinearCurve(999.0), + incremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 1.0], [1.0])), + decremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(0.0, [0.0, 1.0], [1.0])), + ) + _add_simple_thermal_standard!(sys, bus2, static_mbc; name = "thermal_static") + + devs = PSY.get_components(PSY.ThermalStandard, sys) + container = _pp_build_container(sys, 1:3) + # `_consider_parameter` needs StartVariable for StartupCostParameter and StopVariable + # for ShutdownCostParameter; register both devices so neither gets short-circuited + # at the trait level (the `_has_parameter_time_series` filter is what should drop + # the static device). Add both names to each container up front so per-device + # indexing doesn't clash with the single-name axis `add_jump_var!` creates. + names = ["thermal_ts", "thermal_static"] + for V in (IOM.StartVariable, IOM.StopVariable) + IOM.add_variable_container!(container, V, PSY.ThermalStandard, names, 1:3) + for name in names, t in 1:3 + IOM.get_variable(container, V, PSY.ThermalStandard)[name, t] = + JuMP.@variable(IOM.get_jump_model(container), + base_name = "$(V)_$(name)_$(t)") + end + end + + POM.process_market_bid_parameters!(container, devs, _PP_MODEL, false, false) + + for P in (IOM.StartupCostParameter, IOM.ShutdownCostParameter) + param_arr = IOM.get_parameter_array(container, P, PSY.ThermalStandard) + device_axis = axes(param_arr)[1] + @test "thermal_ts" in device_axis + @test "thermal_static" ∉ device_axis + end +end + +@testset "IEC PWL slope population (pending PSI→POM PWL migration)" begin + # PWL parameter population requires the `calc_additional_axes` / + # `_unwrap_for_param` overloads for `AbstractPiecewiseLinear{Slope,Breakpoint} + # Parameter`. Those live in PSI and haven't been migrated to POM yet — see the + # note at `src/common_models/add_parameters.jl:407-409`. Until migrated, + # `add_parameters!` for slope/breakpoint params falls to defaults that mis-shape + # the parameter array and mis-handle `PiecewiseStepData` unwrapping. + # + # This test is expected to fail on the current tree; promote to `@test` when the + # PWL migration lands. + init_time = DateTime("2020-01-01") + horizon, interval, count, resolution = Hour(3), Hour(3), 1, Hour(1) + + sys = PSY.System(100.0) + bus = _add_simple_bus!(sys) + source = _add_simple_source!(sys, bus, + PSY.ImportExportCost(; # placeholder; replaced after we have real TS keys + import_offer_curves = PSY.make_import_curve([0.0, 1.0], [1.0]), + export_offer_curves = PSY.make_export_curve([0.0, 1.0], [1.0]), + ); + name = "source1", + ) + + import_pwl_ts = make_deterministic_ts( + "variable_cost_import", PiecewiseStepData([0.0, 50.0, 100.0], [5.0, 10.0]), + (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), + init_time, horizon, interval, count, resolution) + export_pwl_ts = make_deterministic_ts( + "variable_cost_export", PiecewiseStepData([0.0, 50.0, 100.0], [10.0, 5.0]), + (0.0, 0.0, 0.0), (0.0, 0.0, 0.0), + init_time, horizon, interval, count, resolution) + im_key = add_time_series!(sys, source, import_pwl_ts) + ex_key = add_time_series!(sys, source, export_pwl_ts) + + PSY.set_operation_cost!( + source, + PSY.ImportExportTimeSeriesCost(; + import_offer_curves = PSY.make_import_export_ts_curve(im_key), + export_offer_curves = PSY.make_import_export_ts_curve(ex_key), + ), + ) + + devs = PSY.get_components(PSY.Source, sys) + container = _pp_build_container(sys, 1:3) + iec_model = IOM.DeviceModel(PSY.Source, POM.ImportExportSourceModel) + + @test_broken try + POM.add_parameters!( + container, IOM.IncrementalPiecewiseLinearSlopeParameter, devs, iec_model) + true + catch + false + end +end + +@testset "_get_time_series_name asserts on static-cost devices" begin + # Each OCC `_get_time_series_name` method asserts `op_cost isa TS_OFFER_CURVE_COST_TYPES`. + # IOM's filter keeps the precondition true in production; the guards are here to + # catch any future caller that bypasses the filter. This test confirms they actually + # fire instead of error-ing later with a confusing MethodError in a PSY accessor. + static_mbc = PSY.MarketBidCost(; + no_load_cost = PSY.LinearCurve(0.0), + start_up = (hot = 100.0, warm = 150.0, cold = 200.0), + shut_down = PSY.LinearCurve(50.0), + incremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(5.0, [0.0, 100.0], [25.0])), + decremental_offer_curves = PSY.CostCurve( + PSY.PiecewiseIncrementalCurve(5.0, [0.0, 100.0], [25.0])), + ) + sys = one_bus_one_thermal(static_mbc; name = _PP_THERMAL_NAME) + gen = PSY.get_component(PSY.ThermalStandard, sys, _PP_THERMAL_NAME) + + for P in ( + IOM.StartupCostParameter, + IOM.ShutdownCostParameter, + IOM.IncrementalCostAtMinParameter, + IOM.DecrementalCostAtMinParameter, + ) + @test_throws AssertionError POM._get_time_series_name(P(), gen, _PP_MODEL) + end +end diff --git a/test/test_model_decision.jl b/test/test_model_decision.jl index 87edcc7..aefbaa6 100644 --- a/test/test_model_decision.jl +++ b/test/test_model_decision.jl @@ -286,6 +286,8 @@ end read_parameters(res; table_format = TableFormat.WIDE), Dict{String, DataFrames.DataFrame}, ) + # FIXME could make more uniform which we export and don't export. + # FIXME both PSY and IOM export get_resolution, which creats ambiguity. @test isa(IOM.get_resolution(res), Dates.TimePeriod) @test isa(IOM.get_forecast_horizon(res), Int64) @test isa(get_realized_timestamps(res), StepRange{DateTime}) diff --git a/test/test_utils/add_market_bid_cost.jl b/test/test_utils/add_market_bid_cost.jl index 5a70f9d..1be5cd6 100644 --- a/test/test_utils/add_market_bid_cost.jl +++ b/test/test_utils/add_market_bid_cost.jl @@ -14,9 +14,9 @@ function add_mbc_inner!( error("At least one of incr_curve or decr_curve must be provided") end mbc = MarketBidCost(; - no_load_cost = 0.0, + no_load_cost = LinearCurve(0.0), start_up = (hot = 0.0, warm = 0.0, cold = 0.0), - shut_down = 0.0, + shut_down = LinearCurve(0.0), ) if !isnothing(decr_curve) set_decremental_offer_curves!(mbc, CostCurve(decr_curve)) @@ -115,6 +115,7 @@ function extend_mbc!( # incremental_initial_input is cost at minimum generation, NOT cost at zero generation for comp in get_components(active_components, sys) op_cost = get_operation_cost(comp) + @assert op_cost isa MarketBidCost if do_override_min_x && :active_power_limits in fieldnames(typeof(comp)) min_power = with_units_base(sys, UnitSystem.NATURAL_UNITS) do get_active_power_limits(comp).min @@ -123,29 +124,28 @@ function extend_mbc!( min_power = nothing end - @assert op_cost isa MarketBidCost - for (getter, setter_initial, setter_curves, incr_or_decr) in ( - ( - get_incremental_offer_curves, - set_incremental_initial_input!, - set_incremental_offer_curves!, - "incremental", - ), - ( - get_decremental_offer_curves, - set_decremental_initial_input!, - set_decremental_offer_curves!, - "decremental", - ), + # Capture baseline scalar fields from the static MBC to preserve in the TS MBC. + old_no_load = get_proportional_term(get_no_load_cost(op_cost)) + old_start_up = get_start_up(op_cost) + old_shut_down = get_proportional_term(get_shut_down(op_cost)) + + # TS-backed no_load and shut_down (constant TS of the baseline scalar value). + nl_ts = make_deterministic_ts(sys, "no_load_cost", old_no_load, 0.0, 0.0) + sd_ts = make_deterministic_ts(sys, "shut_down_cost", old_shut_down, 0.0, 0.0) + su_ts = make_deterministic_ts(sys, "start_up", Tuple(old_start_up), 0.0, 0.0) + nl_key = add_time_series!(sys, comp, nl_ts) + sd_key = add_time_series!(sys, comp, sd_ts) + su_key = add_time_series!(sys, comp, su_ts) + + # Build TS-backed offer curves for both directions. + ts_curves = Dict{String, Any}() + for (getter, incr_or_decr) in ( + (get_incremental_offer_curves, "incremental"), + (get_decremental_offer_curves, "decremental"), ) cost_curve = getter(op_cost) - isnothing(cost_curve) && continue - baseline = get_value_curve(cost_curve)::PiecewiseIncrementalCurve - baseline_initial = get_initial_input(baseline) - if zero_cost_at_min - baseline_initial = 0.0 - end + baseline_initial = zero_cost_at_min ? 0.0 : get_initial_input(baseline) baseline_pwl = get_function_data(baseline) if do_override_min_x && isnothing(min_power) min_power = first(get_x_coords(baseline_pwl)) @@ -183,9 +183,18 @@ function extend_mbc!( ) initial_key = add_time_series!(sys, comp, my_initial_ts) curve_key = add_time_series!(sys, comp, my_pwl_ts) - setter_initial(op_cost, initial_key) - setter_curves(op_cost, curve_key) + ts_curves[incr_or_decr] = + PSY.make_market_bid_ts_curve(curve_key, initial_key) end + + new_cost = MarketBidTimeSeriesCost(; + no_load_cost = TimeSeriesLinearCurve(nl_key), + start_up = IS.TupleTimeSeries{PSY.StartUpStages}(su_key), + shut_down = TimeSeriesLinearCurve(sd_key), + incremental_offer_curves = ts_curves["incremental"], + decremental_offer_curves = ts_curves["decremental"], + ) + set_operation_cost!(comp, new_cost) end end diff --git a/test/test_utils/iec_simulation_utils.jl b/test/test_utils/iec_simulation_utils.jl index 81f4b11..5a0b416 100644 --- a/test/test_utils/iec_simulation_utils.jl +++ b/test/test_utils/iec_simulation_utils.jl @@ -104,8 +104,13 @@ function make_5_bus_with_ie_ts( im_key = add_time_series!(sys, source, im_ts) ex_key = add_time_series!(sys, source, ex_ts) - set_import_offer_curves!(oc, im_key) - set_export_offer_curves!(oc, ex_key) + ts_cost = ImportExportTimeSeriesCost(; + import_offer_curves = PSY.make_import_export_ts_curve(im_key), + export_offer_curves = PSY.make_import_export_ts_curve(ex_key), + energy_import_weekly_limit = get_energy_import_weekly_limit(oc), + energy_export_weekly_limit = get_energy_export_weekly_limit(oc), + ) + set_operation_cost!(source, ts_cost) return sys end @@ -206,31 +211,28 @@ function cost_due_to_time_varying_iec( @assert all(power_in_df.DateTime .== power_out_df.DateTime) @assert any([ - get_operation_cost(comp) isa ImportExportCost for + get_operation_cost(comp) isa IEC_TYPES for comp in get_components(T, sys) ]) for gen_name in gen_names comp = get_component(T, sys, gen_name) cost = PSY.get_operation_cost(comp) - (cost isa ImportExportCost) || continue + (cost isa ImportExportTimeSeriesCost) || continue step_df[!, gen_name] .= 0.0 # imports = addition of power = power flowing out of the device # exports = reduction of power = power flowing into the device - for (multiplier, power_df, getter) in ( - (1.0, power_out_df, PSY.get_import_offer_curves), - (-1.0, power_in_df, PSY.get_export_offer_curves), + for (multiplier, power_df, getter_ts) in ( + (1.0, power_out_df, PSY.get_import_variable_cost), + (-1.0, power_in_df, PSY.get_export_variable_cost), ) - offer_curves = getter(cost) - if IOM.is_time_variant(offer_curves) - vc_ts = getter(comp, cost; start_time = step_dt) - @assert all(unique(power_df.DateTime) .== TimeSeries.timestamp(vc_ts)) - step_df[!, gen_name] .+= - multiplier * - _calc_pwi_cost.( - @rsubset(power_df, :name == gen_name).value, - TimeSeries.values(vc_ts), - ) - end + vc_ts = getter_ts(comp, cost; start_time = step_dt) + @assert all(unique(power_df.DateTime) .== TimeSeries.timestamp(vc_ts)) + step_df[!, gen_name] .+= + multiplier * + _calc_pwi_cost.( + @rsubset(power_df, :name == gen_name).value, + TimeSeries.values(vc_ts), + ) end end diff --git a/test/test_utils/mbc_math_helpers.jl b/test/test_utils/mbc_math_helpers.jl new file mode 100644 index 0000000..7a100a3 --- /dev/null +++ b/test/test_utils/mbc_math_helpers.jl @@ -0,0 +1,485 @@ +""" +Helpers for unit-testing MBC/IEC objective function construction. + +Builds minimal PSY systems (one bus, one device) and OptimizationContainers with just the +variables each test needs. Mirrors the pattern in IOM's mock-based helpers, but uses real +PSY types because POM dispatches are keyed on them. + +`_add_simple_*!` helpers follow the style in PowerFlows' `test/test_utils/common.jl`: +small single-purpose functions that return the added component, so they can be composed. +""" + +function _add_simple_bus!( + sys::PSY.System; + number::Int = 1, + name::String = "bus1", + bustype = PSY.ACBusTypes.REF, + base_voltage::Float64 = 230.0, +) + bus = PSY.ACBus(; + number = number, + name = name, + available = true, + bustype = bustype, + angle = 0.0, + magnitude = 1.0, + voltage_limits = (0.0, 2.0), + base_voltage = base_voltage, + ) + PSY.add_component!(sys, bus) + return bus +end + +function _add_simple_interruptible_load!( + sys::PSY.System, + bus::PSY.ACBus, + cost::PSY.OperationalCost; + name::String = "load1", + max_active_power::Float64 = 1.0, + base_power::Float64 = 100.0, +) + load = PSY.InterruptiblePowerLoad(; + name = name, + available = true, + bus = bus, + active_power = 0.0, + reactive_power = 0.0, + max_active_power = max_active_power, + max_reactive_power = 0.0, + operation_cost = cost, + base_power = base_power, + ) + PSY.add_component!(sys, load) + return load +end + +"""One-bus system with a single `InterruptiblePowerLoad` carrying `cost`.""" +function one_bus_one_interruptible_load( + cost::PSY.OperationalCost; + system_base_power::Float64 = 100.0, + kwargs..., +) + sys = PSY.System(system_base_power) + bus = _add_simple_bus!(sys) + _add_simple_interruptible_load!(sys, bus, cost; kwargs...) + return sys +end + +function _add_simple_source!( + sys::PSY.System, + bus::PSY.ACBus, + cost::PSY.OperationalCost; + name::String = "source1", + active_power_limits = (min = -2.0, max = 2.0), + reactive_power_limits = (min = -2.0, max = 2.0), + base_power::Float64 = 100.0, +) + source = PSY.Source(; + name = name, + available = true, + bus = bus, + active_power = 0.0, + reactive_power = 0.0, + active_power_limits = active_power_limits, + reactive_power_limits = reactive_power_limits, + R_th = 0.01, + X_th = 0.02, + internal_voltage = 1.0, + internal_angle = 0.0, + base_power = base_power, + ) + PSY.set_operation_cost!(source, cost) + PSY.add_component!(sys, source) + return source +end + +"""One-bus system with a single `Source` carrying `cost` (typically ImportExport*Cost).""" +function one_bus_one_source( + cost::PSY.OperationalCost; + system_base_power::Float64 = 100.0, + kwargs..., +) + sys = PSY.System(system_base_power) + bus = _add_simple_bus!(sys) + _add_simple_source!(sys, bus, cost; kwargs...) + return sys +end + +function _add_simple_thermal_standard!( + sys::PSY.System, + bus::PSY.ACBus, + cost::PSY.OperationalCost; + name::String = "thermal1", + active_power_limits = (min = 0.1, max = 1.0), + base_power::Float64 = 100.0, +) + gen = PSY.ThermalStandard(; + name = name, + available = true, + status = true, + bus = bus, + active_power = 0.0, + reactive_power = 0.0, + rating = 1.0, + active_power_limits = active_power_limits, + reactive_power_limits = (min = -1.0, max = 1.0), + ramp_limits = nothing, + time_limits = nothing, + operation_cost = cost, + base_power = base_power, + prime_mover_type = PSY.PrimeMovers.OT, + fuel = PSY.ThermalFuels.OTHER, + ) + PSY.add_component!(sys, gen) + return gen +end + +"""One-bus system with a single `ThermalStandard` carrying `cost`.""" +function one_bus_one_thermal( + cost::PSY.OperationalCost; + system_base_power::Float64 = 100.0, + kwargs..., +) + sys = PSY.System(system_base_power) + bus = _add_simple_bus!(sys) + _add_simple_thermal_standard!(sys, bus, cost; kwargs...) + return sys +end + +function _add_simple_thermal_multistart!( + sys::PSY.System, + bus::PSY.ACBus, + cost::PSY.OperationalCost; + name::String = "thermal_ms1", + active_power_limits = (min = 0.1, max = 1.0), + base_power::Float64 = 100.0, +) + gen = PSY.ThermalMultiStart(; + name = name, + available = true, + status = true, + bus = bus, + active_power = 0.0, + reactive_power = 0.0, + rating = 1.0, + prime_mover_type = PSY.PrimeMovers.OT, + fuel = PSY.ThermalFuels.OTHER, + active_power_limits = active_power_limits, + reactive_power_limits = (min = -1.0, max = 1.0), + ramp_limits = (up = 1.0, down = 1.0), + power_trajectory = (startup = 0.1, shutdown = 0.1), + time_limits = (up = 1.0, down = 1.0), + start_time_limits = (hot = 0.5, warm = 2.0, cold = 6.0), + start_types = 3, + operation_cost = cost, + base_power = base_power, + ) + PSY.add_component!(sys, gen) + return gen +end + +"""One-bus system with a single `ThermalMultiStart` carrying `cost`.""" +function one_bus_one_thermal_multistart( + cost::PSY.OperationalCost; + system_base_power::Float64 = 100.0, + kwargs..., +) + sys = PSY.System(system_base_power) + bus = _add_simple_bus!(sys) + _add_simple_thermal_multistart!(sys, bus, cost; kwargs...) + return sys +end + +"""Build an `OptimizationContainer` wrapping `sys` with the given `time_steps`.""" +function build_test_container( + sys::PSY.System, + time_steps::UnitRange{Int}; + resolution = Dates.Hour(1), +) + settings = IOM.Settings( + sys; + horizon = Dates.Hour(length(time_steps)), + resolution = resolution, + ) + container = IOM.OptimizationContainer( + sys, + settings, + JuMP.Model(), + PSY.Deterministic, + ) + IOM.set_time_steps!(container, time_steps) + return container +end + +""" +Allocate a JuMP variable at `(name, t)` in the `V`/`T` container (creating the container +if needed). Returns the new `VariableRef`. +""" +function add_jump_var!( + container::IOM.OptimizationContainer, + ::Type{V}, + ::Type{T}, + name::String, + t::Int, +) where {V <: IOM.VariableType, T} + if !IOM.has_container_key(container, V, T) + IOM.add_variable_container!( + container, + V, + T, + [name], + IOM.get_time_steps(container), + ) + end + var = JuMP.@variable( + IOM.get_jump_model(container), + base_name = "$(V)_$(name)_$(t)", + ) + IOM.get_variable(container, V, T)[name, t] = var + return var +end + +################################################################################# +# Objective coefficient inspection helpers +# +# All return the coefficient of a specific variable in a specific term bucket of the +# container's objective expression. Missing variable ⇒ 0.0 (JuMP.coefficient default). +################################################################################# + +"Coefficient of `get_variable(container, V, T)[name, t]` in the objective's invariant terms." +function obj_coef( + container::IOM.OptimizationContainer, + ::Type{V}, + ::Type{T}, + name::String, + t::Int, +) where {V <: IOM.VariableType, T} + inv = IOM.get_invariant_terms(IOM.get_objective_expression(container)) + return JuMP.coefficient(inv, IOM.get_variable(container, V, T)[name, t]) +end + +"Coefficient of `get_variable(container, V, T)[name, t]` in the objective's variant terms." +function obj_coef_variant( + container::IOM.OptimizationContainer, + ::Type{V}, + ::Type{T}, + name::String, + t::Int, +) where {V <: IOM.VariableType, T} + variant = IOM.get_variant_terms(IOM.get_objective_expression(container)) + return JuMP.coefficient(variant, IOM.get_variable(container, V, T)[name, t]) +end + +""" +Invariant-term coefficients of the PWL block-offer δ variables for `name` at time `t`, +one per segment, in order. +""" +function pwl_delta_coefs( + container::IOM.OptimizationContainer, + dir::IOM.OfferDirection, + ::Type{T}, + name::String, + t::Int, +) where {T} + V = IOM._block_offer_var(dir) + pwl = IOM.get_variable(container, V, T) + inv = IOM.get_invariant_terms(IOM.get_objective_expression(container)) + segs = sort!([k[2] for k in keys(pwl.data) if k[1] == name && k[3] == t]) + return [JuMP.coefficient(inv, pwl[(name, s, t)]) for s in segs] +end + +################################################################################# +# Time-series cost helpers +# +# Fabricate `ForecastKey`s and build `MarketBidTimeSeriesCost` / TS offer curves without +# attaching real time series data to the system — the TS dispatch path reads from +# pre-populated parameter containers (via `setup_delta_pwl_parameters!` and friends), not +# from the time-series store. Mirrors IOM's pattern in test/test_ts_value_curve_objective. +################################################################################# + +_stub_forecast_key(name::String) = IS.ForecastKey(; + time_series_type = IS.Deterministic, + name = name, + initial_timestamp = Dates.DateTime("2020-01-01"), + resolution = Dates.Hour(1), + horizon = Dates.Hour(24), + interval = Dates.Hour(24), + count = 1, + features = Dict{String, Any}(), +) + +"Construct a `CostCurve{TimeSeriesPiecewiseIncrementalCurve}` with stub TS keys." +function stub_ts_offer_curve(; + curve_name::String = "variable_cost", + initial_input_name::String = "initial_input", + power_units::PSY.UnitSystem = PSY.UnitSystem.SYSTEM_BASE, +) + vc = IS.TimeSeriesPiecewiseIncrementalCurve( + _stub_forecast_key(curve_name), + _stub_forecast_key(initial_input_name), + nothing, + ) + return PSY.CostCurve(vc, power_units) +end + +"Construct a minimal `ImportExportTimeSeriesCost` backed by stub TS keys." +function stub_ts_import_export_cost(; + power_units::PSY.UnitSystem = PSY.UnitSystem.SYSTEM_BASE, +) + return PSY.ImportExportTimeSeriesCost(; + import_offer_curves = stub_ts_offer_curve(; + curve_name = "variable_cost import", + initial_input_name = "initial_input import", + power_units = power_units, + ), + export_offer_curves = stub_ts_offer_curve(; + curve_name = "variable_cost export", + initial_input_name = "initial_input export", + power_units = power_units, + ), + ) +end + +"Construct a minimal `MarketBidTimeSeriesCost` backed by stub TS keys." +function stub_ts_market_bid_cost(; power_units::PSY.UnitSystem = PSY.UnitSystem.SYSTEM_BASE) + return PSY.MarketBidTimeSeriesCost(; + no_load_cost = PSY.TimeSeriesLinearCurve(_stub_forecast_key("no_load")), + start_up = IS.TupleTimeSeries{PSY.StartUpStages}(_stub_forecast_key("start_up")), + shut_down = PSY.TimeSeriesLinearCurve(_stub_forecast_key("shut_down")), + incremental_offer_curves = stub_ts_offer_curve(; + curve_name = "variable_cost incremental", + initial_input_name = "initial_input incremental", + power_units = power_units, + ), + decremental_offer_curves = stub_ts_offer_curve(; + curve_name = "variable_cost decremental", + initial_input_name = "initial_input decremental", + power_units = power_units, + ), + ) +end + +################################################################################# +# Parameter-container seeding helpers +# +# For TS MBC tests we skip `add_parameters!` (which would require real time series on +# the system) and populate the parameter containers directly with known Float64 values. +# Copied from IOM's `test/test_utils/objective_function_helpers.jl`. +################################################################################# + +""" +Populate a 2-D parameter container of size `(names × time_steps)` with `values`. The cell +eltype is taken from `values`, so scalar (`Matrix{Float64}`) and tuple-valued +(`Matrix{NTuple{3, Float64}}`) parameter types both work. +""" +function add_test_parameter!( + container::IOM.OptimizationContainer, + ::Type{P}, + ::Type{C}, + names::Vector{String}, + time_steps::UnitRange{Int}, + values::AbstractMatrix, +) where {P <: IOM.ParameterType, C} + T = eltype(values) + param_key = IOM.ParameterKey(P, C) + attributes = IOM.CostFunctionAttributes{T}( + (), IOM.SOSStatusVariable.NO_VARIABLE, false) + param_container = IOM.add_param_container_shared_axes!( + container, param_key, attributes, T, names, time_steps) + jump_model = IOM.get_jump_model(container) + for (i, name) in enumerate(names), t in time_steps + IOM.set_parameter!(param_container, jump_model, values[i, t], name, t) + IOM.set_multiplier!(param_container, 1.0, name, t) + end + return param_container +end + +"Populate a 3-D parameter container of size `(names × segments × time_steps)` with `values`." +function add_test_parameter!( + container::IOM.OptimizationContainer, + ::Type{P}, + ::Type{C}, + names::Vector{String}, + segments::UnitRange{Int}, + time_steps::UnitRange{Int}, + values::Array{Float64, 3}, +) where {P <: IOM.ParameterType, C} + param_key = IOM.ParameterKey(P, C) + attributes = IOM.CostFunctionAttributes{Float64}( + (), IOM.SOSStatusVariable.NO_VARIABLE, false) + param_container = IOM.add_param_container_shared_axes!( + container, param_key, attributes, Float64, names, segments, time_steps) + jump_model = IOM.get_jump_model(container) + for (i, name) in enumerate(names), (j, seg) in enumerate(segments), t in time_steps + IOM.set_parameter!(param_container, jump_model, values[i, j, t], name, seg, t) + IOM.set_multiplier!(param_container, 1.0, name, seg, t) + end + return param_container +end + +""" +Populate `{Incremental,Decremental}PiecewiseLinear{Slope,Breakpoint}Parameter` containers +for the delta PWL path. Each of `slopes` and `breakpoints` is a `(n_devices × n_times)` +matrix of Vectors; each segment/point Vector's length must be the same across all entries. +""" +function setup_delta_pwl_parameters!( + container::IOM.OptimizationContainer, + ::Type{C}, + names::Vector{String}, + slopes::Matrix{Vector{Float64}}, + breakpoints::Matrix{Vector{Float64}}, + time_steps::UnitRange{Int}; + dir::IOM.OfferDirection = IOM.IncrementalOffer(), +) where {C} + n_segments = length(first(slopes)) + n_points = n_segments + 1 + @assert all(length(s) == n_segments for s in slopes) + @assert all(length(b) == n_points for b in breakpoints) + + slope_vals = zeros(Float64, length(names), n_segments, length(time_steps)) + bp_vals = zeros(Float64, length(names), n_points, length(time_steps)) + for i in axes(slopes, 1), (ti, t) in enumerate(time_steps) + for k in 1:n_segments + slope_vals[i, k, ti] = slopes[i, t][k] + end + for k in 1:n_points + bp_vals[i, k, ti] = breakpoints[i, t][k] + end + end + add_test_parameter!( + container, IOM._slope_param(dir), C, names, 1:n_segments, time_steps, slope_vals) + add_test_parameter!( + container, IOM._breakpoint_param(dir), C, names, 1:n_points, time_steps, bp_vals) + return +end + +""" +Upper-bound widths encoded by the per-segment `δ_k ≤ breakpoints[k+1] - breakpoints[k]` +constraints that `add_pwl_block_offer_constraints!` emits as anonymous JuMP constraints. +Returns one value per segment, in order. +""" +function pwl_delta_widths( + container::IOM.OptimizationContainer, + dir::IOM.OfferDirection, + ::Type{T}, + name::String, + t::Int, +) where {T} + V = IOM._block_offer_var(dir) + pwl = IOM.get_variable(container, V, T) + jmodel = IOM.get_jump_model(container) + segs = sort!([k[2] for k in keys(pwl.data) if k[1] == name && k[3] == t]) + + # The width constraints have ScalarAffineFunction(δ) ≤ width. Index by VariableRef. + widths_by_var = Dict{JuMP.VariableRef, Float64}() + for cref in JuMP.all_constraints( + jmodel, JuMP.AffExpr, JuMP.MOI.LessThan{Float64}) + aff = JuMP.constraint_object(cref).func + JuMP.constant(aff) == 0.0 || continue + length(JuMP.linear_terms(aff)) == 1 || continue + (c, v), = JuMP.linear_terms(aff) + c == 1.0 || continue + widths_by_var[v] = JuMP.constraint_object(cref).set.upper + end + return [widths_by_var[pwl[(name, s, t)]] for s in segs] +end diff --git a/test/test_utils/mbc_system_utils.jl b/test/test_utils/mbc_system_utils.jl index 561f2d2..d273b8d 100644 --- a/test/test_utils/mbc_system_utils.jl +++ b/test/test_utils/mbc_system_utils.jl @@ -130,22 +130,17 @@ tweak_for_startup_shutdown!(sys::System) = tweak_system!(sys::System, 0.8, 1.0, tweak_for_decremental_initial!(sys::PSY.System) = tweak_system!(sys, 1.0, 1.2, 0.5) -"""Transfer the market bid cost from old_comp to new_comp, copying any time series in the process.""" +"""Transfer the market bid cost from old_comp to new_comp. + +Static `MarketBidCost` holds no time-series references, so a plain deepcopy is sufficient. +Callers that need TS MBCs build them via `extend_mbc!` after transfer.""" function transfer_mbc!( new_comp::PSY.Device, old_comp::PSY.Device, - new_sys::PSY.System, + ::PSY.System, ) mbc = deepcopy(get_operation_cost(old_comp)) @assert mbc isa PSY.MarketBidCost - for field in fieldnames(PSY.MarketBidCost) - val = getfield(mbc, field) - if val isa IS.TimeSeriesKey - ts = PSY.get_time_series(old_comp, val) - new_ts_key = add_time_series!(new_sys, new_comp, deepcopy(ts)) - setfield!(mbc, field, new_ts_key) - end - end set_operation_cost!(new_comp, mbc) return end @@ -153,40 +148,34 @@ end function zero_out_startup_shutdown_costs!(comp::PSY.Device) op_cost = get_operation_cost(comp)::MarketBidCost set_start_up!(op_cost, (hot = 0.0, warm = 0.0, cold = 0.0)) - set_shut_down!(op_cost, 0.0) + set_shut_down!(op_cost, LinearCurve(0.0)) end -"""Set everything except the incremental_offer_curves to zero on the MarketBidCost attached to the unit.""" -function zero_out_non_incremental_curve!(sys::PSY.System, unit::PSY.Component) +"""Set everything except the incremental_offer_curves to zero on the static MarketBidCost attached to the unit.""" +function zero_out_non_incremental_curve!(::PSY.System, unit::PSY.Component) cost = deepcopy(get_operation_cost(unit)::MarketBidCost) - set_no_load_cost!(cost, 0.0) + set_no_load_cost!(cost, LinearCurve(0.0)) set_start_up!(cost, (hot = 0.0, warm = 0.0, cold = 0.0)) - set_shut_down!(cost, 0.0) + set_shut_down!(cost, LinearCurve(0.0)) # set minimum generation cost (but not min gen power) to zero. - if get_incremental_offer_curves(cost) isa IS.TimeSeriesKey - zero_ts = make_deterministic_ts(sys, "initial_input", 0.0, 0.0, 0.0) - zero_ts_key = add_time_series!(sys, unit, zero_ts) - set_incremental_initial_input!(cost, zero_ts_key) - else - base_curve = get_value_curve(get_incremental_offer_curves(cost)) - x_coords = get_x_coords(base_curve) - slopes = get_slopes(base_curve) - new_curve = PiecewiseIncrementalCurve(0.0, x_coords, slopes) - set_incremental_offer_curves!(cost, CostCurve(new_curve)) - end + base_curve = get_value_curve(get_incremental_offer_curves(cost)) + x_coords = get_x_coords(base_curve) + slopes = get_slopes(base_curve) + new_curve = PiecewiseIncrementalCurve(0.0, x_coords, slopes) + set_incremental_offer_curves!(cost, CostCurve(new_curve)) set_operation_cost!(unit, cost) end -"Set the no_load_cost to `nothing` and the initial_input to the old no_load_cost. Not designed for time series" +"Zero out the no_load_cost and fold its value into the incremental curve's initial_input. Not designed for time series." function no_load_to_initial_input!(comp::Generator) cost = get_operation_cost(comp)::MarketBidCost - no_load = PSY.get_no_load_cost(cost) + no_load = get_proportional_term(PSY.get_no_load_cost(cost)) old_fd = get_function_data( get_value_curve(get_incremental_offer_curves(get_operation_cost(comp))), )::IS.PiecewiseStepData new_vc = PiecewiseIncrementalCurve(old_fd, no_load, nothing) set_incremental_offer_curves!(get_operation_cost(comp), CostCurve(new_vc)) - set_no_load_cost!(get_operation_cost(comp), nothing) + set_no_load_cost!(get_operation_cost(comp), LinearCurve(0.0)) return end @@ -209,59 +198,106 @@ function adjust_min_power!(sys) end end +# Convert the static offer curve at `direction` into a constant time-series-backed curve. +# Returns a `CostCurve{TimeSeriesPiecewiseIncrementalCurve}` suitable for the corresponding +# field of a `MarketBidTimeSeriesCost`. +function _constant_ts_offer_curve!( + sys::System, + comp::PSY.Component, + static_curve::CostCurve{PiecewiseIncrementalCurve}, + incr_or_decr::String, +) + baseline = get_value_curve(static_curve)::PiecewiseIncrementalCurve + init_ts = make_deterministic_ts( + sys, + "initial_input $(incr_or_decr)", + get_initial_input(baseline), + 0.0, + 0.0, + ) + pwl_ts = make_deterministic_ts( + sys, + "variable_cost $(incr_or_decr)", + get_function_data(baseline), + (0.0, 0.0, 0.0), + (0.0, 0.0, 0.0), + ) + init_key = add_time_series!(sys, comp, init_ts) + pwl_key = add_time_series!(sys, comp, pwl_ts) + return PSY.make_market_bid_ts_curve(pwl_key, init_key) +end + +# Promote `comp`'s static MarketBidCost to a `MarketBidTimeSeriesCost`, with all fields +# backed by time series. Offer curves and no_load_cost get constant-valued series; startup +# and shutdown optionally vary per `startup_incr`/`shutdown_incr = (res_incr, interval_incr)`. +# Returns the startup and shutdown Deterministic objects (callers compare to these). +function _promote_mbc_to_ts!( + sys::System, + comp::PSY.Component; + startup_incr::Tuple{Float64, Float64} = (0.0, 0.0), + shutdown_incr::Tuple{Float64, Float64} = (0.0, 0.0), + startup_base::Union{Nothing, NTuple{3, Float64}} = nothing, + shutdown_base::Union{Nothing, Float64} = nothing, +) + op_cost = get_operation_cost(comp)::MarketBidCost + su_base = isnothing(startup_base) ? Tuple(get_start_up(op_cost)) : startup_base + sd_base = if isnothing(shutdown_base) + get_proportional_term(get_shut_down(op_cost)) + else + shutdown_base + end + nl_base = get_proportional_term(get_no_load_cost(op_cost)) + + su_ts = make_deterministic_ts(sys, "start_up", su_base, startup_incr...) + sd_ts = make_deterministic_ts(sys, "shut_down", sd_base, shutdown_incr...) + nl_ts = make_deterministic_ts(sys, "no_load_cost", nl_base, 0.0, 0.0) + su_key = add_time_series!(sys, comp, su_ts) + sd_key = add_time_series!(sys, comp, sd_ts) + nl_key = add_time_series!(sys, comp, nl_ts) + + incr_curve = + _constant_ts_offer_curve!(sys, comp, get_incremental_offer_curves(op_cost), + "incremental") + decr_curve = + _constant_ts_offer_curve!(sys, comp, get_decremental_offer_curves(op_cost), + "decremental") + + new_cost = MarketBidTimeSeriesCost(; + no_load_cost = TimeSeriesLinearCurve(nl_key), + start_up = IS.TupleTimeSeries{PSY.StartUpStages}(su_key), + shut_down = TimeSeriesLinearCurve(sd_key), + incremental_offer_curves = incr_curve, + decremental_offer_curves = decr_curve, + ) + set_operation_cost!(comp, new_cost) + return su_ts, sd_ts +end + """ -Add startup and shutdown time series to a certain component. `with_increments`: whether the -elements should be increasing over time or constant. Version A: designed for -`c_fixed_market_bid_cost`. +Promote a ThermalStandard's static MBC to a TS MBC with varying startup/shutdown. +`with_increments`: whether the startup/shutdown series should vary over time. +Version A: designed for `c_fixed_market_bid_cost`. """ function add_startup_shutdown_ts_a!(sys::System, with_increments::Bool) - res_incr = with_increments ? 0.05 : 0.0 - interval_incr = with_increments ? 0.01 : 0.0 + incr = with_increments ? (0.05, 0.01) : (0.0, 0.0) unit1 = get_component(ThermalStandard, sys, "Test Unit1") - @assert get_operation_cost(unit1) isa MarketBidCost - startup_ts_1 = make_deterministic_ts( - sys, - "start_up", - (1.0, 1.5, 2.0), - res_incr, - interval_incr, + return _promote_mbc_to_ts!( + sys, unit1; + startup_incr = incr, + shutdown_incr = incr, + startup_base = (1.0, 1.5, 2.0), + shutdown_base = 0.5, ) - set_start_up!(sys, unit1, startup_ts_1) - shutdown_ts_1 = - make_deterministic_ts(sys, "shut_down", 0.5, res_incr, interval_incr) - set_shut_down!(sys, unit1, shutdown_ts_1) - return startup_ts_1, shutdown_ts_1 end """ -Add startup and shutdown time series to a certain component. `with_increments`: whether the -elements should be increasing over time or constant. Version B: designed for `c_sys5_pglib`. +Promote a ThermalMultiStart's static MBC to a TS MBC with varying startup/shutdown. +Version B: designed for `c_sys5_pglib`. """ function add_startup_shutdown_ts_b!(sys::System, with_increments::Bool) - res_incr = with_increments ? 0.05 : 0.0 - interval_incr = with_increments ? 0.01 : 0.0 + incr = with_increments ? (0.05, 0.01) : (0.0, 0.0) unit1 = get_component(ThermalMultiStart, sys, "115_STEAM_1") - base_startup = Tuple(get_start_up(get_operation_cost(unit1))) - base_shutdown = get_shut_down(get_operation_cost(unit1)) - @assert get_operation_cost(unit1) isa MarketBidCost - startup_ts_1 = make_deterministic_ts( - sys, - "start_up", - base_startup, - res_incr, - interval_incr, - ) - set_start_up!(sys, unit1, startup_ts_1) - shutdown_ts_1 = - make_deterministic_ts( - sys, - "shut_down", - base_shutdown, - res_incr, - interval_incr, - ) - set_shut_down!(sys, unit1, shutdown_ts_1) - return startup_ts_1, shutdown_ts_1 + return _promote_mbc_to_ts!(sys, unit1; startup_incr = incr, shutdown_incr = incr) end # functions for building the systems: calls the above @@ -352,7 +388,7 @@ function remove_thermal_mbcs!(sys::PSY.System) new_op_cost = ThermalGenerationCost(; variable = get_incremental_offer_curves(old_cost), start_up = get_start_up(old_cost), - shut_down = get_shut_down(old_cost), + shut_down = get_proportional_term(get_shut_down(old_cost)), fixed = 0.0, ) set_operation_cost!(comp, new_op_cost) @@ -462,9 +498,9 @@ function create_multistart_sys( set_operation_cost!( ms_comp, MarketBidCost(; - no_load_cost = nothing, + no_load_cost = LinearCurve(0.0), start_up = (hot = 300.0, warm = 450.0, cold = 500.0), - shut_down = 100.0, + shut_down = LinearCurve(100.0), incremental_offer_curves = CostCurve(new_ic), ), ) diff --git a/test/test_utils/model_checks.jl b/test/test_utils/model_checks.jl index 5b275ca..731b8ce 100644 --- a/test/test_utils/model_checks.jl +++ b/test/test_utils/model_checks.jl @@ -522,7 +522,7 @@ function check_constraint_count( PSY.get_name.( IOM._get_ramp_constraint_devices( container, - get_available_components(T, model.sys), + PSY.get_available_components(T, model.sys), ), ) check_constraint_count(