diff --git a/src/quadratic_approximations/pwmcc_cuts.jl b/src/quadratic_approximations/pwmcc_cuts.jl index cded3a5e..f7a58758 100644 --- a/src/quadratic_approximations/pwmcc_cuts.jl +++ b/src/quadratic_approximations/pwmcc_cuts.jl @@ -85,7 +85,7 @@ function _add_pwmcc_concave_cuts!( selector_cons = add_constraints_container!( container, - PiecewiseMcCormickSelectorSum(), + PiecewiseMcCormickSelectorSum, C, names, time_steps; @@ -93,7 +93,7 @@ function _add_pwmcc_concave_cuts!( ) linking_cons = add_constraints_container!( container, - PiecewiseMcCormickLinking(), + PiecewiseMcCormickLinking, C, names, time_steps; @@ -101,7 +101,7 @@ function _add_pwmcc_concave_cuts!( ) interval_lb_cons = add_constraints_container!( container, - PiecewiseMcCormickIntervalLB(), + PiecewiseMcCormickIntervalLB, C, names, 1:K, @@ -110,7 +110,7 @@ function _add_pwmcc_concave_cuts!( ) interval_ub_cons = add_constraints_container!( container, - PiecewiseMcCormickIntervalUB(), + PiecewiseMcCormickIntervalUB, C, names, 1:K, @@ -119,7 +119,7 @@ function _add_pwmcc_concave_cuts!( ) chord_ub_cons = add_constraints_container!( container, - PiecewiseMcCormickChordUB(), + PiecewiseMcCormickChordUB, C, names, time_steps; @@ -127,7 +127,7 @@ function _add_pwmcc_concave_cuts!( ) tangent_lb_l_cons = add_constraints_container!( container, - PiecewiseMcCormickTangentLBL(), + PiecewiseMcCormickTangentLBL, C, names, time_steps; @@ -135,7 +135,7 @@ function _add_pwmcc_concave_cuts!( ) tangent_lb_r_cons = add_constraints_container!( container, - PiecewiseMcCormickTangentLBR(), + PiecewiseMcCormickTangentLBR, C, names, time_steps; diff --git a/test/test_hybs_approximations.jl b/test/test_hybs_approximations.jl index 2f41340f..ddda4ba9 100644 --- a/test/test_hybs_approximations.jl +++ b/test/test_hybs_approximations.jl @@ -521,4 +521,57 @@ end @test n_bin_hybs < n_bin_bin2 end end + + @testset "HybS with McCormick envelope tightens bounds" begin + # Compare HybSConfig(..., false) vs HybSConfig(..., true) at a fixed point + x0, y0 = 0.4, 0.7 + true_product = x0 * y0 + + results = Dict{Symbol, NTuple{2, Float64}}() # (min_z, max_z) for each config + for (label, add_mc) in [(:no_mc, false), (:with_mc, true)] + z_vals = Float64[] + for sense in [JuMP.MIN_SENSE, JuMP.MAX_SENSE] + setup = _setup_bilinear_test(["dev1"], 1:1) + JuMP.fix(setup.x_var_container["dev1", 1], x0; force = true) + JuMP.fix(setup.y_var_container["dev1", 1], y0; force = true) + + IOM._add_bilinear_approx!( + IOM.HybSConfig(IOM.SawtoothQuadConfig(2), 2, add_mc), + setup.container, + MockThermalGen, + ["dev1"], + 1:1, + setup.x_var_container, + setup.y_var_container, + 0.0, + 1.0, + 0.0, + 1.0, + HYBS_META, + ) + expr_container = IOM.get_expression( + setup.container, + IOM.BilinearProductExpression, + MockThermalGen, + HYBS_META, + ) + z_expr = expr_container["dev1", 1] + + JuMP.@objective(setup.jump_model, sense, z_expr) + JuMP.set_optimizer(setup.jump_model, HiGHS.Optimizer) + JuMP.set_silent(setup.jump_model) + JuMP.optimize!(setup.jump_model) + @test JuMP.termination_status(setup.jump_model) == JuMP.OPTIMAL + push!(z_vals, JuMP.objective_value(setup.jump_model)) + end + results[label] = (z_vals[1], z_vals[2]) # (min_z, max_z) + end + # McCormick version should have gap at least as tight + no_mc_gap = results[:no_mc][2] - results[:no_mc][1] + with_mc_gap = results[:with_mc][2] - results[:with_mc][1] + @test with_mc_gap <= no_mc_gap + 1e-6 + # Both should bracket the true product + @test results[:with_mc][1] <= true_product + 1e-6 + @test results[:with_mc][2] >= true_product - 1e-6 + end end diff --git a/test/test_jump_utils.jl b/test/test_jump_utils.jl index 1085e37e..e7c520fd 100644 --- a/test/test_jump_utils.jl +++ b/test/test_jump_utils.jl @@ -39,6 +39,22 @@ struct MockVariableType <: ISOPT.VariableType end @test isnan(IOM.jump_value(y)) end + @testset "jump_value with solved model" begin + model = JuMP.Model(HiGHS.Optimizer) + JuMP.set_silent(model) + @variable(model, x >= 5.0) + @objective(model, Min, x) + JuMP.optimize!(model) + @test JuMP.termination_status(model) == JuMP.OPTIMAL + @test IOM.jump_value(x) ≈ 5.0 atol = 1e-6 + end + + @testset "add_proportional_to_jump_expression! Float64×Float64" begin + expr = JuMP.AffExpr(1.0) + IOM.add_proportional_to_jump_expression!(expr, 3.0, 4.0) + @test JuMP.constant(expr) ≈ 13.0 # 1.0 + 3.0*4.0 + end + @testset "jump_fixed_value" begin model = JuMP.Model() @@ -266,4 +282,29 @@ struct MockVariableType <: ISOPT.VariableType end @test :value in propertynames(df) @test nrow(df) == 12 # 2 * 2 * 3 end + + @testset "_get_piecewise_pointcurve_per_system_unit DEVICE_BASE" begin + # Points in device base units: x coordinates should be scaled by + # device_base / system_base, y coordinates unchanged + points = [(x = 0.0, y = 0.0), (x = 1.0, y = 10.0), (x = 2.0, y = 30.0)] + pwl_data = IS.PiecewiseLinearData(points) + system_base = 100.0 + device_base = 50.0 + + result = IOM._get_piecewise_pointcurve_per_system_unit( + pwl_data, + Val(IS.UnitSystem.DEVICE_BASE), + system_base, + device_base, + ) + result_points = result.points + ratio = device_base / system_base # 0.5 + @test result_points[1].x ≈ 0.0 * ratio + @test result_points[2].x ≈ 1.0 * ratio + @test result_points[3].x ≈ 2.0 * ratio + # y-coordinates unchanged + @test result_points[1].y ≈ 0.0 + @test result_points[2].y ≈ 10.0 + @test result_points[3].y ≈ 30.0 + end end diff --git a/test/test_optimization_container.jl b/test/test_optimization_container.jl index 54dd9888..3badabaf 100644 --- a/test/test_optimization_container.jl +++ b/test/test_optimization_container.jl @@ -151,4 +151,65 @@ struct MockExpressionType <: ISOPT.ExpressionType end expr_key = PSI.ExpressionKey(MockExpressionType, MockComponentType) @test haskey(PSI.get_expressions(container), expr_key) end + + @testset "_assign_container! throws on duplicate key" begin + mock_sys = MockSystem(100.0) + settings = PSI.Settings( + mock_sys; + horizon = Dates.Hour(24), + resolution = Dates.Hour(1), + time_series_cache_size = 0, + ) + container = PSI.OptimizationContainer( + mock_sys, + settings, + nothing, + MockDeterministic, + ) + PSI.set_time_steps!(container, 1:24) + + # First assignment succeeds + PSI.add_variable_container!( + container, + PSI.ActivePowerVariable, + MockComponentType, + ["gen1"], + 1:24, + ) + + # Second assignment with same key should throw. + # Suppress the @error log to avoid tripping the framework's zero-error assertion. + @test_throws IS.InvalidValue Logging.with_logger(Logging.NullLogger()) do + PSI.add_variable_container!( + container, + PSI.ActivePowerVariable, + MockComponentType, + ["gen1"], + 1:24, + ) + end + end + + @testset "_get_entry throws on missing key" begin + mock_sys = MockSystem(100.0) + settings = PSI.Settings( + mock_sys; + horizon = Dates.Hour(24), + resolution = Dates.Hour(1), + time_series_cache_size = 0, + ) + container = PSI.OptimizationContainer( + mock_sys, + settings, + nothing, + MockDeterministic, + ) + + # Looking up a variable that was never added should throw + @test_throws IS.InvalidValue PSI.get_variable( + container, + PSI.ActivePowerVariable, + MockComponentType, + ) + end end diff --git a/test/test_piecewise_linear.jl b/test/test_piecewise_linear.jl index e6c3dff2..3f12c570 100644 --- a/test/test_piecewise_linear.jl +++ b/test/test_piecewise_linear.jl @@ -798,6 +798,72 @@ end @test JuMP.coefficient(invariant, var_y800) ≈ 2000.0 atol = 1e-10 end + @testset "FuelCurve{PiecewisePointCurve} time-variant fuel cost goes to variant objective" begin + fuel_points = [ + (x = 0.0, y = 0.0), + (x = 50.0, y = 400.0), + (x = 100.0, y = 900.0), + ] + time_steps = 1:2 + fuel_prices = [3.0, 7.0] + + # Create container + device. We pass a scalar fuel_cost for the operation cost mock + # but use a TimeSeriesKey in the FuelCurve itself. + op_cost = MockOperationCost(0.0, false, 5.0) + device = make_mock_thermal( + "gen_tv"; base_power = 100.0, operation_cost = op_cost, + ) + container = setup_pwl_container_with_variables(time_steps, device) + + # Pre-populate FuelCostParameter with time-varying fuel prices + add_test_parameter!( + container, + IOM.FuelCostParameter, + MockThermalGen, + ["gen_tv"], + time_steps, + reshape(Float64.(fuel_prices), 1, :), + ) + + # Build FuelCurve with a TimeSeriesKey as fuel_cost to trigger is_time_variant + ts_key = IS.StaticTimeSeriesKey( + IS.SingleTimeSeries, + "fuel_cost", + Dates.DateTime(2024, 1, 1), + Dates.Hour(1), + length(time_steps), + Dict{String, Any}(), + ) + fuel_curve = IS.FuelCurve( + IS.InputOutputCurve(IS.PiecewiseLinearData(fuel_points)), + IS.UnitSystem.NATURAL_UNITS, + ts_key, + ) + + IOM.add_variable_cost_to_objective!( + container, + TestPWLVariable, + device, + fuel_curve, + TestPWLFormulation, + ) + + # Cost should be in the VARIANT objective, not invariant + obj = IOM.get_objective_expression(container) + variant = IOM.get_variant_terms(obj) + invariant = IOM.get_invariant_terms(obj) + + pwl_var_container = IOM.get_variable( + container, IOM.PiecewiseLinearCostVariable, MockThermalGen, + ) + + # For t=1: y=400 point, fuel_price=3.0, dt=1.0 → cost coef = 400 * 3.0 = 1200 + var_y400_t1 = pwl_var_container["gen_tv", 2, 1] + @test JuMP.coefficient(variant, var_y400_t1) ≈ 400.0 * fuel_prices[1] atol = 1e-10 + # Invariant should NOT contain this term + @test JuMP.coefficient(invariant, var_y400_t1) ≈ 0.0 atol = 1e-10 + end + @testset "FuelCurve{PiecewiseIncrementalCurve} converts and produces correct objective" begin # FuelCurve with incremental (marginal heat rate) data # x_coords: [0, 50, 100] MW, slopes: [8, 10] MMBTU/MWh @@ -893,4 +959,65 @@ end con_set = JuMP.constraint_object(linking_con).set @test con_set.value ≈ -P_min end + + @testset "_add_pwl_constraint_standard! with must_run=true forces bin=1.0" begin + P_min = 20.0 + must_run_points = + [(x = 20.0, y = 0.0), (x = 60.0, y = 200.0), (x = 100.0, y = 800.0)] + (; container, device, break_points, power_var) = + setup_pwl_constraint_test(; time_steps = 1:1, points = must_run_points) + + IOM._add_pwl_constraint_standard!( + container, + device, + break_points, + IOM.SOSStatusVariable.NO_VARIABLE, + 1, + power_var, + true, # must_run + ) + + # Normalization constraint RHS should be 1.0 (bin forced) + norm_con = IOM.get_constraint( + container, + IOM.PiecewiseLinearCostNormalizationConstraint, + MockThermalGen, + )[ + "gen1", + 1, + ] + norm_set = JuMP.constraint_object(norm_con).set + @test norm_set.value ≈ 1.0 + end + + @testset "_get_sos_value returns NO_VARIABLE when skip_proportional_cost" begin + # Temporarily override skip_proportional_cost for our mock type + IOM.skip_proportional_cost(::MockThermalGen) = true + try + setup = setup_pwl_test() + result = IOM._get_sos_value( + setup.container, TestPWLFormulation, setup.device, + ) + @test result == IOM.SOSStatusVariable.NO_VARIABLE + finally + IOM.skip_proportional_cost(::MockThermalGen) = false + end + end + + @testset "_get_sos_value returns PARAMETER when OnStatusParameter exists" begin + (; container, device) = setup_pwl_test(; time_steps = 1:1) + + # Add OnStatusParameter container + add_test_parameter!( + container, + IOM.OnStatusParameter, + MockThermalGen, + ["gen1"], + 1:1, + fill(1.0, 1, 1), + ) + + result = IOM._get_sos_value(container, TestPWLFormulation, device) + @test result == IOM.SOSStatusVariable.PARAMETER + end end diff --git a/test/test_quadratic_approximations.jl b/test/test_quadratic_approximations.jl index 36c39f75..ea0f7f6c 100644 --- a/test/test_quadratic_approximations.jl +++ b/test/test_quadratic_approximations.jl @@ -650,4 +650,187 @@ const TEST_META = "TestVar" end end end + + @testset "Sawtooth with epigraph tightening" begin + @testset "Epigraph brackets true x²" begin + # With epigraph, z is bounded: epigraph_lb ≤ z ≤ sawtooth_ub. + # min z = epigraph (underestimate), max z = sawtooth (overestimate). + # Together they bracket the true x² value. + x0 = 1.7 + true_val = x0^2 + + setup = _setup_qa_test(["dev1"], 1:1) + JuMP.fix(setup.var_container["dev1", 1], x0; force = true) + + IOM._add_quadratic_approx!( + IOM.SawtoothQuadConfig(3, 2), + setup.container, + MockThermalGen, + ["dev1"], + 1:1, + setup.var_container, + 0.0, + 4.0, + TEST_META, + ) + z_expr = IOM.get_expression( + setup.container, + IOM.QuadraticExpression, + MockThermalGen, + TEST_META, + )[ + "dev1", + 1, + ] + + JuMP.set_optimizer(setup.jump_model, HiGHS.Optimizer) + JuMP.set_silent(setup.jump_model) + + # Minimize z → epigraph lower bound + JuMP.@objective(setup.jump_model, Min, z_expr) + JuMP.optimize!(setup.jump_model) + @test JuMP.termination_status(setup.jump_model) == JuMP.OPTIMAL + z_min = JuMP.objective_value(setup.jump_model) + + # Maximize z → sawtooth upper bound + JuMP.@objective(setup.jump_model, Max, z_expr) + JuMP.optimize!(setup.jump_model) + @test JuMP.termination_status(setup.jump_model) == JuMP.OPTIMAL + z_max = JuMP.objective_value(setup.jump_model) + + # z brackets true x² + @test z_min <= true_val + 1e-6 + @test z_max >= true_val - 1e-6 + # Bounds are non-trivial (gap is finite and positive) + @test z_max - z_min >= 0.0 + @test z_max - z_min <= (4.0^2) # gap smaller than full domain squared + end + + @testset "Epigraph provides valid lower bound" begin + # With epigraph tightening, z is bounded: epigraph_lb ≤ z ≤ sawtooth_ub + setup = _setup_qa_test(["dev1"], 1:1) + x_var = setup.var_container["dev1", 1] + x0 = 2.5 + JuMP.fix(x_var, x0; force = true) + + IOM._add_quadratic_approx!( + IOM.SawtoothQuadConfig(3, 3), + setup.container, + MockThermalGen, + ["dev1"], + 1:1, + setup.var_container, + 0.0, + 4.0, + TEST_META, + ) + expr_container = IOM.get_expression( + setup.container, + IOM.QuadraticExpression, + MockThermalGen, + TEST_META, + ) + z_expr = expr_container["dev1", 1] + + # Minimize z — should still be a valid approximation (≤ true x² + ε) + JuMP.@objective(setup.jump_model, Min, z_expr) + JuMP.set_optimizer(setup.jump_model, HiGHS.Optimizer) + JuMP.set_silent(setup.jump_model) + JuMP.optimize!(setup.jump_model) + @test JuMP.termination_status(setup.jump_model) == JuMP.OPTIMAL + z_min = JuMP.objective_value(setup.jump_model) + @test z_min <= x0^2 + 1e-4 # upper bound on x² + @test z_min >= 0.0 # valid lower bound + end + end + + @testset "Solver SOS2 with PWMCC concave cuts" begin + # Test with default pwmcc_segments=4 (the uncovered branch) + x0 = 1.3 + true_val = x0^2 + + results = Dict{Symbol, Float64}() + for (label, config) in [ + (:no_cuts, IOM.SolverSOS2QuadConfig(4, 0)), + (:with_cuts, IOM.SolverSOS2QuadConfig(4)), # default pwmcc_segments=4 + ] + setup = _setup_qa_test(["dev1"], 1:1) + x_var = setup.var_container["dev1", 1] + JuMP.fix(x_var, x0; force = true) + + IOM._add_quadratic_approx!( + config, + setup.container, + MockThermalGen, + ["dev1"], + 1:1, + setup.var_container, + 0.0, + 4.0, + TEST_META, + ) + expr_container = IOM.get_expression( + setup.container, + IOM.QuadraticExpression, + MockThermalGen, + TEST_META, + ) + z_expr = expr_container["dev1", 1] + + # Maximize to test the overestimate tightening (PWMCC adds concave upper bound) + JuMP.@objective(setup.jump_model, Max, z_expr) + JuMP.set_optimizer(setup.jump_model, HiGHS.Optimizer) + JuMP.set_silent(setup.jump_model) + JuMP.optimize!(setup.jump_model) + + @test JuMP.termination_status(setup.jump_model) == JuMP.OPTIMAL + results[label] = JuMP.objective_value(setup.jump_model) + end + # With cuts should give overestimate at least as tight (≤) as without + @test results[:with_cuts] <= results[:no_cuts] + 1e-6 + end + + @testset "Manual SOS2 with PWMCC concave cuts" begin + x0 = 1.3 + true_val = x0^2 + + results = Dict{Symbol, Float64}() + for (label, config) in [ + (:no_cuts, IOM.ManualSOS2QuadConfig(4, 0)), + (:with_cuts, IOM.ManualSOS2QuadConfig(4)), # default pwmcc_segments=4 + ] + setup = _setup_qa_test(["dev1"], 1:1) + x_var = setup.var_container["dev1", 1] + JuMP.fix(x_var, x0; force = true) + + IOM._add_quadratic_approx!( + config, + setup.container, + MockThermalGen, + ["dev1"], + 1:1, + setup.var_container, + 0.0, + 4.0, + TEST_META, + ) + expr_container = IOM.get_expression( + setup.container, + IOM.QuadraticExpression, + MockThermalGen, + TEST_META, + ) + z_expr = expr_container["dev1", 1] + + JuMP.@objective(setup.jump_model, Max, z_expr) + JuMP.set_optimizer(setup.jump_model, HiGHS.Optimizer) + JuMP.set_silent(setup.jump_model) + JuMP.optimize!(setup.jump_model) + + @test JuMP.termination_status(setup.jump_model) == JuMP.OPTIMAL + results[label] = JuMP.objective_value(setup.jump_model) + end + # With cuts should give overestimate at least as tight + @test results[:with_cuts] <= results[:no_cuts] + 1e-6 + end end diff --git a/test/test_quadratic_curve.jl b/test/test_quadratic_curve.jl index d90719f1..dd063361 100644 --- a/test/test_quadratic_curve.jl +++ b/test/test_quadratic_curve.jl @@ -465,4 +465,56 @@ end expected_quadratic, ) end + + @testset "_check_quadratic_monotonicity warns for non-monotonic cost" begin + # derivative f'(x) = 2*quad*x + linear + # With quad=-1.0, linear=0.5: f'(0) = 0.5, f'(1) = -1.5 (negative → warning) + @test_logs (:warn, r"not monotonically increasing") InfrastructureOptimizationModels._check_quadratic_monotonicity( + "test_gen", -1.0, 0.5, 0.0, 1.0, + ) + # Positive-definite case: no warning + @test_logs InfrastructureOptimizationModels._check_quadratic_monotonicity( + "test_gen", 1.0, 1.0, 0.0, 1.0, + ) + end + + @testset "_add_quadraticcurve_variable_term_to_model! populates ProductionCostExpression" begin + time_steps = 1:2 + device = make_mock_thermal("gen_expr"; base_power = 100.0) + container = + setup_quadratic_test_container(time_steps, device; resolution = Dates.Hour(1)) + + # Add a ProductionCostExpression container so the branch is exercised + # Use QuadExpr type since the quadratic cost produces QuadExpr terms + InfrastructureOptimizationModels.add_expression_container!( + container, + InfrastructureOptimizationModels.ProductionCostExpression, + MockThermalGen, + ["gen_expr"], + time_steps; + expr_type = JuMP.QuadExpr, + ) + expr_container = InfrastructureOptimizationModels.get_expression( + container, + InfrastructureOptimizationModels.ProductionCostExpression, + MockThermalGen, + ) + + quadratic_term = 2.0 + linear_term = 5.0 + InfrastructureOptimizationModels._add_quadraticcurve_variable_term_to_model!( + container, + TestActivePowerVariable, + device, + linear_term, + quadratic_term, + 1, + ) + + # The expression at [name, t=1] should be non-zero (cost was added) + cost_expr = expr_container["gen_expr", 1] + # Should be a QuadExpr with non-trivial quadratic terms + @test cost_expr isa JuMP.GenericQuadExpr + @test !isempty(cost_expr.terms) + end end