diff --git a/CHANGELOG.md b/CHANGELOG.md index 14896e890..990729353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,10 @@ Classify the change according to the following categories: ### Deprecated ### Removed - +## Develop min-load-to-ashp +### Added +- Added new attribute `` to **ASHPSpaceHeater** and **ASHPWaterHeater** technologies. When this is populated, this imposes a constraint on the minimum fraction of load served by the ASHP system in every period, if the system is purchased. + ## Develop ### Added - Battery residual value if choosing replacement strategy for degradation diff --git a/src/constraints/thermal_tech_constraints.jl b/src/constraints/thermal_tech_constraints.jl index 3daccdf89..81bdb2bf7 100644 --- a/src/constraints/thermal_tech_constraints.jl +++ b/src/constraints/thermal_tech_constraints.jl @@ -107,29 +107,47 @@ end function add_ashp_force_in_constraints(m, p; _n="") - if "ASHPSpaceHeater" in p.techs.ashp && p.s.ashp.force_into_system - for t in setdiff(p.techs.can_serve_space_heating, ["ASHPSpaceHeater"]) - for ts in p.time_steps - fix(m[Symbol("dvHeatingProduction"*_n)][t,"SpaceHeating",ts], 0.0, force=true) - fix(m[Symbol("dvProductionToWaste"*_n)][t,"SpaceHeating",ts], 0.0, force=true) + if "ASHPSpaceHeater" in p.techs.ashp + if p.s.ashp.force_into_system + for t in setdiff(p.techs.can_serve_space_heating, ["ASHPSpaceHeater"]) + for ts in p.time_steps + fix(m[Symbol("dvHeatingProduction"*_n)][t,"SpaceHeating",ts], 0.0, force=true) + fix(m[Symbol("dvProductionToWaste"*_n)][t,"SpaceHeating",ts], 0.0, force=true) + end end + elseif p.s.ashp.min_allowable_load_service_fraction > 0.0 + @constraint(m, [ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)]["ASHPSpaceHeater","SpaceHeating",ts] >= p.s.ashp.min_allowable_load_service_fraction * p.heating_loads_kw["SpaceHeating"][ts] * m[Symbol("binSegmentASHPSpaceHeater")][1] + ) end end - if "ASHPSpaceHeater" in p.techs.cooling && p.s.ashp.force_into_system - for t in setdiff(p.techs.cooling, ["ASHPSpaceHeater"]) - for ts in p.time_steps - fix(m[Symbol("dvCoolingProduction"*_n)][t,ts], 0.0, force=true) + if "ASHPSpaceHeater" in p.techs.cooling + if p.s.ashp.force_into_system + for t in setdiff(p.techs.cooling, ["ASHPSpaceHeater"]) + for ts in p.time_steps + fix(m[Symbol("dvCoolingProduction"*_n)][t,ts], 0.0, force=true) + end end - end + elseif p.s.ashp.min_allowable_load_service_fraction > 0.0 + @constraint(m, [ts in p.time_steps], + m[Symbol("dvCoolingProduction"*_n)]["ASHPSpaceHeater",ts] >= p.s.ashp.min_allowable_load_service_fraction * p.s.cooling_load.loads_kw_thermal[ts] * m[Symbol("binSegmentASHPSpaceHeater")][1] + ) + end end - if "ASHPWaterHeater" in p.techs.ashp && p.s.ashp_wh.force_into_system - for t in setdiff(p.techs.can_serve_dhw, ["ASHPWaterHeater"]) - for ts in p.time_steps - fix(m[Symbol("dvHeatingProduction"*_n)][t,"DomesticHotWater",ts], 0.0, force=true) - fix(m[Symbol("dvProductionToWaste"*_n)][t,"DomesticHotWater",ts], 0.0, force=true) + if "ASHPWaterHeater" in p.techs.ashp + if p.s.ashp_wh.force_into_system + for t in setdiff(p.techs.can_serve_dhw, ["ASHPWaterHeater"]) + for ts in p.time_steps + fix(m[Symbol("dvHeatingProduction"*_n)][t,"DomesticHotWater",ts], 0.0, force=true) + fix(m[Symbol("dvProductionToWaste"*_n)][t,"DomesticHotWater",ts], 0.0, force=true) + end end + elseif p.s.ashp_wh.min_allowable_load_service_fraction > 0.0 + @constraint(m, [ts in p.time_steps], + m[Symbol("dvHeatingProduction"*_n)]["ASHPWaterHeater","DomesticHotWater",ts] >= p.s.ashp_wh.min_allowable_load_service_fraction * p.heating_loads_kw["DomesticHotWater"][ts] * m[Symbol("binSegmentASHPWaterHeater")][1] + ) end end end diff --git a/src/core/ashp.jl b/src/core/ashp.jl index b29a4fe7f..79bebc442 100644 --- a/src/core/ashp.jl +++ b/src/core/ashp.jl @@ -12,6 +12,7 @@ ASHPSpaceHeater has the following attributes: min_kw::Real # Minimum thermal power size max_kw::Real # Maximum thermal power size min_allowable_kw::Real # Minimum nonzero thermal power size if included + min_allowable_load_service_fraction::Real # Minimum load service required by ASHP system sizing_factor::Real # Size multiplier of system, relative that of the max load given by dispatch profile installed_cost_per_kw::Real # Thermal power-based cost om_cost_per_kw::Real # Thermal power-based fixed O&M cost @@ -33,6 +34,7 @@ struct ASHP <: AbstractThermalTech min_kw::Real max_kw::Real min_allowable_kw::Real + min_allowable_load_service_fraction::Real sizing_factor::Real installed_cost_per_kw::Real om_cost_per_kw::Real @@ -106,6 +108,7 @@ function ASHPSpaceHeater(; max_ton::Real = BIG_NUMBER, min_allowable_ton::Union{Real, Nothing} = nothing, min_allowable_peak_capacity_fraction::Union{Real, Nothing} = nothing, + min_allowable_load_service_fraction::Union{Real, Nothing} = nothing, sizing_factor::Union{Real, Nothing} = nothing, installed_cost_per_ton::Union{Real, Nothing} = nothing, om_cost_per_ton::Union{Real, Nothing} = nothing, @@ -206,16 +209,22 @@ function ASHPSpaceHeater(; cooling_cf = Float64[] end - if !isnothing(min_allowable_ton) && !isnothing(min_allowable_peak_capacity_fraction) - throw(@error("at most one of min_allowable_ton and min_allowable_peak_capacity_fraction may be input.")) + if !isnothing(min_allowable_ton) + !isnothing(min_allowable_peak_capacity_fraction) + !isnothing(min_allowable_load_service_fraction) > 1 + throw(@error("at most one of min_allowable_ton, min_allowable_peak_capacity_fraction and min_allowable_load_service_fraction may be input.")) elseif !isnothing(min_allowable_ton) min_allowable_kw = min_allowable_ton * KWH_THERMAL_PER_TONHOUR + min_allowable_load_service_fraction = 0.0 @warn("user-provided minimum allowable ton is used in the place of the default; this may provided very small sizes if set to zero.") else - if isnothing(min_allowable_peak_capacity_fraction) + if !isnothing(min_allowable_peak_capacity_fraction) + min_allowable_load_service_fraction = 0.0 + elseif !isnothing(min_allowable_load_service_fraction) + min_allowable_peak_capacity_fraction = min_allowable_load_service_fraction + else min_allowable_peak_capacity_fraction = 0.5 + min_allowable_load_service_fraction = 0.0 end - min_allowable_kw = get_ashp_default_min_allowable_size(heating_load, heating_cf, cooling_load, cooling_cf, min_allowable_peak_capacity_fraction) + min_allowable_kw = get_ashp_default_min_allowable_size(heating_load, heating_cf, Real[], Real[], min_allowable_peak_capacity_fraction) end if min_allowable_kw > max_kw @@ -229,6 +238,7 @@ function ASHPSpaceHeater(; min_kw, max_kw, min_allowable_kw, + min_allowable_load_service_fraction, sizing_factor, installed_cost_per_kw, om_cost_per_kw, @@ -296,6 +306,7 @@ function ASHPWaterHeater(; max_ton::Real = BIG_NUMBER, min_allowable_ton::Union{Real, Nothing} = nothing, min_allowable_peak_capacity_fraction::Union{Real, Nothing} = nothing, + min_allowable_load_service_fraction::Union{Real, Nothing} = nothing, sizing_factor::Union{Real, Nothing} = nothing, installed_cost_per_ton::Union{Real, Nothing} = nothing, om_cost_per_ton::Union{Real, Nothing} = nothing, @@ -363,14 +374,20 @@ function ASHPWaterHeater(; heating_cf[heating_cop .== 1] .= 1 - if !isnothing(min_allowable_ton) && !isnothing(min_allowable_peak_capacity_fraction) - throw(@error("at most one of min_allowable_ton and min_allowable_peak_capacity_fraction may be input.")) + if !isnothing(min_allowable_ton) + !isnothing(min_allowable_peak_capacity_fraction) + !isnothing(min_allowable_load_service_fraction) > 1 + throw(@error("at most one of min_allowable_ton, min_allowable_peak_capacity_fraction and min_allowable_load_service_fraction may be input.")) elseif !isnothing(min_allowable_ton) min_allowable_kw = min_allowable_ton * KWH_THERMAL_PER_TONHOUR + min_allowable_load_service_fraction = 0.0 @warn("user-provided minimum allowable ton is used in the place of the default; this may provided very small sizes if set to zero.") else - if isnothing(min_allowable_peak_capacity_fraction) + if !isnothing(min_allowable_peak_capacity_fraction) + min_allowable_load_service_fraction = 0.0 + elseif !isnothing(min_allowable_load_service_fraction) + min_allowable_peak_capacity_fraction = min_allowable_load_service_fraction + else min_allowable_peak_capacity_fraction = 0.5 + min_allowable_load_service_fraction = 0.0 end min_allowable_kw = get_ashp_default_min_allowable_size(heating_load, heating_cf, Real[], Real[], min_allowable_peak_capacity_fraction) end @@ -383,6 +400,7 @@ function ASHPWaterHeater(; min_kw, max_kw, min_allowable_kw, + min_allowable_load_service_fraction, sizing_factor, installed_cost_per_kw, om_cost_per_kw, diff --git a/src/core/reopt.jl b/src/core/reopt.jl index 662d249cd..b688c1ab5 100644 --- a/src/core/reopt.jl +++ b/src/core/reopt.jl @@ -324,10 +324,6 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) add_heating_cooling_constraints(m, p) end - if !isempty(p.techs.ashp) - add_ashp_force_in_constraints(m, p) - end - if !isempty(p.avoided_capex_by_ashp_present_value) && !isempty(p.techs.ashp) avoided_capex_by_ashp(m, p) end @@ -404,6 +400,10 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) ) end end + + if !isempty(p.techs.ashp) + add_ashp_force_in_constraints(m, p) + end @expression(m, TotalStorageCapCosts, p.third_party_factor * ( sum( p.s.storage.attr[b].net_present_cost_per_kw * m[:dvStoragePower][b] for b in p.s.storage.types.elec) + diff --git a/test/runtests.jl b/test/runtests.jl index 0d355fae0..5c9a2344e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2708,6 +2708,42 @@ else # run HiGHS tests @test results["ASHPSpaceHeater"]["annual_thermal_production_tonhour"] ≈ 876.0 rtol=1e-4 @test results["ExistingBoiler"]["annual_thermal_production_mmbtu"] ≈ 0.4 * 8760 rtol=1e-4 end + + @testset "ASHP min load served" begin + d = JSON.parsefile("./scenarios/ashp.json") + d["SpaceHeatingLoad"]["annual_mmbtu"] = 0.5 * 8760 + d["DomesticHotWaterLoad"] = Dict{String,Any}("annual_mmbtu" => 0.5 * 8760, "doe_reference_name" => "FlatLoad") + d["CoolingLoad"] = Dict{String,Any}("thermal_loads_ton" => ones(8760)*0.1) + d["ExistingChiller"] = Dict{String,Any}("retire_in_optimal" => false, "cop" => 100) + d["ExistingBoiler"]["retire_in_optimal"] = false + d["ExistingBoiler"]["fuel_cost_per_mmbtu"] = 0.001 + d["ASHPSpaceHeater"]["can_serve_cooling"] = true + d["ASHPSpaceHeater"]["force_into_system"] = false + d["ASHPSpaceHeater"]["min_allowable_load_service_fraction"] = 0.5 + d["ASHPWaterHeater"] = Dict{String,Any}("force_into_system" => false, "min_allowable_load_service_fraction" => 0.5, "max_ton" => 100000) + + s = Scenario(d) + p = REoptInputs(s) + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(m, p) + + #min_allowable_load_fraction should allow zero-kW system, yield no output + @test results["ASHPWaterHeater"]["annual_electric_consumption_kwh"] ≈ 0.0 atol=1e-4 + @test results["ASHPSpaceHeater"]["annual_thermal_production_mmbtu"] ≈ 0.0 atol=1e-4 + @test results["ASHPSpaceHeater"]["annual_thermal_production_tonhour"] ≈ 0.0 atol=1e-4 + + #force system to be purchased, which enforces min allowable load fraction to force dispatch + d["ASHPSpaceHeater"]["min_ton"] = 10 + d["ASHPWaterHeater"]["min_ton"] = 10 + s = Scenario(d) + p = REoptInputs(s) + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(m, p) + + @test results["ASHPWaterHeater"]["annual_electric_consumption_kwh"] ≈ sum(0.2 * REopt.KWH_PER_MMBTU / p.heating_cop["ASHPWaterHeater"][ts] for ts in p.time_steps) rtol=1e-4 + @test results["ASHPSpaceHeater"]["annual_thermal_production_mmbtu"] ≈ 0.2 * 8760 rtol=1e-4 + @test results["ASHPSpaceHeater"]["annual_thermal_production_tonhour"] ≈ 438.0 rtol=1e-4 + end end