From 3294e395f0c8fc2270e32e2874b718dc131f0ad6 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 10 Apr 2026 15:30:04 -0600 Subject: [PATCH 01/10] Update POM to work with IOM main - Port serialize_problem from IOM (removed there in deduplicate-opt-container) - Adapt to _check_branch_network_compatibility signature change (3-arg to 2-arg) - Remove system_to_file kwarg from tests (removed from IOM Settings) - Trim deserialization test (deserialize_problem not yet ported) - Bump PNM compat to ^0.19 to match IOM - Point test env at local IOM Co-Authored-By: Claude Opus 4.6 (1M context) --- Project.toml | 3 +- src/PowerOperationsModels.jl | 1 + .../instantiate_network_model.jl | 33 ++++++++-- .../operation_model_serialization.jl | 66 +++++++++++++++++++ test/Project.toml | 1 - test/performance/performance_test.jl | 2 - test/test_device_branch_constructors.jl | 5 -- test/test_device_hydro_constructors.jl | 3 - test/test_device_load_constructors.jl | 3 - ..._device_thermal_generation_constructors.jl | 7 -- test/test_model_decision.jl | 20 +----- test/test_utils/run_simulation.jl | 3 - 12 files changed, 99 insertions(+), 48 deletions(-) create mode 100644 src/operation/operation_model_serialization.jl diff --git a/Project.toml b/Project.toml index 15c0a8f..87185af 100644 --- a/Project.toml +++ b/Project.toml @@ -28,7 +28,6 @@ PowerFlowsExt = "PowerFlows" [sources] InfrastructureSystems = {url = "https://github.com/NREL-Sienna/InfrastructureSystems.jl", rev = "IS4"} -InfrastructureOptimizationModels = {url = "https://github.com/NREL-Sienna/InfrastructureOptimizationModels.jl", rev = "main"} [compat] Dates = "1" @@ -37,7 +36,7 @@ InfrastructureOptimizationModels = "0.1" InfrastructureSystems = "3" InteractiveUtils = "1.11.0" JuMP = "^1.28" -PowerNetworkMatrices = "^0.18" +PowerNetworkMatrices = "^0.19" PowerSystems = "5.3" ProgressMeter = "1.11.0" TimerOutputs = "~0.5" diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index e24ea91..859a100 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -280,6 +280,7 @@ include("area_interchange.jl") # Operation lifecycle: build/solve/run include("operation/build_problem.jl") include("initial_conditions/initialization.jl") +include("operation/operation_model_serialization.jl") include("operation/decision_model.jl") include("operation/emulation_model.jl") diff --git a/src/network_models/instantiate_network_model.jl b/src/network_models/instantiate_network_model.jl index 5a881b1..eb15ae7 100644 --- a/src/network_models/instantiate_network_model.jl +++ b/src/network_models/instantiate_network_model.jl @@ -66,6 +66,29 @@ _assign_subnetworks_to_buses( ::PSY.System, ) where {T <: AbstractPowerModel} = nothing +function _get_unmodeled_branch_types( + branch_models::BranchModelContainer, + sys::PSY.System, +) + unmodeled = DataType[] + for d in PSY.get_existing_device_types(sys) + if d <: PSY.ACTransmission && !haskey(branch_models, Symbol(d)) + push!(unmodeled, d) + end + end + return unmodeled +end + +function _validate_network_and_branches( + model::NetworkModel, + branch_models::BranchModelContainer, + sys::PSY.System, +) + unmodeled = _get_unmodeled_branch_types(branch_models, sys) + IOM._check_branch_network_compatibility(model, unmodeled) + return +end + ################################################################################# # Generic fallback for AbstractPowerModel (Ybus-based models: ACP, ACR, etc.) ################################################################################# @@ -76,7 +99,7 @@ function IOM.instantiate_network_model!( number_of_steps::Int, sys::PSY.System, ) where {T <: AbstractPowerModel} - IOM._check_branch_network_compatibility(model, branch_models, sys) + _validate_network_and_branches(model, branch_models, sys) if isempty(model.subnetworks) model.subnetworks = PNM.find_subnetworks(sys) end @@ -127,7 +150,7 @@ function IOM.instantiate_network_model!( number_of_steps::Int, sys::PSY.System, ) - IOM._check_branch_network_compatibility(model, branch_models, sys) + _validate_network_and_branches(model, branch_models, sys) PNM.populate_branch_maps_by_type!(model.network_reduction) empty!(model.reduced_branch_tracker) IOM.set_number_of_steps!(model.reduced_branch_tracker, number_of_steps) @@ -144,7 +167,7 @@ function IOM.instantiate_network_model!( number_of_steps::Int, sys::PSY.System, ) - IOM._check_branch_network_compatibility(model, branch_models, sys) + _validate_network_and_branches(model, branch_models, sys) if isempty(model.subnetworks) model.subnetworks = PNM.find_subnetworks(sys) end @@ -168,7 +191,7 @@ function IOM.instantiate_network_model!( number_of_steps::Int, sys::PSY.System, ) - IOM._check_branch_network_compatibility(model, branch_models, sys) + _validate_network_and_branches(model, branch_models, sys) if IOM.get_PTDF_matrix(model) === nothing @info "PTDF Matrix not provided. Calculating using PowerNetworkMatrices.PTDF" if model.reduce_radial_branches && model.reduce_degree_two_branches @@ -258,7 +281,7 @@ function IOM.instantiate_network_model!( number_of_steps::Int, sys::PSY.System, ) - IOM._check_branch_network_compatibility(model, branch_models, sys) + _validate_network_and_branches(model, branch_models, sys) if IOM.get_PTDF_matrix(model) === nothing @info "PTDF Matrix not provided. Calculating using PowerNetworkMatrices.PTDF" if model.reduce_radial_branches && model.reduce_degree_two_branches diff --git a/src/operation/operation_model_serialization.jl b/src/operation/operation_model_serialization.jl new file mode 100644 index 0000000..e948491 --- /dev/null +++ b/src/operation/operation_model_serialization.jl @@ -0,0 +1,66 @@ +const _SERIALIZED_MODEL_FILENAME = "model.bin" + +struct OptimizerAttributes + name::String + version::String + attributes::Any +end + +function OptimizerAttributes( + model::IOM.OperationModel, + optimizer::IOM.MOI.OptimizerWithAttributes, +) + jump_model = IOM.get_jump_model(model) + name = JuMP.solver_name(jump_model) + # Note that this uses private field access to MOI.OptimizerWithAttributes because there + # is no public method available. + # This could break if MOI changes their implementation. + try + version = IOM.MOI.get(JuMP.backend(jump_model), IOM.MOI.SolverVersion()) + return OptimizerAttributes(name, version, optimizer.params) + catch + @debug "Solver Version not supported by the solver" + version = "MOI.SolverVersion not supported" + return OptimizerAttributes(name, version, optimizer.params) + end +end + +function _get_optimizer_attributes(model::IOM.OperationModel) + return IOM.get_optimizer(IOM.get_settings(model)).params +end + +struct ProblemSerializationWrapper + template::IOM.AbstractProblemTemplate + sys::Union{Nothing, String} + settings::IOM.Settings + model_type::DataType + name::String + optimizer::OptimizerAttributes +end + +function serialize_problem(model::IOM.OperationModel; optimizer = nothing) + # A PowerSystem cannot be serialized in this format because of how it stores + # time series data. Use its specialized serialization method instead. + sys = IOM.get_system(model) + sys_filename = + joinpath(IOM.get_output_dir(model), IOM.make_system_filename(sys)) + # Skip serialization if the system is already in the folder + !ispath(sys_filename) && PSY.to_json(sys, sys_filename) + + if optimizer === nothing + optimizer = IOM.get_optimizer(IOM.get_settings(model)) + @assert optimizer !== nothing "optimizer must be passed if it wasn't saved in Settings" + end + + obj = ProblemSerializationWrapper( + model.template, + sys_filename, + deepcopy(IOM.get_settings(model)), + typeof(model), + string(IOM.get_name(model)), + OptimizerAttributes(model, optimizer), + ) + bin_file_name = joinpath(IOM.get_output_dir(model), _SERIALIZED_MODEL_FILENAME) + Serialization.serialize(bin_file_name, obj) + @info "Serialized OperationModel to" bin_file_name +end diff --git a/test/Project.toml b/test/Project.toml index e676ec5..91d6928 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -32,7 +32,6 @@ TimerOutputs = "a759f4b9-e2f1-59dc-863e-4aeb61b1ea8f" UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [sources] -InfrastructureOptimizationModels = {rev = "main", url = "https://github.com/NREL-Sienna/InfrastructureOptimizationModels.jl"} InfrastructureSystems = {rev = "IS4", url = "https://github.com/NREL-Sienna/InfrastructureSystems.jl"} [compat] diff --git a/test/performance/performance_test.jl b/test/performance/performance_test.jl index 768a4a9..7b15857 100644 --- a/test/performance/performance_test.jl +++ b/test/performance/performance_test.jl @@ -89,7 +89,6 @@ try optimizer = optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.01, "log_to_console" => false), - system_to_file = false, initialize_model = true, optimizer_solve_log_print = false, direct_mode_optimizer = true, @@ -103,7 +102,6 @@ try optimizer = optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.01, "log_to_console" => false), - system_to_file = false, initialize_model = true, check_numerical_bounds = false, ) diff --git a/test/test_device_branch_constructors.jl b/test/test_device_branch_constructors.jl index 7d6fda7..6e9d3d5 100644 --- a/test/test_device_branch_constructors.jl +++ b/test/test_device_branch_constructors.jl @@ -272,7 +272,6 @@ end sys_5; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = false, ) build!(model; output_dir = mktempdir()) @@ -289,7 +288,6 @@ end sys_5; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = false, ) solve!(model; output_dir = mktempdir()) dcp_vars = @@ -346,7 +344,6 @@ end sys_5; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = false, store_variable_names = true, ) @@ -379,7 +376,6 @@ end sys_5; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = false, ) solve!(model; output_dir = mktempdir()) @@ -434,7 +430,6 @@ end sys_5; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = false, ) solve!(model_wl; output_dir = mktempdir()) diff --git a/test/test_device_hydro_constructors.jl b/test/test_device_hydro_constructors.jl index 1b56ca5..a04fdf6 100644 --- a/test/test_device_hydro_constructors.jl +++ b/test/test_device_hydro_constructors.jl @@ -597,7 +597,6 @@ end optimizer = ipopt_optimizer, optimizer_solve_log_print = true, store_variable_names = true, - system_to_file = true, horizon = Hour(24), ) @@ -724,7 +723,6 @@ end sys; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = false, store_variable_names = true, optimizer_solve_log_print = false, ) @@ -773,7 +771,6 @@ end sys; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = false, store_variable_names = true, optimizer_solve_log_print = false, ) diff --git a/test/test_device_load_constructors.jl b/test/test_device_load_constructors.jl index 1c2855a..38309fa 100644 --- a/test/test_device_load_constructors.jl +++ b/test/test_device_load_constructors.jl @@ -112,7 +112,6 @@ end c_sys5_il; name = "UC_fixed_market_bid_cost", optimizer = HiGHS_optimizer, - system_to_file = false, optimizer_solve_log_print = true) @test build!(model; output_dir = test_path) == IOM.ModelBuildStatus.BUILT @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED @@ -189,7 +188,6 @@ end name = "UC", store_variable_names = true, optimizer = solvers[ix], - system_to_file = false, ) @test build!(model; output_dir = mktempdir(; cleanup = true)) == @@ -234,7 +232,6 @@ end name = "UC", store_variable_names = true, optimizer = solvers[ix], - system_to_file = false, ) @test build!(model; output_dir = mktempdir(; cleanup = true)) == diff --git a/test/test_device_thermal_generation_constructors.jl b/test/test_device_thermal_generation_constructors.jl index 483f02d..87d828d 100644 --- a/test/test_device_thermal_generation_constructors.jl +++ b/test/test_device_thermal_generation_constructors.jl @@ -24,7 +24,6 @@ const TIME1 = DateTime("2024-01-01T00:00:00") sys; name = "UC_$(i)", optimizer = HiGHS_optimizer, - system_to_file = false, optimizer_solve_log_print = true, ) @test build!(model; output_dir = test_path) == IOM.ModelBuildStatus.BUILT @@ -59,7 +58,6 @@ const TIME1 = DateTime("2024-01-01T00:00:00") sys_no_startup; name = "UC_no_startup", optimizer = HiGHS_optimizer, - system_to_file = false, optimizer_solve_log_print = true, ) @test build!(model_no_startup; output_dir = test_path) == IOM.ModelBuildStatus.BUILT @@ -88,7 +86,6 @@ const TIME1 = DateTime("2024-01-01T00:00:00") sys_with_startup; name = "UC_with_startup", optimizer = HiGHS_optimizer, - system_to_file = false, optimizer_solve_log_print = true, ) @test build!(model_with_startup; output_dir = test_path) == @@ -138,7 +135,6 @@ end sys; name = "UC_$(i)", optimizer = HiGHS_optimizer, - system_to_file = false, ) @test build!(model; output_dir = test_path) == IOM.ModelBuildStatus.BUILT @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED @@ -1164,7 +1160,6 @@ end =# sys_5; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = false, store_variable_names = true, rebuild_model = rebuild, ) @@ -1260,7 +1255,6 @@ end sys; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = false, store_variable_names = true, optimizer_solve_log_print = false, ) @@ -1368,7 +1362,6 @@ end =# sys; name = "UC", optimizer = ipopt_optimizer, - system_to_file = false, store_variable_names = true, optimizer_solve_log_print = false, ) diff --git a/test/test_model_decision.jl b/test/test_model_decision.jl index 13652aa..127333b 100644 --- a/test/test_model_decision.jl +++ b/test/test_model_decision.jl @@ -311,6 +311,8 @@ end IOM.RunStatus.SUCCESSFULLY_FINALIZED end +# TODO: Deserialization constructor (DecisionModel(path, optimizer)) was removed from IOM. +# Port deserialize_problem to POM to re-enable this test. @testset "Test Serialization, deserialization and write optimizer problem" begin fpath = mktempdir(; cleanup = true) sys = PSB.build_system(PSITestSystems, "c_sys5_re") @@ -324,23 +326,7 @@ end file_list = sort!(collect(readdir(fpath))) model_name = IOM.get_name(model) @test IOM._JUMP_MODEL_FILENAME in file_list - @test IOM._SERIALIZED_MODEL_FILENAME in file_list - ED2 = DecisionModel(fpath, HiGHS_optimizer) - @test build!(ED2; output_dir = fpath) == IOM.ModelBuildStatus.BUILT - psi_checksolve_test(ED2, [MOI.OPTIMAL], 240000.0, 10000) - - path2 = mktempdir(; cleanup = true) - model_no_sys = - DecisionModel(template, sys; optimizer = HiGHS_optimizer, system_to_file = false) - - @test build!(model_no_sys; output_dir = path2) == IOM.ModelBuildStatus.BUILT - @test solve!(model_no_sys) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - file_list = sort!(collect(readdir(path2))) - @test !any(occursin.(r"\.h5$", file_list)) - ED3 = DecisionModel(path2, HiGHS_optimizer; system = sys) - build!(ED3; output_dir = path2) - psi_checksolve_test(ED3, [MOI.OPTIMAL], 240000.0, 10000) + @test POM._SERIALIZED_MODEL_FILENAME in file_list end @testset "Test NonSpinning reserve model" begin diff --git a/test/test_utils/run_simulation.jl b/test/test_utils/run_simulation.jl index 7d46ea5..0b4bda9 100644 --- a/test/test_utils/run_simulation.jl +++ b/test/test_utils/run_simulation.jl @@ -11,7 +11,6 @@ function run_simulation( file_path::String, export_path; in_memory = false, - system_to_file = true, uc_network_model = nothing, ed_network_model = nothing, ) @@ -45,14 +44,12 @@ function run_simulation( c_sys5_hy_uc; name = "UC", optimizer = HiGHS_optimizer, - system_to_file = system_to_file, ), DecisionModel( template_ed, c_sys5_hy_ed; name = "ED", optimizer = ipopt_optimizer, - system_to_file = system_to_file, ), ], ) From 698925f5bcf277ecc81df340169553f750249d72 Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 13 Apr 2026 15:44:51 -0400 Subject: [PATCH 02/10] restore deserialization test; restore new pwl names; remove op model serialization (restored in IOM); --- src/PowerOperationsModels.jl | 8 +-- src/common_models/market_bid_overrides.jl | 16 ++--- src/core/problem_template.jl | 8 +-- src/energy_storage_models/storage_models.jl | 4 +- .../operation_model_serialization.jl | 66 ------------------- src/services_models/reserves.jl | 2 +- .../thermal_generation.jl | 6 +- test/test_model_decision.jl | 20 +++++- 8 files changed, 39 insertions(+), 91 deletions(-) delete mode 100644 src/operation/operation_model_serialization.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 1205b36..c9a0957 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -141,8 +141,8 @@ import InfrastructureOptimizationModels: _include_constant_min_gen_power_in_constraint, add_variable_cost_to_objective!, _vom_offer_direction, - _add_pwl_constraint!, - add_pwl_term!, + 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, @@ -175,7 +175,7 @@ import InfrastructureOptimizationModels: set_service_model!, finalize_template!, make_empty_jump_model_with_settings, - _set_model! + set_model! using InfrastructureOptimizationModels # TODO: use explicit imports. @@ -280,7 +280,7 @@ include("area_interchange.jl") # Operation lifecycle: build/solve/run include("operation/build_problem.jl") include("initial_conditions/initialization.jl") -include("operation/operation_model_serialization.jl") +include("operation/template_validation.jl") include("operation/decision_model.jl") include("operation/emulation_model.jl") diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index 4de701b..10e6ea8 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -175,7 +175,7 @@ function add_variable_cost_to_objective!( ::ImportExportSourceModel, ) isnothing(get_output_offer_curves(cost_function)) && return - add_pwl_term!( + add_pwl_term_delta!( IncrementalOffer(), container, component, @@ -194,7 +194,7 @@ function add_variable_cost_to_objective!( ::ImportExportSourceModel, ) isnothing(get_input_offer_curves(cost_function)) && return - add_pwl_term!( + add_pwl_term_delta!( DecrementalOffer(), container, component, @@ -221,7 +221,7 @@ function add_variable_cost_to_objective!( if !(isnothing(get_output_offer_curves(cost_function))) error("Component $(component_name) is not allowed to participate as a supply.") end - add_pwl_term!( + add_pwl_term_delta!( DecrementalOffer(), container, component, @@ -241,7 +241,7 @@ _vom_offer_direction(::AbstractControllablePowerLoadFormulation) = DecrementalOf """ PWL block offer constraints for ORDC (ReserveDemandCurve). """ -function _add_pwl_constraint!( +function add_pwl_constraint_delta!( container::OptimizationContainer, component::T, ::U, @@ -273,7 +273,7 @@ end """ PWL cost terms for StepwiseCostReserve (AbstractServiceFormulation). """ -function add_pwl_term!( +function add_pwl_term_delta!( container::OptimizationContainer, component::T, cost_data::PSY.CostCurve{PSY.PiecewiseIncrementalCurve}, @@ -300,7 +300,7 @@ function add_pwl_term!( slopes = IS.get_y_coords(data) break_points = PSY.get_x_coords(data) for t in time_steps - pwl_vars = add_pwl_variables!( + pwl_vars = add_pwl_variables_delta!( container, PiecewiseLinearBlockIncrementalOffer, T, @@ -309,9 +309,9 @@ function add_pwl_term!( length(slopes); upper_bound = Inf, ) - add_pwl_constraint!(container, component, U(), break_points, pwl_vars, t) + add_pwl_constraint_delta!(container, component, U(), break_points, pwl_vars, t) pwl_cost_expressions[t] = - get_pwl_cost_expression(pwl_vars, slopes, multiplier * dt) + get_pwl_cost_expression_delta(pwl_vars, slopes, multiplier * dt) end return pwl_cost_expressions end diff --git a/src/core/problem_template.jl b/src/core/problem_template.jl index e230078..5bdfa61 100644 --- a/src/core/problem_template.jl +++ b/src/core/problem_template.jl @@ -140,7 +140,7 @@ function set_device_model!( template::OperationsProblemTemplate, model::DeviceModel{D}, ) where {D <: IS.InfrastructureSystemsComponent} - _set_model!(template.devices, model) + set_model!(template.devices, model) return end @@ -152,7 +152,7 @@ function set_device_model!( template::OperationsProblemTemplate, model::DeviceModel{D}, ) where {D <: PSY.Branch} - _set_model!(template.branches, model) + set_model!(template.branches, model) return end @@ -191,7 +191,7 @@ function set_service_model!( service_name::String, model::ServiceModel{T, <:AbstractServiceFormulation}, ) where {T <: PSY.Service} - _set_model!(template.services, (service_name, Symbol(T)), model) + set_model!(template.services, (service_name, Symbol(T)), model) return end @@ -199,7 +199,7 @@ function set_service_model!( template::OperationsProblemTemplate, model::ServiceModel{<:PSY.Service, <:AbstractServiceFormulation}, ) - _set_model!(template.services, model) + set_model!(template.services, model) return end diff --git a/src/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index 8396fdc..1f3730d 100644 --- a/src/energy_storage_models/storage_models.jl +++ b/src/energy_storage_models/storage_models.jl @@ -1916,7 +1916,7 @@ function _add_variable_cost_to_objective!( @debug "Market Bid" _group = LOG_GROUP_COST_FUNCTIONS component_name incremental_cost_curves = PSY.get_incremental_offer_curves(cost_function) if !isnothing(incremental_cost_curves) - add_pwl_term!( + add_pwl_term_delta!( IncrementalOffer(), container, component, @@ -1942,7 +1942,7 @@ function _add_variable_cost_to_objective!( @debug "Market Bid" _group = LOG_GROUP_COST_FUNCTIONS component_name decremental_cost_curves = PSY.get_decremental_offer_curves(cost_function) if !isnothing(decremental_cost_curves) - add_pwl_term!( + add_pwl_term_delta!( DecrementalOffer(), container, component, diff --git a/src/operation/operation_model_serialization.jl b/src/operation/operation_model_serialization.jl deleted file mode 100644 index e948491..0000000 --- a/src/operation/operation_model_serialization.jl +++ /dev/null @@ -1,66 +0,0 @@ -const _SERIALIZED_MODEL_FILENAME = "model.bin" - -struct OptimizerAttributes - name::String - version::String - attributes::Any -end - -function OptimizerAttributes( - model::IOM.OperationModel, - optimizer::IOM.MOI.OptimizerWithAttributes, -) - jump_model = IOM.get_jump_model(model) - name = JuMP.solver_name(jump_model) - # Note that this uses private field access to MOI.OptimizerWithAttributes because there - # is no public method available. - # This could break if MOI changes their implementation. - try - version = IOM.MOI.get(JuMP.backend(jump_model), IOM.MOI.SolverVersion()) - return OptimizerAttributes(name, version, optimizer.params) - catch - @debug "Solver Version not supported by the solver" - version = "MOI.SolverVersion not supported" - return OptimizerAttributes(name, version, optimizer.params) - end -end - -function _get_optimizer_attributes(model::IOM.OperationModel) - return IOM.get_optimizer(IOM.get_settings(model)).params -end - -struct ProblemSerializationWrapper - template::IOM.AbstractProblemTemplate - sys::Union{Nothing, String} - settings::IOM.Settings - model_type::DataType - name::String - optimizer::OptimizerAttributes -end - -function serialize_problem(model::IOM.OperationModel; optimizer = nothing) - # A PowerSystem cannot be serialized in this format because of how it stores - # time series data. Use its specialized serialization method instead. - sys = IOM.get_system(model) - sys_filename = - joinpath(IOM.get_output_dir(model), IOM.make_system_filename(sys)) - # Skip serialization if the system is already in the folder - !ispath(sys_filename) && PSY.to_json(sys, sys_filename) - - if optimizer === nothing - optimizer = IOM.get_optimizer(IOM.get_settings(model)) - @assert optimizer !== nothing "optimizer must be passed if it wasn't saved in Settings" - end - - obj = ProblemSerializationWrapper( - model.template, - sys_filename, - deepcopy(IOM.get_settings(model)), - typeof(model), - string(IOM.get_name(model)), - OptimizerAttributes(model, optimizer), - ) - bin_file_name = joinpath(IOM.get_output_dir(model), _SERIALIZED_MODEL_FILENAME) - Serialization.serialize(bin_file_name, obj) - @info "Serialized OperationModel to" bin_file_name -end diff --git a/src/services_models/reserves.jl b/src/services_models/reserves.jl index 7eee109..a6b63d7 100644 --- a/src/services_models/reserves.jl +++ b/src/services_models/reserves.jl @@ -543,7 +543,7 @@ function _add_reserves_variable_cost_to_objective!( end pwl_cost_expressions = - add_pwl_term!(container, component, variable_cost, T(), U()) + add_pwl_term_delta!(container, component, variable_cost, T(), U()) for t in time_steps add_to_expression!( container, diff --git a/src/static_injector_models/thermal_generation.jl b/src/static_injector_models/thermal_generation.jl index 3e41292..0f7efd2 100644 --- a/src/static_injector_models/thermal_generation.jl +++ b/src/static_injector_models/thermal_generation.jl @@ -1559,7 +1559,7 @@ Add PWL cost terms for ThermalDispatchNoMin formulation. Rejects non-convex or negative-slope PWL data since ThermalDispatchNoMin cannot use SOS-2 formulations. """ -function IOM.add_pwl_term!( +function IOM.add_pwl_term_lambda!( container::IOM.OptimizationContainer, component::T, cost_function::Union{ @@ -1621,7 +1621,7 @@ function IOM.add_pwl_term!( temp_cost_function = IOM.create_temporary_cost_function_in_system_per_unit(cost_function, data) for t in time_steps - IOM.add_pwl_variables!(container, T, name, t, data) + IOM.add_pwl_variables_lambda!(container, T, name, t, data) power_var = IOM.get_variable(container, U(), T)[name, t] IOM._add_pwl_constraint_standard!( container, @@ -1632,7 +1632,7 @@ function IOM.add_pwl_term!( power_var, ) pwl_cost = - IOM.get_pwl_cost_expression( + IOM.get_pwl_cost_expression_lambda( container, component, t, diff --git a/test/test_model_decision.jl b/test/test_model_decision.jl index 127333b..13652aa 100644 --- a/test/test_model_decision.jl +++ b/test/test_model_decision.jl @@ -311,8 +311,6 @@ end IOM.RunStatus.SUCCESSFULLY_FINALIZED end -# TODO: Deserialization constructor (DecisionModel(path, optimizer)) was removed from IOM. -# Port deserialize_problem to POM to re-enable this test. @testset "Test Serialization, deserialization and write optimizer problem" begin fpath = mktempdir(; cleanup = true) sys = PSB.build_system(PSITestSystems, "c_sys5_re") @@ -326,7 +324,23 @@ end file_list = sort!(collect(readdir(fpath))) model_name = IOM.get_name(model) @test IOM._JUMP_MODEL_FILENAME in file_list - @test POM._SERIALIZED_MODEL_FILENAME in file_list + @test IOM._SERIALIZED_MODEL_FILENAME in file_list + ED2 = DecisionModel(fpath, HiGHS_optimizer) + @test build!(ED2; output_dir = fpath) == IOM.ModelBuildStatus.BUILT + psi_checksolve_test(ED2, [MOI.OPTIMAL], 240000.0, 10000) + + path2 = mktempdir(; cleanup = true) + model_no_sys = + DecisionModel(template, sys; optimizer = HiGHS_optimizer, system_to_file = false) + + @test build!(model_no_sys; output_dir = path2) == IOM.ModelBuildStatus.BUILT + @test solve!(model_no_sys) == IOM.RunStatus.SUCCESSFULLY_FINALIZED + + file_list = sort!(collect(readdir(path2))) + @test !any(occursin.(r"\.h5$", file_list)) + ED3 = DecisionModel(path2, HiGHS_optimizer; system = sys) + build!(ED3; output_dir = path2) + psi_checksolve_test(ED3, [MOI.OPTIMAL], 240000.0, 10000) end @testset "Test NonSpinning reserve model" begin From 72eac8af9eaacfe22f7677bda58360137be43eef Mon Sep 17 00:00:00 2001 From: Anthony Costarelli Date: Mon, 13 Apr 2026 16:49:09 -0400 Subject: [PATCH 03/10] remove problem serialization test --- test/test_model_decision.jl | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/test/test_model_decision.jl b/test/test_model_decision.jl index 13652aa..02344c6 100644 --- a/test/test_model_decision.jl +++ b/test/test_model_decision.jl @@ -311,38 +311,6 @@ end IOM.RunStatus.SUCCESSFULLY_FINALIZED end -@testset "Test Serialization, deserialization and write optimizer problem" begin - fpath = mktempdir(; cleanup = true) - sys = PSB.build_system(PSITestSystems, "c_sys5_re") - template = get_template_dispatch_with_network( - NetworkModel(CopperPlatePowerModel; duals = [CopperPlateBalanceConstraint]), - ) - model = DecisionModel(template, sys; optimizer = HiGHS_optimizer) - @test build!(model; output_dir = fpath) == IOM.ModelBuildStatus.BUILT - @test solve!(model) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - file_list = sort!(collect(readdir(fpath))) - model_name = IOM.get_name(model) - @test IOM._JUMP_MODEL_FILENAME in file_list - @test IOM._SERIALIZED_MODEL_FILENAME in file_list - ED2 = DecisionModel(fpath, HiGHS_optimizer) - @test build!(ED2; output_dir = fpath) == IOM.ModelBuildStatus.BUILT - psi_checksolve_test(ED2, [MOI.OPTIMAL], 240000.0, 10000) - - path2 = mktempdir(; cleanup = true) - model_no_sys = - DecisionModel(template, sys; optimizer = HiGHS_optimizer, system_to_file = false) - - @test build!(model_no_sys; output_dir = path2) == IOM.ModelBuildStatus.BUILT - @test solve!(model_no_sys) == IOM.RunStatus.SUCCESSFULLY_FINALIZED - - file_list = sort!(collect(readdir(path2))) - @test !any(occursin.(r"\.h5$", file_list)) - ED3 = DecisionModel(path2, HiGHS_optimizer; system = sys) - build!(ED3; output_dir = path2) - psi_checksolve_test(ED3, [MOI.OPTIMAL], 240000.0, 10000) -end - @testset "Test NonSpinning reserve model" begin c_sys5 = PSB.build_system(PSITestSystems, "c_sys5_uc_non_spin"; add_reserves = true) template = get_thermal_standard_uc_template() From 5d00c459ff69b50ba86bac007fa8b811f1e279dc Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Tue, 14 Apr 2026 11:35:44 -0600 Subject: [PATCH 04/10] update POM for PSY MarketBidCost/ImportExportCost static/TS split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PSY split MarketBidCost and ImportExportCost into separate static and time-series-backed types (MarketBidCost + MarketBidTimeSeriesCost, ImportExportCost + ImportExportTimeSeriesCost). This mirrors the IOM update (PR #74) on the POM side. Dispatch changes: - Consolidate MBC OnVariable proportional_cost + is_time_variant_term into a single generic pair in market_bid_overrides.jl, keyed on an _onvar_offer_direction trait (Generator → IncrementalOffer, ControllableLoad → DecrementalOffer). Per-device thermal/hydro/load overrides drop out. - Replace stale `isnothing(get_{input,output}_offer_curves(...))` checks with IOM.is_nontrivial_offer (ZERO_OFFER_CURVE is now the default, not nothing). Load dispatch rejects non-trivial supply with ArgumentError. - Delegate hydro's add_proportional_cost! to IOM's shared add_proportional_cost_maybe_time_variant! (drops a broken _lookup_maybe_time_variant_param path). - Collapse is_time_variant_term / Type-based signatures to match IOM. - _get_time_series_name for *CostAtMinParameter traverses through the curve (initial_input now lives inside TimeSeriesPiecewiseIncrementalCurve, not on MBC directly). Test utilities rewritten for the new types. Existing MBC/IEC test helpers (add_market_bid_cost.jl, mbc_system_utils.jl, iec_simulation_utils.jl) updated to use LinearCurve fields and the new TS cost constructors. New unit-test suite for MBC/IEC objective-function construction: - test/test_market_bid_cost.jl: Load (PowerLoadDispatch, PowerLoadInterruption) and Thermal (BasicUC, MultiStartUC) with static and TS costs. One testset per (device × formulation × cost-type); dedicated scaling testsets cover dt / unit conversion. - test/test_import_export_cost.jl: Source + ImportExportSourceModel with static and TS IEC, distinct import/export breakpoints. - test/test_utils/mbc_math_helpers.jl: minimal system builders, OptimizationContainer setup, JuMP variable seeding, objective coefficient / PWL delta coefficient / PWL delta width inspection, stub TS curve/MBC construction, parameter seeding helpers. Flagged FIXME: Source IEC dispatch always adds PWL for both directions (previous isnothing guard is dead; a one-directional Source would benefit from a cheap TS-curve emptiness check once available). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/common_models/add_parameters.jl | 20 +- src/common_models/market_bid_overrides.jl | 102 +++- src/energy_storage_models/storage_models.jl | 4 +- src/static_injector_models/electric_loads.jl | 41 +- .../hydro_generation.jl | 65 +-- .../thermal_generation.jl | 22 +- test/includes.jl | 1 + test/test_device_load_constructors.jl | 8 +- test/test_import_export_cost.jl | 165 ++++++ test/test_market_bid_cost.jl | 341 ++++++++++++ test/test_utils/add_market_bid_cost.jl | 55 +- test/test_utils/iec_simulation_utils.jl | 38 +- test/test_utils/mbc_math_helpers.jl | 485 ++++++++++++++++++ test/test_utils/mbc_system_utils.jl | 184 ++++--- 14 files changed, 1267 insertions(+), 264 deletions(-) create mode 100644 test/test_import_export_cost.jl create mode 100644 test/test_market_bid_cost.jl create mode 100644 test/test_utils/mbc_math_helpers.jl diff --git a/src/common_models/add_parameters.jl b/src/common_models/add_parameters.jl index 6e68eae..c6f1ceb 100644 --- a/src/common_models/add_parameters.jl +++ b/src/common_models/add_parameters.jl @@ -311,21 +311,31 @@ _get_time_series_name(::StartupCostParameter, device::PSY.Component, ::DeviceMod IS.get_name(PSY.get_start_up(PSY.get_operation_cost(device))) _get_time_series_name(::ShutdownCostParameter, device::PSY.Component, ::DeviceModel) = - IS.get_name(PSY.get_shut_down(PSY.get_operation_cost(device))) + IS.get_name(IS.get_time_series_key(PSY.get_shut_down(PSY.get_operation_cost(device)))) _get_time_series_name( ::IncrementalCostAtMinParameter, device::PSY.Device, ::DeviceModel, -) = - IS.get_name(PSY.get_incremental_initial_input(PSY.get_operation_cost(device))) +) = IS.get_name( + IS.get_initial_input( + PSY.get_value_curve( + PSY.get_incremental_offer_curves(PSY.get_operation_cost(device)), + ), + ), +) _get_time_series_name( ::DecrementalCostAtMinParameter, device::PSY.Device, ::DeviceModel, -) = - IS.get_name(PSY.get_decremental_initial_input(PSY.get_operation_cost(device))) +) = IS.get_name( + IS.get_initial_input( + PSY.get_value_curve( + PSY.get_decremental_offer_curves(PSY.get_operation_cost(device)), + ), + ), +) ################################################################################# # _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 10e6ea8..6cafc95 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -13,7 +13,52 @@ _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 IOM.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, + ::OnVariable, + comp::Union{PSY.Generator, PSY.ControllableLoad}, + ::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, + ::OnVariable, + comp::T, + ::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 + +is_time_variant_term(::PSY.MarketBidCost) = false +is_time_variant_term(::PSY.MarketBidTimeSeriesCost) = true ################################################################################# # Section 2: _consider_parameter — compact commitment startup @@ -60,15 +105,21 @@ function validate_occ_component( end end +# 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 validate_occ_component( ::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 @@ -76,13 +127,9 @@ function validate_occ_component( ::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 @@ -90,13 +137,9 @@ function validate_occ_component( ::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,14 +210,18 @@ _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, ::ActivePowerOutVariable, component::PSY.Source, - cost_function::PSY.ImportExportCost, + cost_function::IOM.IEC_TYPES, ::ImportExportSourceModel, ) - isnothing(get_output_offer_curves(cost_function)) && return add_pwl_term_delta!( IncrementalOffer(), container, @@ -190,10 +237,9 @@ function add_variable_cost_to_objective!( container::OptimizationContainer, ::ActivePowerInVariable, component::PSY.Source, - cost_function::PSY.ImportExportCost, + cost_function::IOM.IEC_TYPES, ::ImportExportSourceModel, ) - isnothing(get_input_offer_curves(cost_function)) && return add_pwl_term_delta!( DecrementalOffer(), container, @@ -218,8 +264,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/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index 1f3730d..21a28e1 100644 --- a/src/energy_storage_models/storage_models.jl +++ b/src/energy_storage_models/storage_models.jl @@ -1906,7 +1906,7 @@ function _add_variable_cost_to_objective!( container::OptimizationContainer, ::T, component::PSY.Component, - cost_function::PSY.MarketBidCost, + cost_function::IOM.MBC_TYPES, ::U, ) where { T <: Union{ActivePowerOutVariable, StorageRegularizationVariableDischarge}, @@ -1932,7 +1932,7 @@ function _add_variable_cost_to_objective!( container::OptimizationContainer, ::T, component::PSY.Component, - cost_function::PSY.MarketBidCost, + cost_function::IOM.MBC_TYPES, ::U, ) where { T <: Union{ActivePowerInVariable, StorageRegularizationVariableCharge}, diff --git a/src/static_injector_models/electric_loads.jl b/src/static_injector_models/electric_loads.jl index 3cac0dd..e4ddc62 100644 --- a/src/static_injector_models/electric_loads.jl +++ b/src/static_injector_models/electric_loads.jl @@ -232,42 +232,7 @@ function onvar_cost( return _onvar_cost(container, PSY.get_variable(cost), d, t) end -is_time_variant_term( - ::OptimizationContainer, - ::PSY.LoadCost, - ::OnVariable, - ::PSY.ControllableLoad, - ::AbstractLoadFormulation, - ::Int, -) = false - -is_time_variant_term( - ::OptimizationContainer, - cost::PSY.MarketBidCost, - ::OnVariable, - ::PSY.ControllableLoad, - ::PowerLoadInterruption, - ::Int, -) = - is_time_variant(PSY.get_decremental_initial_input(cost)) +is_time_variant_term(::PSY.LoadCost) = false -function proportional_cost( - container::OptimizationContainer, - cost::PSY.MarketBidCost, - ::OnVariable, - comp::T, - ::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_term 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 9603d19..3473c50 100644 --- a/src/static_injector_models/hydro_generation.jl +++ b/src/static_injector_models/hydro_generation.jl @@ -2269,67 +2269,20 @@ 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, - ::OnVariable, - ::PSY.HydroGen, - ::AbstractHydroFormulation, - t::Int, -) = false +is_time_variant_term(::PSY.HydroGenerationCost) = false -function add_proportional_cost!( +skip_proportional_cost(d::PSY.HydroPumpTurbine) = PSY.get_must_run(d) + +add_proportional_cost!( container::OptimizationContainer, ::U, devices::IS.FlattenIteratorWrapper{T}, - ::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(), d, 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, - ::OnVariable, - comp::PSY.HydroGen, - ::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, - ::OnVariable, - ::PSY.HydroGen, - ::AbstractHydroUnitCommitment, - t::Int, -) = - is_time_variant(PSY.get_incremental_initial_input(cost)) + formulation::AbstractHydroUnitCommitment, +) where {U <: OnVariable, T <: PSY.HydroGen} = + add_proportional_cost_maybe_time_variant!(container, U(), devices, formulation) -# end copy-paste +# MarketBidCost (static + time-series) proportional_cost/is_time_variant_term 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 0f7efd2..f098236 100644 --- a/src/static_injector_models/thermal_generation.jl +++ b/src/static_injector_models/thermal_generation.jl @@ -94,24 +94,12 @@ initial_condition_variable(::InitialTimeDurationOff, d::PSY.ThermalGen, ::Abstra function proportional_cost(container::OptimizationContainer, cost::PSY.ThermalGenerationCost, S::OnVariable, T::PSY.ThermalGen, U::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_time_variant_term(::OptimizationContainer, ::PSY.ThermalGenerationCost, ::OnVariable, ::PSY.ThermalGen, ::AbstractThermalFormulation, t::Int) = false - -function proportional_cost(container::OptimizationContainer, cost::PSY.MarketBidCost, ::OnVariable, comp::T, ::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, ::OnVariable, ::PSY.ThermalGen, ::AbstractThermalFormulation, t::Int) = - is_time_variant(PSY.get_incremental_initial_input(cost)) +is_time_variant_term(::PSY.ThermalGenerationCost) = false + +# MarketBidCost (static + time-series) proportional_cost/is_time_variant_term are generic — +# see common_models/market_bid_overrides.jl. -proportional_cost(::Union{PSY.MarketBidCost, PSY.ThermalGenerationCost}, ::Union{RateofChangeConstraintSlackUp, RateofChangeConstraintSlackDown}, ::PSY.ThermalGen, ::AbstractThermalFormulation) = CONSTRAINT_VIOLATION_SLACK_COST +proportional_cost(::Union{IOM.MBC_TYPES, PSY.ThermalGenerationCost}, ::Union{RateofChangeConstraintSlackUp, RateofChangeConstraintSlackDown}, ::PSY.ThermalGen, ::AbstractThermalFormulation) = CONSTRAINT_VIOLATION_SLACK_COST has_multistart_variables(::PSY.ThermalGen, ::AbstractThermalFormulation)=false 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_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..a2ecafb --- /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_market_bid_cost.jl b/test/test_market_bid_cost.jl new file mode 100644 index 0000000..4d4e3cf --- /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_utils/add_market_bid_cost.jl b/test/test_utils/add_market_bid_cost.jl index 5a70f9d..6e42245 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,26 @@ 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) + nl_key = add_time_series!(sys, comp, nl_ts) + sd_key = add_time_series!(sys, comp, sd_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 +181,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 = old_start_up, + 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..833060e 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 IOM.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..45062d1 --- /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 = (hot = 0.0, warm = 0.0, cold = 0.0), + 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..b334cb6 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 = 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), ), ) From ad102a399c56467e0bf69fa374a06928dda8fcf9 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 15 Apr 2026 13:21:50 -0600 Subject: [PATCH 05/10] adapt to TupleTimeSeries-wrapped start_up on MarketBidTimeSeriesCost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream PSY now types `MarketBidTimeSeriesCost.start_up` as `TupleTimeSeries{StartUpStages}` instead of a bare `TimeSeriesKey`: - `_get_time_series_name(::StartupCostParameter, ...)` extracts the key via `IS.get_time_series_key` (mirrors the existing Shutdown path). - `validate_occ_component` on `ThermalMultiStart` early-returns for TupleTimeSeries — values are pre-validated to `NTuple{3, Float64}` at construction. - Renewable/Storage startup-nonzero check compares via `any(!iszero, values(x))` so it works for both static `StartUpStages` (NamedTuple) and TS-backed `NTuple{3, Float64}` elements. - Test helpers (`extend_mbc!`, `_promote_mbc_to_ts!`, `stub_ts_market_bid_cost`) wrap the TimeSeriesKey in `IS.TupleTimeSeries{PSY.StartUpStages}` when constructing `MarketBidTimeSeriesCost`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/common_models/add_parameters.jl | 2 +- src/common_models/market_bid_overrides.jl | 16 ++++++++++------ test/test_utils/add_market_bid_cost.jl | 4 +++- test/test_utils/mbc_math_helpers.jl | 2 +- test/test_utils/mbc_system_utils.jl | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/common_models/add_parameters.jl b/src/common_models/add_parameters.jl index c6f1ceb..25cbe03 100644 --- a/src/common_models/add_parameters.jl +++ b/src/common_models/add_parameters.jl @@ -308,7 +308,7 @@ _get_time_series_name(::T, ::PSY.Component, model::DeviceModel) where {T <: Para 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))) + IS.get_name(IS.get_time_series_key(PSY.get_start_up(PSY.get_operation_cost(device)))) _get_time_series_name(::ShutdownCostParameter, device::PSY.Component, ::DeviceModel) = IS.get_name(IS.get_time_series_key(PSY.get_shut_down(PSY.get_operation_cost(device)))) diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index 6cafc95..6db1dc0 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -78,11 +78,13 @@ _consider_parameter( ################################################################################# # ThermalMultiStart: accept NTuple{3, Float64} and StartUpStages without warning -function validate_occ_component( +function IOM.validate_occ_component( ::StartupCostParameter, device::PSY.ThermalMultiStart, ) startup = PSY.get_start_up(PSY.get_operation_cost(device)) + # TupleTimeSeries{StartUpStages} guarantees NTuple{3, Float64} values at construction + startup isa IS.TupleTimeSeries && return _validate_eltype( Union{Float64, NTuple{3, Float64}, StartUpStages}, device, @@ -93,13 +95,15 @@ end # Renewable / Storage: warn on nonzero startup, shutdown, and no-load costs -function validate_occ_component( +function IOM.validate_occ_component( ::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), 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 @@ -113,7 +117,7 @@ end _scalar_if_static(x::IS.LinearCurve) = IS.get_proportional_term(x) _scalar_if_static(::IS.TimeSeriesLinearCurve) = nothing -function validate_occ_component( +function IOM.validate_occ_component( ::ShutdownCostParameter, device::Union{PSY.RenewableDispatch, PSY.Storage}, ) @@ -123,7 +127,7 @@ function validate_occ_component( end end -function validate_occ_component( +function IOM.validate_occ_component( ::IncrementalCostAtMinParameter, device::Union{PSY.RenewableDispatch, PSY.Storage}, ) @@ -133,7 +137,7 @@ function validate_occ_component( end end -function validate_occ_component( +function IOM.validate_occ_component( ::DecrementalCostAtMinParameter, device::PSY.Storage, ) diff --git a/test/test_utils/add_market_bid_cost.jl b/test/test_utils/add_market_bid_cost.jl index 6e42245..1be5cd6 100644 --- a/test/test_utils/add_market_bid_cost.jl +++ b/test/test_utils/add_market_bid_cost.jl @@ -132,8 +132,10 @@ function extend_mbc!( # 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}() @@ -187,7 +189,7 @@ function extend_mbc!( new_cost = MarketBidTimeSeriesCost(; no_load_cost = TimeSeriesLinearCurve(nl_key), - start_up = old_start_up, + 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"], diff --git a/test/test_utils/mbc_math_helpers.jl b/test/test_utils/mbc_math_helpers.jl index 45062d1..79970b6 100644 --- a/test/test_utils/mbc_math_helpers.jl +++ b/test/test_utils/mbc_math_helpers.jl @@ -344,7 +344,7 @@ end 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 = (hot = 0.0, warm = 0.0, cold = 0.0), + 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", diff --git a/test/test_utils/mbc_system_utils.jl b/test/test_utils/mbc_system_utils.jl index b334cb6..d273b8d 100644 --- a/test/test_utils/mbc_system_utils.jl +++ b/test/test_utils/mbc_system_utils.jl @@ -264,7 +264,7 @@ function _promote_mbc_to_ts!( new_cost = MarketBidTimeSeriesCost(; no_load_cost = TimeSeriesLinearCurve(nl_key), - start_up = su_key, + start_up = IS.TupleTimeSeries{PSY.StartUpStages}(su_key), shut_down = TimeSeriesLinearCurve(sd_key), incremental_offer_curves = incr_curve, decremental_offer_curves = decr_curve, From d86e81542185ee26ea13707d4dedc1fed6b3f4b9 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Wed, 15 Apr 2026 13:23:26 -0600 Subject: [PATCH 06/10] add parameter-population tests; fix missed default_interface_methods include MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `test/test_mbc_parameter_population.jl` exercises the `system-with-TS → parameter container` half of the MBC pipeline, which was previously only covered transitively. IEC PWL is `@test_broken` pending the PSI→POM migration of slope/breakpoint overloads. Along the way: - Include `src/core/default_interface_methods.jl` from the main module so `get_multiplier_value` for OCC parameters resolves to the defaults instead of the catch-all error. - Drop the duplicate `requires_initialization` in that file (IOM already defines it; the duplicate caused a method-overwrite warning). - Add `IS.@assert_op op_cost isa TS_OFFER_CURVE_COST_TYPES` at the top of each OCC `_get_time_series_name` method so a future bypass of IOM's filter surfaces a clean precondition error. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/PowerOperationsModels.jl | 1 + src/common_models/add_parameters.jl | 57 +++-- src/core/default_interface_methods.jl | 5 +- test/test_mbc_parameter_population.jl | 307 ++++++++++++++++++++++++++ 4 files changed, 349 insertions(+), 21 deletions(-) create mode 100644 test/test_mbc_parameter_population.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index c9a0957..0ee8a1c 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -191,6 +191,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") diff --git a/src/common_models/add_parameters.jl b/src/common_models/add_parameters.jl index 25cbe03..d0604e4 100644 --- a/src/common_models/add_parameters.jl +++ b/src/common_models/add_parameters.jl @@ -307,35 +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(IS.get_time_series_key(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(IS.get_time_series_key(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 IOM.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 IOM.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( - IS.get_initial_input( - PSY.get_value_curve( - PSY.get_incremental_offer_curves(PSY.get_operation_cost(device)), - ), - ), ) + op_cost = PSY.get_operation_cost(device) + IS.@assert_op op_cost isa IOM.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( - IS.get_initial_input( - PSY.get_value_curve( - PSY.get_decremental_offer_curves(PSY.get_operation_cost(device)), - ), - ), ) + op_cost = PSY.get_operation_cost(device) + IS.@assert_op op_cost isa IOM.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/core/default_interface_methods.jl b/src/core/default_interface_methods.jl index c37b265..9922f2b 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(::StartupCostParameter, ::PSY.Device, ::AbstractDeviceFormulation) = 1.0 get_multiplier_value(::ShutdownCostParameter, ::PSY.Device, ::AbstractDeviceFormulation) = 1.0 get_multiplier_value(::AbstractCostAtMinParameter, ::PSY.Device, ::AbstractDeviceFormulation) = 1.0 @@ -13,8 +14,6 @@ get_multiplier_value(::AbstractPiecewiseLinearBreakpointParameter, ::PSY.Device, 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/test/test_mbc_parameter_population.jl b/test/test_mbc_parameter_population.jl new file mode 100644 index 0000000..fbfb551 --- /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 + + IOM.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 From 61344edd60ff544912cc3fc1aea051ebcd55abb0 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 17 Apr 2026 14:00:48 -0600 Subject: [PATCH 07/10] pass types in MBC/IEC tests --- src/common_models/market_bid_overrides.jl | 8 ++--- test/test_device_hydro_constructors.jl | 1 + test/test_import_export_cost.jl | 16 ++++----- test/test_market_bid_cost.jl | 40 +++++++++++------------ test/test_mbc_parameter_population.jl | 2 +- test/test_utils/mbc_math_helpers.jl | 2 +- 6 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index db81628..176f2a8 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -35,9 +35,9 @@ _cost_at_min_param(::DecrementalOffer) = DecrementalCostAtMinParameter() proportional_cost( ::OptimizationContainer, ::PSY.MarketBidCost, - ::OnVariable, + ::Type{OnVariable}, comp::Union{PSY.Generator, PSY.ControllableLoad}, - ::AbstractDeviceFormulation, + ::Type{<:AbstractDeviceFormulation}, ::Int, ) = IOM.get_initial_input(_onvar_offer_direction(comp), comp) @@ -45,9 +45,9 @@ proportional_cost( function proportional_cost( container::OptimizationContainer, ::PSY.MarketBidTimeSeriesCost, - ::OnVariable, + ::Type{OnVariable}, comp::T, - ::AbstractDeviceFormulation, + ::Type{<:AbstractDeviceFormulation}, t::Int, ) where {T <: Union{PSY.Generator, PSY.ControllableLoad}} param = _cost_at_min_param(_onvar_offer_direction(comp)) 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_import_export_cost.jl b/test/test_import_export_cost.jl index a2ecafb..28cfd4c 100644 --- a/test/test_import_export_cost.jl +++ b/test/test_import_export_cost.jl @@ -37,10 +37,10 @@ _static_iec(import_xs, import_ys, export_xs, export_ys) = PSY.ImportExportCost(; add_jump_var!(container, IOM.ActivePowerInVariable, PSY.Source, _SOURCE_NAME, 1) POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerOutVariable(), source, cost, POM.ImportExportSourceModel(), + container, IOM.ActivePowerOutVariable, source, cost, POM.ImportExportSourceModel, ) POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerInVariable(), source, cost, POM.ImportExportSourceModel(), + container, IOM.ActivePowerInVariable, source, cost, POM.ImportExportSourceModel, ) # Import side: IncrementalOffer sign = +1. @@ -80,10 +80,10 @@ end add_jump_var!(container, IOM.ActivePowerInVariable, PSY.Source, _SOURCE_NAME, 1) POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerOutVariable(), source, cost, POM.ImportExportSourceModel(), + container, IOM.ActivePowerOutVariable, source, cost, POM.ImportExportSourceModel, ) POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerInVariable(), source, cost, POM.ImportExportSourceModel(), + container, IOM.ActivePowerInVariable, source, cost, POM.ImportExportSourceModel, ) # Import slope coefficient = +(6 × 100) × 0.25 = +150. @@ -129,17 +129,17 @@ end dir = IOM.DecrementalOffer()) POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerOutVariable(), source, cost, POM.ImportExportSourceModel(), + container, IOM.ActivePowerOutVariable, source, cost, POM.ImportExportSourceModel, ) POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerInVariable(), source, cost, POM.ImportExportSourceModel(), + 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) + container, IOM.PiecewiseLinearBlockIncrementalOffer, PSY.Source) decr_pwl = IOM.get_variable( - container, IOM.PiecewiseLinearBlockDecrementalOffer(), PSY.Source) + container, IOM.PiecewiseLinearBlockDecrementalOffer, PSY.Source) @test [JuMP.coefficient(variant, incr_pwl[(_SOURCE_NAME, s, 1)]) for s in 1:2] ≈ [2.0, 5.0] diff --git a/test/test_market_bid_cost.jl b/test/test_market_bid_cost.jl index 4d4e3cf..df7ce87 100644 --- a/test/test_market_bid_cost.jl +++ b/test/test_market_bid_cost.jl @@ -34,7 +34,7 @@ _decr_mbc(initial_input::Float64, xs::Vector{Float64}, slopes::Vector{Float64}) container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerVariable(), load, cost, POM.PowerLoadDispatch()) + container, IOM.ActivePowerVariable, load, cost, POM.PowerLoadDispatch) # Decremental sign = -1, dt = 1 hr, SYSTEM_BASE ⇒ coefficient == -slope. @test pwl_delta_coefs( @@ -60,7 +60,7 @@ end container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerVariable(), load, cost, POM.PowerLoadDispatch()) + container, IOM.ActivePowerVariable, load, cost, POM.PowerLoadDispatch) @test pwl_delta_coefs( container, IOM.DecrementalOffer(), PSY.InterruptiblePowerLoad, _LOAD_NAME, 1, @@ -93,11 +93,11 @@ end dir = IOM.DecrementalOffer()) POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerVariable(), load, cost, POM.PowerLoadDispatch()) + container, IOM.ActivePowerVariable, load, cost, POM.PowerLoadDispatch) variant = IOM.get_variant_terms(IOM.get_objective_expression(container)) pwl = IOM.get_variable( - container, IOM.PiecewiseLinearBlockDecrementalOffer(), + 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] @@ -123,9 +123,9 @@ end container, IOM.OnVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) IOM.add_variable_cost!( - container, IOM.ActivePowerVariable(), devs, POM.PowerLoadInterruption()) + container, IOM.ActivePowerVariable, devs, POM.PowerLoadInterruption) POM.add_proportional_cost!( - container, IOM.OnVariable(), devs, POM.PowerLoadInterruption()) + container, IOM.OnVariable, devs, POM.PowerLoadInterruption) # OnVariable: coefficient = initial_input × OBJECTIVE_FUNCTION_NEGATIVE = -2.0. @test obj_coef( @@ -163,14 +163,14 @@ end [_LOAD_NAME], 1:2, reshape([2.5, 4.5], 1, 2)) IOM.add_variable_cost!( - container, IOM.ActivePowerVariable(), devs, POM.PowerLoadInterruption()) + container, IOM.ActivePowerVariable, devs, POM.PowerLoadInterruption) POM.add_proportional_cost!( - container, IOM.OnVariable(), devs, POM.PowerLoadInterruption()) + 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(), + container, IOM.PiecewiseLinearBlockDecrementalOffer, PSY.InterruptiblePowerLoad) # OnVariable: param × OBJECTIVE_FUNCTION_NEGATIVE. @@ -198,7 +198,7 @@ end container, IOM.ActivePowerVariable, PSY.InterruptiblePowerLoad, _LOAD_NAME, 1) @test_throws ArgumentError POM.add_variable_cost_to_objective!( - container, IOM.ActivePowerVariable(), load, cost, POM.PowerLoadDispatch()) + container, IOM.ActivePowerVariable, load, cost, POM.PowerLoadDispatch) end @testset "ThermalStandard + ThermalBasicUnitCommitment + static MBC" begin @@ -222,13 +222,13 @@ end end IOM.add_variable_cost!( - container, IOM.ActivePowerVariable(), devs, POM.ThermalBasicUnitCommitment()) + container, IOM.ActivePowerVariable, devs, POM.ThermalBasicUnitCommitment) IOM.add_start_up_cost!( - container, IOM.StartVariable(), devs, POM.ThermalBasicUnitCommitment()) + container, IOM.StartVariable, devs, POM.ThermalBasicUnitCommitment) IOM.add_shut_down_cost!( - container, IOM.StopVariable(), devs, POM.ThermalBasicUnitCommitment()) + container, IOM.StopVariable, devs, POM.ThermalBasicUnitCommitment) POM.add_proportional_cost!( - container, IOM.OnVariable(), devs, POM.ThermalBasicUnitCommitment()) + container, IOM.OnVariable, devs, POM.ThermalBasicUnitCommitment) # StartVariable takes the max over StartUpStages for basic UC formulations. @test obj_coef( @@ -280,13 +280,13 @@ end [_THERMAL_NAME], 1:2, reshape([30.0, 45.0], 1, 2)) IOM.add_variable_cost!( - container, IOM.ActivePowerVariable(), devs, POM.ThermalBasicUnitCommitment()) + container, IOM.ActivePowerVariable, devs, POM.ThermalBasicUnitCommitment) IOM.add_start_up_cost!( - container, IOM.StartVariable(), devs, POM.ThermalBasicUnitCommitment()) + container, IOM.StartVariable, devs, POM.ThermalBasicUnitCommitment) IOM.add_shut_down_cost!( - container, IOM.StopVariable(), devs, POM.ThermalBasicUnitCommitment()) + container, IOM.StopVariable, devs, POM.ThermalBasicUnitCommitment) POM.add_proportional_cost!( - container, IOM.OnVariable(), devs, POM.ThermalBasicUnitCommitment()) + container, IOM.OnVariable, devs, POM.ThermalBasicUnitCommitment) variant = IOM.get_variant_terms(IOM.get_objective_expression(container)) for (V, expected_t1, expected_t2) in ( @@ -299,7 +299,7 @@ end @test JuMP.coefficient(variant, var[_THERMAL_NAME, 2]) ≈ expected_t2 end pwl = IOM.get_variable( - container, IOM.PiecewiseLinearBlockIncrementalOffer(), PSY.ThermalStandard) + 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] @@ -326,7 +326,7 @@ end for V in (POM.HotStartVariable, POM.WarmStartVariable, POM.ColdStartVariable) IOM.add_start_up_cost!( - container, V(), devs, POM.ThermalMultiStartUnitCommitment()) + container, V, devs, POM.ThermalMultiStartUnitCommitment) end variant = IOM.get_variant_terms(IOM.get_objective_expression(container)) diff --git a/test/test_mbc_parameter_population.jl b/test/test_mbc_parameter_population.jl index fbfb551..4e606aa 100644 --- a/test/test_mbc_parameter_population.jl +++ b/test/test_mbc_parameter_population.jl @@ -206,7 +206,7 @@ end # 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) + 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), diff --git a/test/test_utils/mbc_math_helpers.jl b/test/test_utils/mbc_math_helpers.jl index 79970b6..7a100a3 100644 --- a/test/test_utils/mbc_math_helpers.jl +++ b/test/test_utils/mbc_math_helpers.jl @@ -225,7 +225,7 @@ function add_jump_var!( if !IOM.has_container_key(container, V, T) IOM.add_variable_container!( container, - V(), + V, T, [name], IOM.get_time_steps(container), From a392a91b8b67c2ff7badabc09f866c86ae9f950b Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Fri, 17 Apr 2026 16:51:32 -0600 Subject: [PATCH 08/10] move POM-only utils out of IOM --- src/network_models/pm_translator.jl | 17 ++++++++++++ .../thermal_generation.jl | 26 ++++++++++++++++++- .../TwoTerminalDC_branches.jl | 24 +++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) 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/static_injector_models/thermal_generation.jl b/src/static_injector_models/thermal_generation.jl index 3c7862d..4319f3f 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 @@ -1586,7 +1610,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] 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 From 183d0915b1e66fd55821805da55aa76693b6c469 Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Mon, 20 Apr 2026 08:22:16 -0600 Subject: [PATCH 09/10] transfer PSY aware code from IOM --- src/PowerOperationsModels.jl | 9 +- src/common_models/add_parameters.jl | 8 +- src/common_models/market_bid_overrides.jl | 14 +- src/common_models/market_bid_plumbing.jl | 573 ++++++++++++++++++ src/energy_storage_models/storage_models.jl | 4 +- src/operation/template_validation.jl | 4 +- .../thermal_generation.jl | 51 +- test/Project.toml | 5 + test/test_mbc_parameter_population.jl | 2 +- test/test_utils/iec_simulation_utils.jl | 2 +- 10 files changed, 642 insertions(+), 30 deletions(-) create mode 100644 src/common_models/market_bid_plumbing.jl diff --git a/src/PowerOperationsModels.jl b/src/PowerOperationsModels.jl index 0ee8a1c..0fd7b99 100644 --- a/src/PowerOperationsModels.jl +++ b/src/PowerOperationsModels.jl @@ -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!, @@ -214,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") @@ -238,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 04e7bb1..370c91b 100644 --- a/src/common_models/add_parameters.jl +++ b/src/common_models/add_parameters.jl @@ -316,7 +316,7 @@ function _get_time_series_name( ::DeviceModel, ) op_cost = PSY.get_operation_cost(device) - IS.@assert_op op_cost isa IOM.TS_OFFER_CURVE_COST_TYPES + 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 @@ -326,7 +326,7 @@ function _get_time_series_name( ::DeviceModel, ) op_cost = PSY.get_operation_cost(device) - IS.@assert_op op_cost isa IOM.TS_OFFER_CURVE_COST_TYPES + 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 @@ -336,7 +336,7 @@ function _get_time_series_name( ::DeviceModel, ) op_cost = PSY.get_operation_cost(device) - IS.@assert_op op_cost isa IOM.TS_OFFER_CURVE_COST_TYPES + 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)), @@ -350,7 +350,7 @@ function _get_time_series_name( ::DeviceModel, ) op_cost = PSY.get_operation_cost(device) - IS.@assert_op op_cost isa IOM.TS_OFFER_CURVE_COST_TYPES + 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)), diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index 176f2a8..de9adf0 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -13,7 +13,7 @@ _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 IOM.MBC_TYPES + PSY.get_operation_cost(device) isa MBC_TYPES ################################################################################# # Section 1b: Generic MarketBidCost OnVariable proportional cost @@ -77,16 +77,16 @@ _consider_parameter( # Section 3: Device-specific validate_occ_component ################################################################################# -# ThermalMultiStart: accept NTuple{3, Float64} and StartUpStages without warning +# 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{StartUpStages} guarantees NTuple{3, Float64} values at construction + # 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", @@ -101,7 +101,7 @@ function IOM.validate_occ_component( ) startup = PSY.get_start_up(PSY.get_operation_cost(device)) apply_maybe_across_time_series(device, startup) do x - # x may be Float64 (TGC), StartUpStages (static MBC), or NTuple{3, Float64} + # 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))." @@ -223,7 +223,7 @@ function add_variable_cost_to_objective!( container::OptimizationContainer, ::Type{ActivePowerOutVariable}, component::PSY.Source, - cost_function::IOM.IEC_TYPES, + cost_function::IEC_TYPES, ::Type{ImportExportSourceModel}, ) isnothing(get_output_offer_curves(cost_function)) && return @@ -242,7 +242,7 @@ function add_variable_cost_to_objective!( container::OptimizationContainer, ::Type{ActivePowerInVariable}, component::PSY.Source, - cost_function::IOM.IEC_TYPES, + cost_function::IEC_TYPES, ::Type{ImportExportSourceModel}, ) isnothing(get_input_offer_curves(cost_function)) && return diff --git a/src/common_models/market_bid_plumbing.jl b/src/common_models/market_bid_plumbing.jl new file mode 100644 index 0000000..11b5578 --- /dev/null +++ b/src/common_models/market_bid_plumbing.jl @@ -0,0 +1,573 @@ +################################################################################# +# 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 + +################################################################################# +# 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 + add_to_objective_variant_expression!(container, pwl_cost) + else + 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)) + 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) = + IS.get_forecast_initial_timestamp(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/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index 526ca96..45f7cfe 100644 --- a/src/energy_storage_models/storage_models.jl +++ b/src/energy_storage_models/storage_models.jl @@ -1869,7 +1869,7 @@ function _add_variable_cost_to_objective!( container::OptimizationContainer, ::T, component::PSY.Component, - cost_function::IOM.MBC_TYPES, + 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::IOM.MBC_TYPES, + cost_function::MBC_TYPES, ::U, ) where { T <: Union{ActivePowerInVariable, StorageRegularizationVariableCharge}, 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/thermal_generation.jl b/src/static_injector_models/thermal_generation.jl index 4319f3f..e049de9 100644 --- a/src/static_injector_models/thermal_generation.jl +++ b/src/static_injector_models/thermal_generation.jl @@ -123,7 +123,7 @@ is_time_variant_term(::PSY.ThermalGenerationCost) = false # MarketBidCost (static + time-series) proportional_cost/is_time_variant_term are generic — # see common_models/market_bid_overrides.jl. -proportional_cost(::Union{IOM.MBC_TYPES, 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 @@ -138,7 +138,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? @@ -147,14 +147,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 @@ -1100,14 +1100,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 @@ -1635,3 +1635,34 @@ 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/test/Project.toml b/test/Project.toml index c6f7fa6..deb7313 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/test_mbc_parameter_population.jl b/test/test_mbc_parameter_population.jl index 4e606aa..cee8322 100644 --- a/test/test_mbc_parameter_population.jl +++ b/test/test_mbc_parameter_population.jl @@ -214,7 +214,7 @@ end end end - IOM.process_market_bid_parameters!(container, devs, _PP_MODEL, false, false) + 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) diff --git a/test/test_utils/iec_simulation_utils.jl b/test/test_utils/iec_simulation_utils.jl index 833060e..5a0b416 100644 --- a/test/test_utils/iec_simulation_utils.jl +++ b/test/test_utils/iec_simulation_utils.jl @@ -211,7 +211,7 @@ function cost_due_to_time_varying_iec( @assert all(power_in_df.DateTime .== power_out_df.DateTime) @assert any([ - get_operation_cost(comp) isa IOM.IEC_TYPES for + get_operation_cost(comp) isa IEC_TYPES for comp in get_components(T, sys) ]) for gen_name in gen_names From 4d97f10a289951f0a42ddb9b6c94d48077ddd260 Mon Sep 17 00:00:00 2001 From: Luke Kiernan Date: Mon, 20 Apr 2026 15:34:31 -0600 Subject: [PATCH 10/10] all tests except 1 now pass --- ext/PowerFlowsExt/PowerFlowsExt.jl | 2 +- src/PowerOperationsModels.jl | 2 +- src/common_models/add_parameters.jl | 2 +- src/common_models/market_bid_overrides.jl | 3 - src/common_models/market_bid_plumbing.jl | 41 ++++++-- src/energy_storage_models/storage_models.jl | 12 +-- src/static_injector_models/electric_loads.jl | 6 +- .../hydro_generation.jl | 11 ++- .../thermal_generation.jl | 55 +++++++---- test/Project.toml | 6 +- test/test_is_time_variant_proportional.jl | 94 +++++++++++++++++++ test/test_mbc_parameter_population.jl | 2 +- test/test_model_decision.jl | 2 + test/test_utils/model_checks.jl | 2 +- 14 files changed, 190 insertions(+), 50 deletions(-) create mode 100644 test/test_is_time_variant_proportional.jl 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 0fd7b99..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, diff --git a/src/common_models/add_parameters.jl b/src/common_models/add_parameters.jl index 370c91b..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 diff --git a/src/common_models/market_bid_overrides.jl b/src/common_models/market_bid_overrides.jl index de9adf0..27ed2ef 100644 --- a/src/common_models/market_bid_overrides.jl +++ b/src/common_models/market_bid_overrides.jl @@ -57,9 +57,6 @@ function proportional_cost( return param_arr[name, t] * param_mult[name, t] end -is_time_variant_term(::PSY.MarketBidCost) = false -is_time_variant_term(::PSY.MarketBidTimeSeriesCost) = true - ################################################################################# # Section 2: _consider_parameter — compact commitment startup # Compact/multi-start formulations have HotStart/WarmStart/ColdStart variables diff --git a/src/common_models/market_bid_plumbing.jl b/src/common_models/market_bid_plumbing.jl index 11b5578..7829f02 100644 --- a/src/common_models/market_bid_plumbing.jl +++ b/src/common_models/market_bid_plumbing.jl @@ -109,8 +109,10 @@ get_offer_curves(::IOM.IncrementalOffer, op_cost::PSY.OfferCurveCost) = # 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{<: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) = @@ -163,6 +165,13 @@ _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 ################################################################################# @@ -418,7 +427,11 @@ function IOM.add_pwl_term_delta!( ::PSY.OfferCurveCost, ::Type{U}, ::Type{V}, -) where {T <: IS.InfrastructureSystemsComponent, U <: VariableType, V <: AbstractDeviceFormulation} +) where { + T <: IS.InfrastructureSystemsComponent, + U <: VariableType, + V <: AbstractDeviceFormulation, +} W = IOM._block_offer_var(dir) X = IOM._block_offer_constraint(dir) @@ -472,9 +485,9 @@ function IOM.add_pwl_term_delta!( ) if is_variant - add_to_objective_variant_expression!(container, pwl_cost) + IOM.add_to_objective_variant_expression!(container, pwl_cost) else - add_to_objective_invariant_expression!(container, pwl_cost) + IOM.add_to_objective_invariant_expression!(container, pwl_cost) end end end @@ -537,7 +550,7 @@ function _add_vom_cost_to_objective_helper!( ) 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)) - add_proportional_cost_invariant!(container, T, component, cost_term, power_units) + IOM.add_proportional_cost_invariant!(container, T, component, cost_term, power_units) return end @@ -567,7 +580,21 @@ IOM.set_units_base_system!(sys::PSY.System, base) = PSY.set_units_base_system!(s 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) = - IS.get_forecast_initial_timestamp(sys) + 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/energy_storage_models/storage_models.jl b/src/energy_storage_models/storage_models.jl index 45f7cfe..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 diff --git a/src/static_injector_models/electric_loads.jl b/src/static_injector_models/electric_loads.jl index fe3a101..70441b3 100644 --- a/src/static_injector_models/electric_loads.jl +++ b/src/static_injector_models/electric_loads.jl @@ -228,7 +228,9 @@ function onvar_cost( return _onvar_cost(container, PSY.get_variable(cost), d, t) end -is_time_variant_term(::PSY.LoadCost) = false +# 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 -# MarketBidCost (static + time-series) proportional_cost/is_time_variant_term are generic — +# 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 e4ad8e3..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,7 +2220,10 @@ proportional_cost( ) where {U <: OnVariable, V <: AbstractHydroUnitCommitment} = proportional_cost(cost, U, comp, V) -is_time_variant_term(::PSY.HydroGenerationCost) = 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 skip_proportional_cost(d::PSY.HydroPumpTurbine) = PSY.get_must_run(d) @@ -2232,7 +2235,7 @@ add_proportional_cost!( ) where {U <: OnVariable, T <: PSY.HydroGen, V <: AbstractHydroUnitCommitment} = add_proportional_cost_maybe_time_variant!(container, U, devices, V) -# MarketBidCost (static + time-series) proportional_cost/is_time_variant_term are generic — +# 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. diff --git a/src/static_injector_models/thermal_generation.jl b/src/static_injector_models/thermal_generation.jl index e049de9..4bee2e8 100644 --- a/src/static_injector_models/thermal_generation.jl +++ b/src/static_injector_models/thermal_generation.jl @@ -118,9 +118,25 @@ 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_time_variant_term(::PSY.ThermalGenerationCost) = false - -# MarketBidCost (static + time-series) proportional_cost/is_time_variant_term are generic — +# 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) + +IOM.uses_commitment_variables(::Type{<:PSY.ThermalGen}) = true + +# MarketBidCost (static + time-series) proportional_cost/is_time_variant_proportional are generic — # see common_models/market_bid_overrides.jl. proportional_cost(::Union{MBC_TYPES, PSY.ThermalGenerationCost}, ::Type{<:Union{RateofChangeConstraintSlackUp, RateofChangeConstraintSlackDown}}, ::PSY.ThermalGen, ::Type{<:AbstractThermalFormulation}) = CONSTRAINT_VIOLATION_SLACK_COST @@ -462,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] @@ -713,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) @@ -779,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] @@ -793,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), @@ -887,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, :]) @@ -932,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]) @@ -1256,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), @@ -1281,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] @@ -1331,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 @@ -1636,7 +1652,6 @@ function IOM.add_pwl_term_lambda!( 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!( diff --git a/test/Project.toml b/test/Project.toml index deb7313..12187fc 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -32,9 +32,9 @@ 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"} +InfrastructureOptimizationModels = {path = "../../InfrastructureOptimizationModels.jl"} +InfrastructureSystems = {path = "../../InfrastructureSystems.jl"} +PowerSystems = {path = "../../PowerSystems.jl"} [compat] HiGHS = "1" 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_mbc_parameter_population.jl b/test/test_mbc_parameter_population.jl index cee8322..c16ca79 100644 --- a/test/test_mbc_parameter_population.jl +++ b/test/test_mbc_parameter_population.jl @@ -214,7 +214,7 @@ end end end - process_market_bid_parameters!(container, devs, _PP_MODEL, false, false) + 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) 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/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(