diff --git a/src/InfrastructureOptimizationModels.jl b/src/InfrastructureOptimizationModels.jl index d98a6fb..b0117ec 100644 --- a/src/InfrastructureOptimizationModels.jl +++ b/src/InfrastructureOptimizationModels.jl @@ -344,6 +344,7 @@ export add_sparse_pwl_interpolation_variables! export JuMPOrFloat # Constraint helpers export add_range_constraints!, add_parameterized_upper_bound_range_constraints +export add_parameterized_lower_bound_range_constraints export add_reserve_bound_range_constraints!, add_commitment_bound_range_constraints! export add_semicontinuous_range_constraints!, add_semicontinuous_ramp_constraints! export add_slacked_range_constraints!, fill_slacked_range_constraints! diff --git a/src/common_models/range_constraint.jl b/src/common_models/range_constraint.jl index f6d95f9..395f534 100644 --- a/src/common_models/range_constraint.jl +++ b/src/common_models/range_constraint.jl @@ -414,7 +414,19 @@ function add_parameterized_bound_range_constraints( return end -# Backwards-compatible wrappers +# Direction-specific wrappers over `add_parameterized_bound_range_constraints`. +""" +Add `array[name, t] >= rhs[name, t]` constraints of type `T`, where `array` is the variable or +expression `U` and the right-hand side is built from the parameter `P`. The right-hand side +depends on the parameter family: + + - generic `P`: `multiplier[name, t] * parameter[name, t]` + - `P <: EventParameter`: `get_max_active_power(device) * parameter[name, t]` + - `P <: TimeSeriesParameter`: `multiplier[name, t] * parameter_column[t]`, and only devices + that own the time series named in `get_time_series_names(model)[P]` are constrained. + +Mirror of [`add_parameterized_upper_bound_range_constraints`](@ref). +""" function add_parameterized_lower_bound_range_constraints( container::OptimizationContainer, ::Type{T}, @@ -444,6 +456,18 @@ function add_parameterized_lower_bound_range_constraints( return end +""" +Add `array[name, t] <= rhs[name, t]` constraints of type `T`, where `array` is the variable or +expression `U` and the right-hand side is built from the parameter `P`. The right-hand side +depends on the parameter family: + + - generic `P`: `multiplier[name, t] * parameter[name, t]` + - `P <: EventParameter`: `get_max_active_power(device) * parameter[name, t]` + - `P <: TimeSeriesParameter`: `multiplier[name, t] * parameter_column[t]`, and only devices + that own the time series named in `get_time_series_names(model)[P]` are constrained. + +Mirror of [`add_parameterized_lower_bound_range_constraints`](@ref). +""" function add_parameterized_upper_bound_range_constraints( container::OptimizationContainer, ::Type{T}, diff --git a/test/InfrastructureOptimizationModelsTests.jl b/test/InfrastructureOptimizationModelsTests.jl index dbf9855..35f33b7 100644 --- a/test/InfrastructureOptimizationModelsTests.jl +++ b/test/InfrastructureOptimizationModelsTests.jl @@ -135,6 +135,7 @@ function run_tests() include(joinpath(TEST_DIR, "test_jump_utils.jl")) include(joinpath(TEST_DIR, "test_pwl_methods.jl")) include(joinpath(TEST_DIR, "test_ramp_constraints.jl")) + include(joinpath(TEST_DIR, "test_parameterized_range_constraints.jl")) include(joinpath(TEST_DIR, "test_duration_constraints.jl")) include(joinpath(TEST_DIR, "test_emulation_model_store.jl")) include(joinpath(TEST_DIR, "test_model_store.jl")) diff --git a/test/mocks/mock_components.jl b/test/mocks/mock_components.jl index 2e2881c..d54f931 100644 --- a/test/mocks/mock_components.jl +++ b/test/mocks/mock_components.jl @@ -128,7 +128,8 @@ end get_name(l::MockLoad) = l.name get_available(l::MockLoad) = l.available get_bus(l::MockLoad) = l.bus -get_max_active_power(l::MockLoad) = l.max_active_power +IOM.get_max_active_power(l::MockLoad) = l.max_active_power +IOM.get_max_active_power(g::MockThermalGen) = g.active_power_limits.max # Mock Branch struct MockBranch <: AbstractMockDevice diff --git a/test/mocks/mock_time_series.jl b/test/mocks/mock_time_series.jl index ce7664a..56b2b30 100644 --- a/test/mocks/mock_time_series.jl +++ b/test/mocks/mock_time_series.jl @@ -4,7 +4,7 @@ Minimal time series mocks for testing parameter updates. using Dates -struct MockDeterministic +struct MockDeterministic <: IS.TimeSeriesData name::String data::Vector{Float64} resolution::Dates.Period @@ -18,3 +18,23 @@ struct MockSingleTimeSeries end get_name(ts::Union{MockDeterministic, MockSingleTimeSeries}) = ts.name + +# Mock components are immutable and hold no time series manager, so `IS.has_time_series` +# can't work off the component itself. Builders that filter devices on time series +# ownership (e.g. the parameterized range constraints) consult this registry instead. +const MOCK_TIME_SERIES_REGISTRY = Dict{String, Set{String}}() + +function mock_add_time_series!(component, ts_name::AbstractString) + push!(get!(MOCK_TIME_SERIES_REGISTRY, get_name(component), Set{String}()), ts_name) + return +end + +mock_clear_time_series!() = empty!(MOCK_TIME_SERIES_REGISTRY) + +function IS.has_time_series( + component::AbstractMockDevice, + ::Type{MockDeterministic}, + ts_name::AbstractString, +) + return ts_name in get(MOCK_TIME_SERIES_REGISTRY, get_name(component), Set{String}()) +end diff --git a/test/test_parameterized_range_constraints.jl b/test/test_parameterized_range_constraints.jl new file mode 100644 index 0000000..92d9cf0 --- /dev/null +++ b/test/test_parameterized_range_constraints.jl @@ -0,0 +1,211 @@ +""" +Unit tests for the parameterized range constraints +(`add_parameterized_lower_bound_range_constraints` / +`add_parameterized_upper_bound_range_constraints`). + +The real branching is the parameter family: generic, `EventParameter`, and +`TimeSeriesParameter` dispatch to different `_bound_range_with_parameter!` methods, and the +time series one additionally filters devices, so those carry the bulk of the coverage. +""" + +struct TestValueParameter <: IOM.VariableValueParameter end +struct TestTimeSeriesParameter <: IOM.TimeSeriesParameter end +struct TestEventParameter <: IOM.EventParameter end + +const PARAM_RANGE_TS_NAME = "max_active_power" +const PARAM_RANGE_MULTIPLIER = 0.5 + +param_range_value(name, t) = name == "A" ? 1.0 * t : 10.0 + t + +function _make_parameterized_range_container(devices, time_steps) + mock_sys = MockSystem(100.0) + settings = IOM.Settings( + mock_sys; + horizon = Dates.Hour(length(time_steps)), + resolution = Dates.Hour(1), + time_series_cache_size = 0, + ) + container = IOM.OptimizationContainer(mock_sys, settings, nothing, MockDeterministic) + IOM.set_time_steps!(container, time_steps) + jump_model = IOM.get_jump_model(container) + names = [get_name(d) for d in devices] + D = eltype(devices) + var = IOM.add_variable_container!(container, TestVariableType, D, names, time_steps) + expr = + IOM.add_expression_container!(container, TestExpressionType, D, names, time_steps) + for name in names, t in time_steps + v = JuMP.@variable(jump_model) + var[name, t] = v + # Scaled so a test can tell the expression path from the variable path. + expr[name, t] = 2.0 * v + end + return container +end + +"Add a `TestValueParameter` container with `param[name, t] = param_range_value(name, t)`." +function _add_value_parameter!(container, names, time_steps) + param_container = IOM.add_param_container!( + container, + TestValueParameter, + MockThermalGen, + IOM.VariableKey(TestVariableType, MockThermalGen), + names, + time_steps, + ) + param_array = IOM.get_parameter_array(param_container) + mult_array = IOM.get_multiplier_array(param_container) + for name in names, t in time_steps + param_array[name, t] = param_range_value(name, t) + mult_array[name, t] = PARAM_RANGE_MULTIPLIER + end + return param_container +end + +@testset "Parameterized range constraints" begin + time_steps = 1:3 + devices = [make_mock_thermal("A"), make_mock_thermal("B")] + names = ["A", "B"] + model = IOM.DeviceModel( + MockThermalGen, + TestFormulation; + time_series_names = Dict{Type{<:IOM.ParameterType}, String}( + TestTimeSeriesParameter => PARAM_RANGE_TS_NAME, + ), + ) + + @testset "Bounds from a variable value parameter" begin + container = _make_parameterized_range_container(devices, time_steps) + _add_value_parameter!(container, names, time_steps) + for f in ( + add_parameterized_lower_bound_range_constraints, + add_parameterized_upper_bound_range_constraints, + ) + f( + container, + TestConstraintType, + TestVariableType, + TestValueParameter, + devices, + model, + TestPowerModel, + ) + end + con_lb = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "lb") + con_ub = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "ub") + var = IOM.get_variable(container, TestVariableType, MockThermalGen) + # The two directions land in distinct containers under the "lb"/"ub" metas. Sense is + # compile-time dispatch, invariant across names and time steps, so spot-check it + # once; the RHS arithmetic is what varies, so check that elementwise. + @test JuMP.constraint_object(con_lb["A", 1]).set isa MOI.GreaterThan + @test JuMP.constraint_object(con_ub["A", 1]).set isa MOI.LessThan + for name in names, t in time_steps + @test JuMP.normalized_coefficient(con_lb[name, t], var[name, t]) ≈ 1.0 + @test JuMP.normalized_rhs(con_lb[name, t]) ≈ + PARAM_RANGE_MULTIPLIER * param_range_value(name, t) + end + end + + @testset "Exercise expression codepath" begin + container = _make_parameterized_range_container(devices, time_steps) + _add_value_parameter!(container, names, time_steps) + add_parameterized_lower_bound_range_constraints( + container, + TestConstraintType, + TestExpressionType, + TestValueParameter, + devices, + model, + TestPowerModel, + ) + con = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "lb") + var = IOM.get_variable(container, TestVariableType, MockThermalGen) + # The expression is 2 * var, so the LHS coefficient must be 2, not 1. + for name in names, t in time_steps + @test JuMP.normalized_coefficient(con[name, t], var[name, t]) ≈ 2.0 + end + end + + @testset "Time series parameter constrains only devices owning the time series" begin + mock_clear_time_series!() + ts_devices = + [make_mock_thermal("A"), make_mock_thermal("B"), make_mock_thermal("C")] + ts_names = ["A", "C"] # B has no time series + for device in ts_devices + get_name(device) in ts_names && + mock_add_time_series!(device, PARAM_RANGE_TS_NAME) + end + uuids = Dict(name => "uuid-$name" for name in ts_names) + container = _make_parameterized_range_container(ts_devices, time_steps) + param_container = IOM.add_param_container!( + container, + TestTimeSeriesParameter, + MockThermalGen, + MockDeterministic, + PARAM_RANGE_TS_NAME, + collect(values(uuids)), + ts_names, + (), + time_steps, + ) + attributes = IOM.get_attributes(param_container) + param_array = IOM.get_parameter_array(param_container) + mult_array = IOM.get_multiplier_array(param_container) + for name in ts_names + IOM.add_component_name!(attributes, name, uuids[name]) + for t in time_steps + param_array[uuids[name], t] = param_range_value(name, t) + mult_array[name, t] = PARAM_RANGE_MULTIPLIER + end + end + + add_parameterized_lower_bound_range_constraints( + container, + TestConstraintType, + TestVariableType, + TestTimeSeriesParameter, + ts_devices, + model, + TestPowerModel, + ) + con = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "lb") + @test axes(con)[1] == ts_names + # RHS comes from the parameter's column refs rather than the parameter array. + for name in ts_names, t in time_steps + @test JuMP.normalized_rhs(con[name, t]) ≈ + PARAM_RANGE_MULTIPLIER * param_range_value(name, t) + end + mock_clear_time_series!() + end + + @testset "Event parameter scales by the device's max active power" begin + container = _make_parameterized_range_container(devices, time_steps) + param_container = IOM.add_param_container!( + container, + TestEventParameter, + MockThermalGen, + MockThermalGen, + names, + time_steps, + ) + param_array = IOM.get_parameter_array(param_container) + for name in names, t in time_steps + param_array[name, t] = param_range_value(name, t) + end + add_parameterized_lower_bound_range_constraints( + container, + TestConstraintType, + TestVariableType, + TestEventParameter, + devices, + model, + TestPowerModel, + ) + con = IOM.get_constraint(container, TestConstraintType, MockThermalGen, "lb") + for (device, name) in zip(devices, names), t in time_steps + # The event path scales by the device's max active power, not the multiplier + # array (which is left as NaN here). + expected = IOM.get_max_active_power(device) * param_range_value(name, t) + @test JuMP.normalized_rhs(con[name, t]) ≈ expected + end + end +end diff --git a/test/verify_mocks.jl b/test/verify_mocks.jl index 1134ee0..3b0ee9d 100644 --- a/test/verify_mocks.jl +++ b/test/verify_mocks.jl @@ -51,7 +51,7 @@ load = MockLoad("load1", true, bus, 75.0) get_name(load) get_available(load) get_bus(load) -get_max_active_power(load) +IOM.get_max_active_power(load) # MockBranch bus2 = MockBus("bus2", 2, :PQ)