diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4331ae6ab..3b2123337 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -13,8 +13,7 @@ jobs: matrix: julia-version: ['1.8'] julia-arch: [x64] - # os: [ubuntu-latest, windows-latest, macOS-11] - os: [windows-latest, macOS-11] + os: [windows-latest] steps: - uses: actions/checkout@v2 @@ -24,4 +23,4 @@ jobs: - uses: julia-actions/julia-buildpkg@latest # - uses: mxschmitt/action-tmate@v3 # for interactive debugging - run: julia --project=. -e 'using Pkg; Pkg.activate("test"); Pkg.rm("Xpress"); Pkg.activate("."); using TestEnv; TestEnv.activate(); cd("test"); include("runtests.jl")' - shell: bash \ No newline at end of file + shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c307765d..6f1fc300c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,27 @@ Classify the change according to the following categories: ### Deprecated ### Removed +## v0.48.0 +### Added +- Added new file `src/core/ASHP.jl` with new technology **ASHP**, which uses electricity as input and provides heating and/or cooling as output; load balancing and technology-specific constraints have been updated and added accordingly +- In `src/core/existing_chiller.jl`, Added new atttribute **retire_in_optimal** to the **ExistingChiller** struct +- Financial output **initial_capital_costs_after_incentives_without_macrs** which has "net year one" CapEx after incentives except for MACRS, which helps with users defining their own "simple payback period" +### Changed +- Improve the full test suite reporting with a verbose summary table, and update the structure to reflect long-term open-source solver usage. +- Removed MacOS from the runner list and just run with Windows OS, since MacOS commonly freezes and gets cancelled. We have not seen Windows OS pass while other OS's fail. +- Suppress JuMP warning messages from 15-minute and multiple PVs test scenarios to avoid flooding the test logs with those warnings. +- Updated/specified User-Agent header of "REopt.jl" for PVWatts and Wind Toolkit API requests; default before was "HTTP.jl"; this allows specific tracking of REopt.jl usage which call PVWatts and Wind Toolkit through api.data.gov. +- Improves DRY coding by replacing multiple instances of the same chunks of code for MACRS deprecation and CHP capital cost into functions that are now in financial.jl. +- Simplifies the CHP sizing test to avoid a ~30 minute solve time, by avoiding the fuel burn y-intercept binaries which come with differences between full-load and part-load efficiency. +- For third party analysis proforma.jl metrics, O&M cost for existing Generator is now kept with offtaker, not the owner/developer +### Fixed +- Proforma calcs including "simple" payback and IRR for thermal techs/scenarios. + - The operating costs of fuel and O&M were missing for all thermal techs such as ExistingBoiler, CHP, and others; this adds those sections of code to properly calculate the operating costs. +- Added a test to validate the simple payback calculation with CHP (and ExistingBoiler) and checks the REopt result value against a spreadsheet proforma calculation (see Bill's spreadsheet). +- Added a couple of missing techs for the initial capital cost calculation in financial.jl. +- An issue with setup_boiler_inputs in reopt_inputs.jl. +- Fuel costs in proforma.jl were not consistent with the optimization costs, so that was corrected so that they are only added to the offtaker cashflows and not the owner/developer cashflows for third party. + ## v0.47.2 ### Fixed - Increased the big-M bound on maximum net metering benefit to prevent artificially low export benefits. diff --git a/Project.toml b/Project.toml index 7e492ee02..ce6381328 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "REopt" uuid = "d36ad4e8-d74a-4f7a-ace1-eaea049febf6" authors = ["Nick Laws", "Hallie Dunham ", "Bill Becker ", "Bhavesh Rathod ", "Alex Zolan ", "Amanda Farthing "] -version = "0.47.2" +version = "0.48.0" [deps] ArchGDAL = "c9ce4bd3-c3d5-55b8-8973-c0e20141b8c3" diff --git a/data/ashp/ashp_defaults.json b/data/ashp/ashp_defaults.json new file mode 100644 index 000000000..9d568579c --- /dev/null +++ b/data/ashp/ashp_defaults.json @@ -0,0 +1,41 @@ +{ + "SpaceHeating": + { + "max_ton": 99999999, + "installed_cost_per_ton": 2250, + "om_cost_per_ton": 40, + "macrs_option_years": 0, + "macrs_bonus_fraction": 0.0, + "can_supply_steam_turbine": false, + "can_serve_process_heat": false, + "can_serve_dhw": false, + "can_serve_space_heating": true, + "can_serve_cooling": true, + "back_up_temp_threshold_degF": 10.0, + "sizing_factor": 1.0, + "heating_cop_reference": [1.5,2.3,3.3,4.5], + "heating_cf_reference": [0.38,0.64,1.0,1.4], + "heating_reference_temps_degF": [-5,17,47,80], + "cooling_cop_reference": [4.0, 3.5, 2.9, 2.2], + "cooling_cf_reference": [1.03, 0.98, 0.93, 0.87], + "cooling_reference_temps_degF": [70, 82, 95, 110] + }, + "DomesticHotWater": + { + "max_ton": 99999999, + "installed_cost_per_ton": 2250, + "om_cost_per_ton": 40, + "macrs_option_years": 0, + "macrs_bonus_fraction": 0.0, + "can_supply_steam_turbine": false, + "can_serve_process_heat": false, + "can_serve_dhw": true, + "can_serve_space_heating": false, + "can_serve_cooling": false, + "back_up_temp_threshold_degF": 10.0, + "sizing_factor": 1.0, + "heating_cop_reference": [1.5,2.3,3.3,4.5], + "heating_cf_reference": [0.38,0.64,1.0,1.4], + "heating_reference_temps_degF": [-5,17,47,80] + } +} diff --git a/docs/src/reopt/inputs.md b/docs/src/reopt/inputs.md index dad72ede7..967d544ad 100644 --- a/docs/src/reopt/inputs.md +++ b/docs/src/reopt/inputs.md @@ -176,3 +176,8 @@ REopt.SteamTurbine ```@docs REopt.ElectricHeater ``` + +## ASHP +```@docs +REopt.ASHP +``` diff --git a/src/REopt.jl b/src/REopt.jl index 15df7e434..5ff756e2f 100644 --- a/src/REopt.jl +++ b/src/REopt.jl @@ -24,7 +24,9 @@ export avert_emissions_profiles, cambium_emissions_profile, easiur_data, - get_existing_chiller_default_cop + get_existing_chiller_default_cop, + get_electric_heater_defaults, + get_ashp_defaults import HTTP import JSON @@ -135,6 +137,7 @@ include("core/chp.jl") include("core/ghp.jl") include("core/steam_turbine.jl") include("core/electric_heater.jl") +include("core/ashp.jl") include("core/scenario.jl") include("core/bau_scenario.jl") include("core/reopt_inputs.jl") @@ -179,6 +182,7 @@ include("results/thermal_storage.jl") include("results/outages.jl") include("results/wind.jl") include("results/electric_load.jl") +include("results/heating_cooling_load.jl") include("results/existing_boiler.jl") include("results/boiler.jl") include("results/existing_chiller.jl") @@ -188,7 +192,7 @@ include("results/flexible_hvac.jl") include("results/ghp.jl") include("results/steam_turbine.jl") include("results/electric_heater.jl") -include("results/heating_cooling_load.jl") +include("results/ashp.jl") include("core/reopt.jl") include("core/reopt_multinode.jl") diff --git a/src/constraints/electric_utility_constraints.jl b/src/constraints/electric_utility_constraints.jl index 201317e8f..c1842f578 100644 --- a/src/constraints/electric_utility_constraints.jl +++ b/src/constraints/electric_utility_constraints.jl @@ -72,7 +72,7 @@ function add_export_constraints(m, p; _n="") sum(p.max_sizes[t] for t in NEM_techs), p.hours_per_time_step * maximum([sum(( p.s.electric_load.loads_kw[ts] + - p.s.cooling_load.loads_kw_thermal[ts]/p.cop["ExistingChiller"] + + p.s.cooling_load.loads_kw_thermal[ts]/p.cooling_cop["ExistingChiller"][ts] + (p.s.space_heating_load.loads_kw[ts] + p.s.dhw_load.loads_kw[ts] + p.s.process_heat_load.loads_kw[ts]) ) for ts in p.s.electric_tariff.time_steps_monthly[m]) for m in p.months ]) diff --git a/src/constraints/load_balance.jl b/src/constraints/load_balance.jl index 9d6980f0d..bdb93fa24 100644 --- a/src/constraints/load_balance.jl +++ b/src/constraints/load_balance.jl @@ -12,10 +12,10 @@ function add_elec_load_balance_constraints(m, p; _n="") sum(sum(m[Symbol("dvProductionToStorage"*_n)][b, t, ts] for b in p.s.storage.types.elec) + m[Symbol("dvCurtail"*_n)][t, ts] for t in p.techs.elec) + sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) - + sum(m[Symbol("dvCoolingProduction"*_n)][t, ts] / p.cop[t] for t in setdiff(p.techs.cooling,p.techs.ghp)) - + sum(m[Symbol("dvHeatingProduction"*_n)][t, q, ts] / p.heating_cop[t] for q in p.heating_loads, t in p.techs.electric_heater) + + sum(m[Symbol("dvCoolingProduction"*_n)][t, ts] / p.cooling_cop[t][ts] for t in setdiff(p.techs.cooling,p.techs.ghp)) + + sum(m[Symbol("dvHeatingProduction"*_n)][t, q, ts] / p.heating_cop[t][ts] for q in p.heating_loads, t in p.techs.electric_heater) + p.s.electric_load.loads_kw[ts] - - p.s.cooling_load.loads_kw_thermal[ts] / p.cop["ExistingChiller"] + - p.s.cooling_load.loads_kw_thermal[ts] / p.cooling_cop["ExistingChiller"][ts] + sum(p.ghp_electric_consumption_kw[g,ts] * m[Symbol("binGHP"*_n)][g] for g in p.ghp_options) ) else @@ -28,10 +28,10 @@ function add_elec_load_balance_constraints(m, p; _n="") + sum(m[Symbol("dvProductionToGrid"*_n)][t, u, ts] for u in p.export_bins_by_tech[t]) + m[Symbol("dvCurtail"*_n)][t, ts] for t in p.techs.elec) + sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec) - + sum(m[Symbol("dvCoolingProduction"*_n)][t, ts] / p.cop[t] for t in setdiff(p.techs.cooling,p.techs.ghp)) - + sum(m[Symbol("dvHeatingProduction"*_n)][t, q, ts] / p.heating_cop[t] for q in p.heating_loads, t in p.techs.electric_heater) + + sum(m[Symbol("dvCoolingProduction"*_n)][t, ts] / p.cooling_cop[t][ts] for t in setdiff(p.techs.cooling,p.techs.ghp)) + + sum(m[Symbol("dvHeatingProduction"*_n)][t, q, ts] / p.heating_cop[t][ts] for q in p.heating_loads, t in p.techs.electric_heater) + p.s.electric_load.loads_kw[ts] - - p.s.cooling_load.loads_kw_thermal[ts] / p.cop["ExistingChiller"] + - p.s.cooling_load.loads_kw_thermal[ts] / p.cooling_cop["ExistingChiller"][ts] + sum(p.ghp_electric_consumption_kw[g,ts] * m[Symbol("binGHP"*_n)][g] for g in p.ghp_options) ) end diff --git a/src/constraints/outage_constraints.jl b/src/constraints/outage_constraints.jl index 8a34f6c2f..49b9244e1 100644 --- a/src/constraints/outage_constraints.jl +++ b/src/constraints/outage_constraints.jl @@ -49,22 +49,22 @@ function add_outage_cost_constraints(m,p) end end - if !isempty(p.techs.segmented) + if !isempty(intersect(p.techs.segmented, p.techs.elec)) @warn "Adding binary variable(s) to model cost curves in stochastic outages" if solver_is_compatible_with_indicator_constraints(p.s.settings.solver_name) - @constraint(m, [t in p.techs.segmented], # cannot have this for statement in sum( ... for t in ...) ??? + @constraint(m, [t in intersect(p.techs.segmented, p.techs.elec)], # cannot have this for statement in sum( ... for t in ...) ??? m[:binMGTechUsed][t] => {m[:dvMGTechUpgradeCost][t] >= p.s.financial.microgrid_upgrade_cost_fraction * p.third_party_factor * sum(p.cap_cost_slope[t][s] * m[Symbol("dvSegmentSystemSize"*t)][s] + p.seg_yint[t][s] * m[Symbol("binSegment"*t)][s] for s in 1:p.n_segs_by_tech[t])} ) else - @constraint(m, [t in p.techs.segmented], + @constraint(m, [t in intersect(p.techs.segmented, p.techs.elec)], m[:dvMGTechUpgradeCost][t] >= p.s.financial.microgrid_upgrade_cost_fraction * p.third_party_factor * sum(p.cap_cost_slope[t][s] * m[Symbol("dvSegmentSystemSize"*t)][s] + p.seg_yint[t][s] * m[Symbol("binSegment"*t)][s] for s in 1:p.n_segs_by_tech[t]) - (maximum(p.cap_cost_slope[t][s] for s in 1:p.n_segs_by_tech[t]) * p.max_sizes[t] + maximum(p.seg_yint[t][s] for s in 1:p.n_segs_by_tech[t]))*(1-m[:binMGTechUsed][t]) ) - @constraint(m, [t in p.techs.segmented], m[:dvMGTechUpgradeCost][t] >= 0.0) + @constraint(m, [t in intersect(p.techs.segmented, p.techs.elec)], m[:dvMGTechUpgradeCost][t] >= 0.0) end end diff --git a/src/constraints/thermal_tech_constraints.jl b/src/constraints/thermal_tech_constraints.jl index 56699b5e9..ef3fafc36 100644 --- a/src/constraints/thermal_tech_constraints.jl +++ b/src/constraints/thermal_tech_constraints.jl @@ -64,7 +64,7 @@ function add_heating_tech_constraints(m, p; _n="") # Constraint (7_heating_prod_size): Production limit based on size for non-electricity-producing heating techs if !isempty(setdiff(p.techs.heating, union(p.techs.elec, p.techs.ghp))) @constraint(m, [t in setdiff(p.techs.heating, union(p.techs.elec, p.techs.ghp)), ts in p.time_steps], - sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) <= m[Symbol("dvSize"*_n)][t] + sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) <= m[Symbol("dvSize"*_n)][t] * p.heating_cf[t][ts] ) end # Constraint (7_heating_load_compatability): Set production variables for incompatible heat loads to zero @@ -88,7 +88,56 @@ function add_heating_tech_constraints(m, p; _n="") end end end - # Enfore + + # Enforce no waste heat for any technology that isn't both electricity- and heat-producing + for t in setdiff(p.techs.heating, union(p.techs.elec, p.techs.ghp)) + for q in p.heating_loads + for ts in p.time_steps + fix(m[Symbol("dvProductionToWaste"*_n)][t,q,ts], 0.0, force=true) + end + end + end +end + +function add_heating_cooling_constraints(m, p; _n="") + @constraint(m, [t in setdiff(intersect(p.techs.cooling, p.techs.heating), p.techs.ghp), ts in p.time_steps], + sum(m[Symbol("dvHeatingProduction"*_n)][t,q,ts] for q in p.heating_loads) / p.heating_cf[t][ts] + m[Symbol("dvCoolingProduction"*_n)][t,ts] / p.cooling_cf[t][ts] <= m[Symbol("dvSize"*_n)][t] + ) +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) + end + 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) + end + 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) + end + end + end +end + +function avoided_capex_by_ashp(m, p; _n="") + m[:AvoidedCapexByASHP] = @expression(m, + sum(p.avoided_capex_by_ashp_present_value[t] for t in p.techs.ashp) + ) end function no_existing_boiler_production(m, p; _n="") @@ -103,7 +152,7 @@ end function add_cooling_tech_constraints(m, p; _n="") # Constraint (7_cooling_prod_size): Production limit based on size for boiler @constraint(m, [t in setdiff(p.techs.cooling, p.techs.ghp), ts in p.time_steps_with_grid], - m[Symbol("dvCoolingProduction"*_n)][t,ts] <= m[Symbol("dvSize"*_n)][t] + m[Symbol("dvCoolingProduction"*_n)][t,ts] <= m[Symbol("dvSize"*_n)][t] * p.cooling_cf[t][ts] ) # The load balance for cooling is only applied to time_steps_with_grid, so make sure we don't arbitrarily show cooling production for time_steps_without_grid for t in setdiff(p.techs.cooling, p.techs.ghp) @@ -112,3 +161,10 @@ function add_cooling_tech_constraints(m, p; _n="") end end end + +function no_existing_chiller_production(m, p; _n="") + for ts in p.time_steps + fix(m[Symbol("dvCoolingProduction"*_n)]["ExistingChiller",ts], 0.0, force=true) + end + fix(m[Symbol("dvSize"*_n)]["ExistingChiller"], 0.0, force=true) +end diff --git a/src/core/ashp.jl b/src/core/ashp.jl new file mode 100644 index 000000000..f2f2bfcc0 --- /dev/null +++ b/src/core/ashp.jl @@ -0,0 +1,509 @@ +# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE. + +""" +ASHPSpaceHeater + +If a user provides the `ASHPSpaceHeater` key then the optimal scenario has the option to purchase +this new `ASHP` to meet the heating load in addition to using the `ExistingBoiler` +to meet the heating load. + +ASHPSpaceHeater has the following attributes: +```julia + 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 + 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 + macrs_option_years::Int # MACRS schedule for financial analysis. Set to zero to disable + macrs_bonus_fraction::Real # Fraction of upfront project costs to depreciate under MACRS + heating_cop::Array{<:Real,1} # COP of the heating (i.e., thermal produced / electricity consumed) + cooling_cop::Array{<:Real,1} # COP of the cooling (i.e., thermal produced / electricity consumed) + heating_cf::Array{<:Real,1} # ASHP's heating capacity factor curves + cooling_cf::Array{<:Real,1} # ASHP's cooling capacity factor curves + can_serve_cooling::Bool # If ASHP can supply heat to the cooling load + force_into_system::Bool # force into system to serve all space heating loads if true + back_up_temp_threshold_degF::Real # Degree in F that system switches from ASHP to resistive heater + avoided_capex_by_ashp_present_value::Real # avoided capital expenditure due to presence of ASHP system vs. defaults heating and cooling techs + max_ton::Real # maximum allowable thermal power (tons) + +``` +""" +struct ASHP <: AbstractThermalTech + min_kw::Real + max_kw::Real + min_allowable_kw::Real + sizing_factor::Real + installed_cost_per_kw::Real + om_cost_per_kw::Real + macrs_option_years::Int + macrs_bonus_fraction::Real + can_supply_steam_turbine::Bool + heating_cop::Array{<:Real,1} + cooling_cop::Array{<:Real,1} + heating_cf::Array{<:Real,1} + cooling_cf::Array{<:Real,1} + can_serve_dhw::Bool + can_serve_space_heating::Bool + can_serve_process_heat::Bool + can_serve_cooling::Bool + force_into_system::Bool + back_up_temp_threshold_degF::Real + avoided_capex_by_ashp_present_value::Real + max_ton::Real + installed_cost_per_ton::Real + om_cost_per_ton::Real + heating_cop_reference::Array{<:Real,1} + heating_cf_reference::Array{<:Real,1} + heating_reference_temps_degF::Array{<:Real,1} + cooling_cop_reference::Array{<:Real,1} + cooling_cf_reference::Array{<:Real,1} + cooling_reference_temps_degF::Array{<:Real,1} +end + + +""" +ASHPSpaceHeater + +If a user provides the `ASHPSpaceHeater` key then the optimal scenario has the option to purchase +this new `ASHP` to meet the heating load in addition to using the `ExistingBoiler` +to meet the heating load. + +```julia +function ASHPSpaceHeater(; + min_ton::Real = 0.0, # Minimum thermal power size + max_ton::Real = BIG_NUMBER, # Maximum thermal power size + min_allowable_ton::Union{Real, Nothing} = nothing, # Minimum nonzero thermal power size if included + min_allowable_peak_capacity_fraction::Union{Real, Nothing} = nothing, # minimum allowable fraction of peak heating + cooling load + sizing_factor::::Union{Real, Nothing} = nothing, # Size multiplier of system, relative that of the max load given by dispatch profile + om_cost_per_ton::Union{Real, nothing} = nothing, # Thermal power-based fixed O&M cost + macrs_option_years::Int = 0, # MACRS schedule for financial analysis. Set to zero to disable + macrs_bonus_fraction::Real = 0.0, # Fraction of upfront project costs to depreciate under MACRS + can_serve_cooling::Union{Bool, Nothing} = nothing # If ASHP can supply heat to the cooling load + force_into_system::Bool = false # force into system to serve all space heating loads if true + avoided_capex_by_ashp_present_value::Real = 0.0 # avoided capital expenditure due to presence of ASHP system vs. defaults heating and cooling techs + + #The following inputs are used to create the attributes heating_cop and heating cf: + heating_cop_reference::Array{<:Real,1}, # COP of the heating (i.e., thermal produced / electricity consumed) + heating_cf_reference::Array{<:Real,1}, # ASHP's heating capacity factor curves + heating_reference_temps_degF ::Array{<:Real,1}, # ASHP's reference temperatures for heating COP and CF + back_up_temp_threshold_degF::Real = 10, # Degree in F that system switches from ASHP to resistive heater + + #The following inputs are used to create the attributes cooling_cop and cooling cf: + cooling_cop::Array{<:Real,1}, # COP of the cooling (i.e., thermal produced / electricity consumed) + cooling_cf::Array{<:Real,1}, # ASHP's cooling capacity factor curves + cooling_reference_temps_degF ::Array{<:Real,1}, # ASHP's reference temperatures for cooling COP and CF + + #The following inputs are taken from the Site object: + ambient_temp_degF::Array{<:Real,1} #time series of ambient temperature + heating_load::Array{Real,1} # time series of site space heating load + cooling_load::Union{Array{Real,1}, Nothing} # time series of site cooling load +) +``` +""" +function ASHPSpaceHeater(; + min_ton::Real = 0.0, + max_ton::Real = BIG_NUMBER, + min_allowable_ton::Union{Real, Nothing} = nothing, + min_allowable_peak_capacity_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, + macrs_option_years::Int = 0, + macrs_bonus_fraction::Real = 0.0, + avoided_capex_by_ashp_present_value::Real = 0.0, + can_serve_cooling::Union{Bool, Nothing} = nothing, + force_into_system::Bool = false, + heating_cop_reference::Array{<:Real,1} = Real[], + heating_cf_reference::Array{<:Real,1} = Real[], + heating_reference_temps_degF::Array{<:Real,1} = Real[], + back_up_temp_threshold_degF::Union{Real, Nothing} = nothing, + cooling_cop_reference::Array{<:Real,1} = Real[], + cooling_cf_reference::Array{<:Real,1} = Real[], + cooling_reference_temps_degF::Array{<:Real,1} = Real[], + ambient_temp_degF::Array{<:Real,1} = Real[], + heating_load::Array{<:Real,1} = Real[], + cooling_load::Array{<:Real,1} = Real[] + ) + + defaults = get_ashp_defaults("SpaceHeating",force_into_system) + + # populate defaults as needed + if isnothing(installed_cost_per_ton) + installed_cost_per_ton = defaults["installed_cost_per_ton"] + end + if isnothing(om_cost_per_ton) + om_cost_per_ton = defaults["om_cost_per_ton"] + end + if isnothing(can_serve_cooling) + can_serve_cooling = defaults["can_serve_cooling"] + end + if isnothing(force_into_system) + force_into_system = defaults["force_into_system"] + end + if isnothing(back_up_temp_threshold_degF) + back_up_temp_threshold_degF = defaults["back_up_temp_threshold_degF"] + end + if isnothing(max_ton) + max_ton = defaults["max_ton"] + end + if isnothing(sizing_factor) + sizing_factor = defaults["sizing_factor"] + end + + if length(heating_cop_reference) != length(heating_cf_reference) || length(heating_cf_reference) != length(heating_reference_temps_degF) + throw(@error("heating_cop_reference, heating_cf_reference, and heating_reference_temps_degF must all be the same length.")) + else + if length(heating_cop_reference) == 0 + heating_cop_reference = convert(Vector{Float64}, defaults["heating_cop_reference"]) + heating_cf_reference = convert(Vector{Float64}, defaults["heating_cf_reference"]) + heating_reference_temps_degF = convert(Vector{Float64}, defaults["heating_reference_temps_degF"]) + end + end + if length(cooling_cop_reference) != length(cooling_cf_reference) || length(cooling_cf_reference) != length(cooling_reference_temps_degF) + throw(@error("cooling_cop_reference, cooling_cf_reference, and cooling_reference_temps_degF must all be the same length.")) + else + if length(cooling_cop_reference) == 0 && can_serve_cooling + cooling_cop_reference = convert(Vector{Float64}, defaults["cooling_cop_reference"]) + cooling_cf_reference = convert(Vector{Float64}, defaults["cooling_cf_reference"]) + cooling_reference_temps_degF = convert(Vector{Float64}, defaults["cooling_reference_temps_degF"]) + end + end + + #pre-set defaults that aren't mutable due to technology specifications + can_supply_steam_turbine = defaults["can_supply_steam_turbine"] + can_serve_space_heating = defaults["can_serve_space_heating"] + can_serve_dhw = defaults["can_serve_dhw"] + can_serve_process_heat = defaults["can_serve_process_heat"] + + + # Convert max sizes, cost factors from mmbtu_per_hour to kw + min_kw = min_ton * KWH_THERMAL_PER_TONHOUR + max_kw = max_ton * KWH_THERMAL_PER_TONHOUR + + + installed_cost_per_kw = installed_cost_per_ton / KWH_THERMAL_PER_TONHOUR + om_cost_per_kw = om_cost_per_ton / KWH_THERMAL_PER_TONHOUR + + heating_cop, heating_cf = get_ashp_performance(heating_cop_reference, + heating_cf_reference, + heating_reference_temps_degF, + ambient_temp_degF, + back_up_temp_threshold_degF + ) + + heating_cf[heating_cop .== 1] .= 1 + + if can_serve_cooling + cooling_cop, cooling_cf = get_ashp_performance(cooling_cop_reference, + cooling_cf_reference, + cooling_reference_temps_degF, + ambient_temp_degF, + -460 + ) + else + cooling_cop = Float64[] + 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.")) + elseif !isnothing(min_allowable_ton) + min_allowable_kw = min_allowable_ton * KWH_THERMAL_PER_TONHOUR + @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) + min_allowable_peak_capacity_fraction = 0.5 + end + min_allowable_kw = get_ashp_default_min_allowable_size(heating_load, heating_cf, cooling_load, cooling_cf, min_allowable_peak_capacity_fraction) + end + + if min_allowable_kw > max_kw + throw(@error("The ASHPSpaceHeater minimum allowable size of $min_allowable_kw kW is larger than the maximum size of $max_kw kW.")) + end + + installed_cost_per_kw *= sizing_factor + om_cost_per_kw *= sizing_factor + + ASHP( + min_kw, + max_kw, + min_allowable_kw, + sizing_factor, + installed_cost_per_kw, + om_cost_per_kw, + macrs_option_years, + macrs_bonus_fraction, + can_supply_steam_turbine, + heating_cop, + cooling_cop, + heating_cf, + cooling_cf, + can_serve_dhw, + can_serve_space_heating, + can_serve_process_heat, + can_serve_cooling, + force_into_system, + back_up_temp_threshold_degF, + avoided_capex_by_ashp_present_value, + max_ton, + installed_cost_per_ton, + om_cost_per_ton, + heating_cop_reference, + heating_cf_reference, + heating_reference_temps_degF, + cooling_cop_reference, + cooling_cf_reference, + cooling_reference_temps_degF + ) +end + + +""" +ASHP Water_Heater + +If a user provides the `ASHPWaterHeater` key then the optimal scenario has the option to purchase +this new `ASHPWaterHeater` to meet the domestic hot water load in addition to using the `ExistingBoiler` +to meet the domestic hot water load. + +```julia +function ASHPWaterHeater(; + min_ton::Real = 0.0, # Minimum thermal power size + max_ton::Real = BIG_NUMBER, # Maximum thermal power size + min_allowable_ton::Real = 0.0 # Minimum nonzero thermal power size if included + installed_cost_per_ton::Union{Real, nothing} = nothing, # Thermal power-based cost + om_cost_per_ton::Union{Real, nothing} = nothing, # Thermal power-based fixed O&M cost + macrs_option_years::Int = 0, # MACRS schedule for financial analysis. Set to zero to disable + macrs_bonus_fraction::Real = 0.0, # Fraction of upfront project costs to depreciate under MACRS + can_supply_steam_turbine::Union{Bool, nothing} = nothing # If the boiler can supply steam to the steam turbine for electric production + avoided_capex_by_ashp_present_value::Real = 0.0 # avoided capital expenditure due to presence of ASHP system vs. defaults heating and cooling techs + force_into_system::Bool = false # force into system to serve all hot water loads if true + + #The following inputs are used to create the attributes heating_cop and heating cf: + heating_cop_reference::Array{<:Real,1}, # COP of the heating (i.e., thermal produced / electricity consumed) + heating_cf_reference::Array{<:Real,1}, # ASHP's heating capacity factor curves + heating_reference_temps_degF ::Array{<:Real,1}, # ASHP's reference temperatures for heating COP and CF + back_up_temp_threshold_degF::Real = 10 # temperature threshold at which backup resistive heater is used + + #The following inputs are taken from the Site object: + ambient_temp_degF::Array{<:Real,1} = Float64[] # time series of ambient temperature + heating_load::Array{<:Real,1} # time series of site domestic hot water load +) +``` +""" +function ASHPWaterHeater(; + min_ton::Real = 0.0, + max_ton::Real = BIG_NUMBER, + min_allowable_ton::Union{Real, Nothing} = nothing, + min_allowable_peak_capacity_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, + macrs_option_years::Int = 0, + macrs_bonus_fraction::Real = 0.0, + avoided_capex_by_ashp_present_value::Real = 0.0, + force_into_system::Bool = false, + heating_cop_reference::Array{<:Real,1} = Real[], + heating_cf_reference::Array{<:Real,1} = Real[], + heating_reference_temps_degF::Array{<:Real,1} = Real[], + back_up_temp_threshold_degF::Union{Real, Nothing} = nothing, + ambient_temp_degF::Array{<:Real,1} = Real[], + heating_load::Array{<:Real,1} = Real[] + ) + + defaults = get_ashp_defaults("DomesticHotWater",force_into_system) + + # populate defaults as needed + if isnothing(installed_cost_per_ton) + installed_cost_per_ton = defaults["installed_cost_per_ton"] + end + if isnothing(om_cost_per_ton) + om_cost_per_ton = defaults["om_cost_per_ton"] + end + if isnothing(back_up_temp_threshold_degF) + back_up_temp_threshold_degF = defaults["back_up_temp_threshold_degF"] + end + if isnothing(max_ton) + max_ton = defaults["max_ton"] + end + if isnothing(sizing_factor) + sizing_factor = defaults["sizing_factor"] + end + + if length(heating_cop_reference) != length(heating_cf_reference) || length(heating_cf_reference) != length(heating_reference_temps_degF) + throw(@error("heating_cop_reference, heating_cf_reference, and heating_reference_temps_degF must all be the same length.")) + else + if length(heating_cop_reference) == 0 + heating_cop_reference = convert(Vector{Float64}, defaults["heating_cop_reference"]) + heating_cf_reference = convert(Vector{Float64}, defaults["heating_cf_reference"]) + heating_reference_temps_degF = convert(Vector{Float64}, defaults["heating_reference_temps_degF"]) + end + end + + #pre-set defaults that aren't mutable due to technology specifications + can_supply_steam_turbine = defaults["can_supply_steam_turbine"] + can_serve_space_heating = defaults["can_serve_space_heating"] + can_serve_dhw = defaults["can_serve_dhw"] + can_serve_process_heat = defaults["can_serve_process_heat"] + can_serve_cooling = defaults["can_serve_cooling"] + + # Convert max sizes, cost factors from mmbtu_per_hour to kw + min_kw = min_ton * KWH_THERMAL_PER_TONHOUR + max_kw = max_ton * KWH_THERMAL_PER_TONHOUR + + installed_cost_per_kw = installed_cost_per_ton / KWH_THERMAL_PER_TONHOUR + om_cost_per_kw = om_cost_per_ton / KWH_THERMAL_PER_TONHOUR + + heating_cop, heating_cf = get_ashp_performance(heating_cop_reference, + heating_cf_reference, + heating_reference_temps_degF, + ambient_temp_degF, + back_up_temp_threshold_degF + ) + + 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.")) + elseif !isnothing(min_allowable_ton) + min_allowable_kw = min_allowable_ton * KWH_THERMAL_PER_TONHOUR + @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) + min_allowable_peak_capacity_fraction = 0.5 + end + 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 + throw(@error("The ASHPWaterHeater minimum allowable size of $min_allowable_kw kW is larger than the maximum size of $max_kw kW.")) + end + + ASHP( + min_kw, + max_kw, + min_allowable_kw, + sizing_factor, + installed_cost_per_kw, + om_cost_per_kw, + macrs_option_years, + macrs_bonus_fraction, + can_supply_steam_turbine, + heating_cop, + Float64[], + heating_cf, + Float64[], + can_serve_dhw, + can_serve_space_heating, + can_serve_process_heat, + can_serve_cooling, + force_into_system, + back_up_temp_threshold_degF, + avoided_capex_by_ashp_present_value, + max_ton, + installed_cost_per_ton, + om_cost_per_ton, + heating_cop_reference, + heating_cf_reference, + heating_reference_temps_degF, + Real[], + Real[], + Real[] + ) +end + + + +""" +function get_ashp_defaults(load_served::String="SpaceHeating") + +Obtains defaults for the ASHP from a JSON data file. + +inputs +load_served::String -- identifier of heating load served by AHSP system +force_into_system::Bool -- exclusively serves compatible thermal loads if true (i.e., replaces existing technologies) + +returns +ashp_defaults::Dict -- Dictionary containing defaults for ASHP +""" +function get_ashp_defaults(load_served::String="SpaceHeating", force_into_system::Bool=false) + if !(load_served in ["SpaceHeating", "DomesticHotWater"]) + throw(@error("Invalid inputs: argument `load_served` to function get_ashp_defaults() must be a String in the set ['SpaceHeating', 'DomesticHotWater'].")) + end + all_ashp_defaults = JSON.parsefile(joinpath(dirname(@__FILE__), "..", "..", "data", "ashp", "ashp_defaults.json")) + defaults = all_ashp_defaults[load_served] + if force_into_system + defaults["sizing_factor"] = 1.1 + defaults["om_cost_per_ton"] = 0.0 + end + return defaults +end + +""" +function get_ashp_performance(cop_reference, + cf_reference, + reference_temps, + ambient_temp_degF, + back_up_temp_threshold_degF = 10.0 + ) +""" +function get_ashp_performance(cop_reference, + cf_reference, + reference_temps, + ambient_temp_degF, + back_up_temp_threshold_degF = 10.0 + ) + num_timesteps = length(ambient_temp_degF) + cop = zeros(num_timesteps) + cf = zeros(num_timesteps) + for ts in 1:num_timesteps + if ambient_temp_degF[ts] < reference_temps[1] && ambient_temp_degF[ts] < last(reference_temps) + cop[ts] = cop_reference[argmin(reference_temps)] + cf[ts] = cf_reference[argmin(reference_temps)] + elseif ambient_temp_degF[ts] > reference_temps[1] && ambient_temp_degF[ts] > last(reference_temps) + cop[ts] = cop_reference[argmax(reference_temps)] + cf[ts] = cf_reference[argmax(reference_temps)] + else + for i in 2:length(reference_temps) + if ambient_temp_degF[ts] >= min(reference_temps[i-1], reference_temps[i]) && + ambient_temp_degF[ts] <= max(reference_temps[i-1], reference_temps[i]) + cop[ts] = cop_reference[i-1] + (cop_reference[i]-cop_reference[i-1])*(ambient_temp_degF[ts]-reference_temps[i-1])/(reference_temps[i]-reference_temps[i-1]) + cf[ts] = cf_reference[i-1] + (cf_reference[i]-cf_reference[i-1])*(ambient_temp_degF[ts]-reference_temps[i-1])/(reference_temps[i]-reference_temps[i-1]) + break + end + end + end + if ambient_temp_degF[ts] < back_up_temp_threshold_degF + cop[ts] = 1.0 + cf[ts] = 1.0 + end + end + return cop, cf +end + +""" +function get_ashp_default_min_allowable_size(heating_load::Array{Float64}, # time series of heating load + heating_cf::Array{Float64,1}, # time series of capacity factor for heating + cooling_load::Array{Float64,1} = Float64[], # # time series of capacity factor for heating + cooling_cf::Array{Float64,1} = Float64[], # time series of capacity factor for heating + peak_load_thermal_factor::Float64 = 0.5 # peak load multiplier for minimum allowable size + ) + +Obtains the default minimum allowable size for ASHP system. This is calculated as half of the peak site thermal load(s) addressed by the system, including the capacity factor +""" +function get_ashp_default_min_allowable_size(heating_load::Array{<:Real,1}, + heating_cf::Array{<:Real,1}, + cooling_load::Array{<:Real,1} = Real[], + cooling_cf::Array{<:Real,1} = Real[], + peak_load_thermal_factor::Real = 0.5 + ) + + if isempty(cooling_cf) + peak_load = maximum(heating_load ./ heating_cf) + else + peak_load = maximum( (heating_load ./ heating_cf) .+ (cooling_load ./ cooling_cf) ) + end + + return peak_load_thermal_factor * peak_load +end + diff --git a/src/core/bau_inputs.jl b/src/core/bau_inputs.jl index e854c1107..c8b7661b5 100644 --- a/src/core/bau_inputs.jl +++ b/src/core/bau_inputs.jl @@ -25,9 +25,12 @@ function BAUInputs(p::REoptInputs) existing_sizes = Dict(t => 0.0 for t in techs.all) cap_cost_slope = Dict{String, Any}() om_cost_per_kw = Dict(t => 0.0 for t in techs.all) - cop = Dict(t => 0.0 for t in techs.cooling) thermal_cop = Dict{String, Float64}() - heating_cop = Dict{String, Float64}() + heating_cop = Dict{String, Array{Float64,1}}() + cooling_cop = Dict{String, Array{Float64,1}}() + heating_cf = Dict{String, Array{Float64,1}}() + cooling_cf = Dict{String, Array{Float64,1}}() + avoided_capex_by_ashp_present_value = Dict(t => 0.0 for t in techs.all) production_factor = DenseAxisArray{Float64}(undef, techs.all, p.time_steps) tech_renewable_energy_fraction = Dict(t => 0.0 for t in techs.all) # !!! note: tech_emissions_factors are in lb / kWh of fuel burned (gets multiplied by kWh of fuel burned, not kWh electricity consumption, ergo the use of the HHV instead of fuel slope) @@ -90,13 +93,13 @@ function BAUInputs(p::REoptInputs) if "ExistingBoiler" in techs.all setup_existing_boiler_inputs(bau_scenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, boiler_efficiency, - tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh) + tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh, + heating_cf) end + cooling_cop["ExistingChiller"] = ones(length(p.time_steps)) if "ExistingChiller" in techs.all - setup_existing_chiller_inputs(bau_scenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, cop) - else - cop["ExistingChiller"] = 1.0 + setup_existing_chiller_inputs(bau_scenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, cooling_cop, cooling_cf) end # Assign null GHP parameters for REoptInputs @@ -170,7 +173,6 @@ function BAUInputs(p::REoptInputs) existing_sizes, cap_cost_slope, om_cost_per_kw, - cop, thermal_cop, p.time_steps, p.time_steps_with_grid, @@ -223,11 +225,15 @@ function BAUInputs(p::REoptInputs) tech_emissions_factors_PM25, p.techs_operating_reserve_req_fraction, heating_cop, + cooling_cop, + heating_cf, + cooling_cf, heating_loads, heating_loads_kw, heating_loads_served_by_tes, unavailability, - absorption_chillers_using_heating_load + absorption_chillers_using_heating_load, + avoided_capex_by_ashp_present_value ) end diff --git a/src/core/existing_chiller.jl b/src/core/existing_chiller.jl index 73f97b5f1..bb20830d3 100644 --- a/src/core/existing_chiller.jl +++ b/src/core/existing_chiller.jl @@ -5,6 +5,7 @@ loads_kw_thermal::Vector{<:Real}, cop::Union{Real, Nothing} = nothing, max_thermal_factor_on_peak_load::Real=1.25 + retire_in_optimal::Bool = false # Do NOT use in the optimal case (still used in BAU) ``` !!! note "Max ExistingChiller size" @@ -17,19 +18,22 @@ struct ExistingChiller <: AbstractThermalTech max_kw::Real cop::Union{Real, Nothing} max_thermal_factor_on_peak_load::Real + retire_in_optimal::Bool end function ExistingChiller(; loads_kw_thermal::Vector{<:Real}, cop::Union{Real, Nothing} = nothing, - max_thermal_factor_on_peak_load::Real=1.25 + max_thermal_factor_on_peak_load::Real=1.25, + retire_in_optimal::Bool = false ) max_kw = maximum(loads_kw_thermal) * max_thermal_factor_on_peak_load ExistingChiller( max_kw, cop, - max_thermal_factor_on_peak_load + max_thermal_factor_on_peak_load, + retire_in_optimal ) end diff --git a/src/core/heating_cooling_loads.jl b/src/core/heating_cooling_loads.jl index bd769a015..92b3eecb8 100644 --- a/src/core/heating_cooling_loads.jl +++ b/src/core/heating_cooling_loads.jl @@ -18,13 +18,15 @@ There are many ways in which a DomesticHotWaterLoad can be defined: 3. One can provide the `fuel_loads_mmbtu_per_hour` value in the `DomesticHotWaterLoad` key within the `Scenario`. !!! note "Hot water loads" - Hot water and space heating thermal "load" inputs are in terms of energy input required (boiler fuel), not the actual energy demand. - The fuel energy is multiplied by the boiler_efficiency to get the actual energy demand. + Hot water, space heating, and process heat thermal "load" inputs are in terms of energy input required (boiler fuel), + not the actual energy demand. The fuel energy is multiplied by the existing_boiler_efficiency to get the actual energy + demand. """ struct DomesticHotWaterLoad loads_kw::Array{Real, 1} annual_mmbtu::Real + unaddressable_annual_fuel_mmbtu::Real function DomesticHotWaterLoad(; doe_reference_name::String = "", @@ -62,6 +64,7 @@ struct DomesticHotWaterLoad end loads_kw = fuel_loads_mmbtu_per_hour .* (KWH_PER_MMBTU * existing_boiler_efficiency) .* addressable_load_fraction + unaddressable_annual_fuel_mmbtu = sum(fuel_loads_mmbtu_per_hour .* (1 .- addressable_load_fraction)) / time_steps_per_hour if !isempty(doe_reference_name) || length(blended_doe_reference_names) > 0 @warn "DomesticHotWaterLoad `fuel_loads_mmbtu_per_hour` was provided, so doe_reference_name and/or blended_doe_reference_names will be ignored." @@ -72,12 +75,14 @@ struct DomesticHotWaterLoad if length(blended_doe_reference_names) > 0 @warn "DomesticHotWaterLoad doe_reference_name was provided, so blended_doe_reference_names will be ignored." end + unaddressable_annual_fuel_mmbtu = get_unaddressable_fuel(addressable_load_fraction, annual_mmbtu, monthly_mmbtu, loads_kw, existing_boiler_efficiency) elseif length(blended_doe_reference_names) > 0 && length(blended_doe_reference_names) == length(blended_doe_reference_percents) loads_kw = blend_and_scale_doe_profiles(BuiltInDomesticHotWaterLoad, latitude, longitude, 2017, blended_doe_reference_names, blended_doe_reference_percents, city, annual_mmbtu, monthly_mmbtu, addressable_load_fraction, existing_boiler_efficiency) + unaddressable_annual_fuel_mmbtu = get_unaddressable_fuel(addressable_load_fraction, annual_mmbtu, monthly_mmbtu, loads_kw, existing_boiler_efficiency) else throw(@error("Cannot construct DomesticHotWaterLoad. You must provide either [fuel_loads_mmbtu_per_hour], [doe_reference_name, city], or [blended_doe_reference_names, blended_doe_reference_percents, city].")) @@ -90,7 +95,8 @@ struct DomesticHotWaterLoad new( loads_kw, - (sum(loads_kw)/time_steps_per_hour)/KWH_PER_MMBTU + (sum(loads_kw)/time_steps_per_hour)/KWH_PER_MMBTU, + unaddressable_annual_fuel_mmbtu ) end end @@ -127,12 +133,14 @@ In this case the values provided for `doe_reference_name`, or `blended_doe_refe `blended_doe_reference_percents` are copied from the `ElectricLoad` to the `SpaceHeatingLoad`. !!! note "Space heating loads" - Hot water and space heating thermal "load" inputs are in terms of energy input required (boiler fuel), not the actual energy demand. - The fuel energy is multiplied by the boiler_efficiency to get the actual energy demand. + Hot water, space heating, and process heat thermal "load" inputs are in terms of energy input required (boiler fuel), + not the actual energy demand. The fuel energy is multiplied by the existing_boiler_efficiency to get the actual energy + emand. """ struct SpaceHeatingLoad loads_kw::Array{Real, 1} annual_mmbtu::Real + unaddressable_annual_fuel_mmbtu::Real function SpaceHeatingLoad(; doe_reference_name::String = "", @@ -170,6 +178,7 @@ struct SpaceHeatingLoad end loads_kw = fuel_loads_mmbtu_per_hour .* (KWH_PER_MMBTU * existing_boiler_efficiency) .* addressable_load_fraction + unaddressable_annual_fuel_mmbtu = sum(fuel_loads_mmbtu_per_hour .* (1 .- addressable_load_fraction)) / time_steps_per_hour if !isempty(doe_reference_name) || length(blended_doe_reference_names) > 0 @warn "SpaceHeatingLoad fuel_loads_mmbtu_per_hour was provided, so doe_reference_name and/or blended_doe_reference_names will be ignored." @@ -180,12 +189,14 @@ struct SpaceHeatingLoad if length(blended_doe_reference_names) > 0 @warn "SpaceHeatingLoad doe_reference_name was provided, so blended_doe_reference_names will be ignored." end + unaddressable_annual_fuel_mmbtu = get_unaddressable_fuel(addressable_load_fraction, annual_mmbtu, monthly_mmbtu, loads_kw, existing_boiler_efficiency) elseif length(blended_doe_reference_names) > 0 && length(blended_doe_reference_names) == length(blended_doe_reference_percents) loads_kw = blend_and_scale_doe_profiles(BuiltInSpaceHeatingLoad, latitude, longitude, 2017, blended_doe_reference_names, blended_doe_reference_percents, city, annual_mmbtu, monthly_mmbtu, addressable_load_fraction, existing_boiler_efficiency) + unaddressable_annual_fuel_mmbtu = get_unaddressable_fuel(addressable_load_fraction, annual_mmbtu, monthly_mmbtu, loads_kw, existing_boiler_efficiency) else throw(@error("Cannot construct BuiltInSpaceHeatingLoad. You must provide either [fuel_loads_mmbtu_per_hour], [doe_reference_name, city], or [blended_doe_reference_names, blended_doe_reference_percents, city].")) @@ -198,7 +209,8 @@ struct SpaceHeatingLoad new( loads_kw, - (sum(loads_kw)/time_steps_per_hour)/KWH_PER_MMBTU + (sum(loads_kw)/time_steps_per_hour)/KWH_PER_MMBTU, + unaddressable_annual_fuel_mmbtu ) end end @@ -1426,17 +1438,28 @@ end """ `ProcessHeatLoad` is an optional REopt input with the following keys and default values: ```julia - annual_mmbtu::Union{Real, Nothing} = nothing - fuel_loads_mmbtu_per_hour::Array{<:Real,1} = Real[] + industry_reference_name::String = "", + sector::String = "", + blended_industry_reference_names::Array{String, 1} = String[], + blended_industry_reference_percents::Array{<:Real, 1} = Real[], + annual_mmbtu::Union{Real, Nothing} = nothing, + monthly_mmbtu::Array{<:Real,1} = Real[], + addressable_load_fraction::Any = 1.0, + fuel_loads_mmbtu_per_hour::Array{<:Real,1} = Real[], + time_steps_per_hour::Int = 1, # corresponding to `fuel_loads_mmbtu_per_hour` + latitude::Real = 0.0, + longitude::Real = 0.0, + existing_boiler_efficiency::Real = NaN ``` There are many ways in which a ProcessHeatLoad can be defined: -1. One can provide the `fuel_loads_mmbtu_per_hour` value in the `ProcessHeatLoad` key within the `Scenario`. -2. One can provide the `annual_mmbtu` value in the `ProcessHeatLoad` key within the `Scenario`; this assumes a flat load. +1. When using either `industry_reference_name` or `blended_industry_reference_names` +2. One can provide the `industry_reference_name` or `blended_industry_reference_names` directly in the `ProcessHeatLoad` key within the `Scenario`. These values can be combined with the `annual_mmbtu` or `monthly_mmbtu` inputs to scale the industry reference profile(s). +3. One can provide the `fuel_loads_mmbtu_per_hour` value in the `ProcessHeatLoad` key within the `Scenario`. !!! note "Process heat loads" - These loads are presented in terms of process heat required without regard to the efficiency of the input heating, - unlike the hot-water and space heating loads which are provided in terms of fuel input. + Process heat "load" inputs are in terms of fuel energy input required (boiler fuel), not the actual thermal demand. + The fuel energy is multiplied by the existing_boiler_efficiency to get the actual energy demand. """ function BuiltInProcessHeatLoad( @@ -1485,9 +1508,11 @@ function BuiltInProcessHeatLoad( built_in_load("process_heat", city, buildingtype, year, annual_mmbtu, monthly_mmbtu, existing_boiler_efficiency) end + struct ProcessHeatLoad loads_kw::Array{Real, 1} annual_mmbtu::Real + unaddressable_annual_fuel_mmbtu::Real function ProcessHeatLoad(; industry_reference_name::String = "", @@ -1532,6 +1557,7 @@ struct ProcessHeatLoad end loads_kw = fuel_loads_mmbtu_per_hour .* (KWH_PER_MMBTU * existing_boiler_efficiency) .* addressable_load_fraction + unaddressable_annual_fuel_mmbtu = sum(fuel_loads_mmbtu_per_hour .* (1 .- addressable_load_fraction)) / time_steps_per_hour if !isempty(doe_reference_name) || length(blended_doe_reference_names) > 0 @warn "ProcessHeatLoad fuel_loads_mmbtu_per_hour was provided, so doe_reference_name and/or blended_doe_reference_names will be ignored." @@ -1542,15 +1568,18 @@ struct ProcessHeatLoad if length(blended_doe_reference_names) > 0 @warn "ProcessHeatLoad doe_reference_name was provided, so blended_doe_reference_names will be ignored." end + unaddressable_annual_fuel_mmbtu = get_unaddressable_fuel(addressable_load_fraction, annual_mmbtu, monthly_mmbtu, loads_kw, existing_boiler_efficiency) elseif length(blended_doe_reference_names) > 0 && length(blended_doe_reference_names) == length(blended_doe_reference_percents) loads_kw = blend_and_scale_doe_profiles(BuiltInProcessHeatLoad, latitude, longitude, 2017, blended_doe_reference_names, blended_doe_reference_percents, city, annual_mmbtu, monthly_mmbtu, addressable_load_fraction, existing_boiler_efficiency) + + unaddressable_annual_fuel_mmbtu = get_unaddressable_fuel(addressable_load_fraction, annual_mmbtu, monthly_mmbtu, loads_kw, existing_boiler_efficiency) else throw(@error("Cannot construct BuiltInProcessHeatLoad. You must provide either [fuel_loads_mmbtu_per_hour], - [doe_reference_name, city], or [blended_doe_reference_names, blended_doe_reference_percents, city].")) + [industry_reference_name, city], or [blended_industry_reference_names, blended_industry_reference_percents, city].")) end if length(loads_kw) < 8760*time_steps_per_hour @@ -1560,7 +1589,29 @@ struct ProcessHeatLoad new( loads_kw, - (sum(loads_kw)/time_steps_per_hour)/KWH_PER_MMBTU + (sum(loads_kw)/time_steps_per_hour)/KWH_PER_MMBTU, + unaddressable_annual_fuel_mmbtu + ) end +end + +""" + get_unaddressable_fuel(addressable_load_fraction, annual_mmbtu, monthly_mmbtu, loads_kw, existing_boiler_efficiency) + +Get unaddressable fuel load, for reporting + :addressable_load_fraction is the fraction of the input fuel load that is addressable to supply by energy technologies, like CHP + :annual_mmbtu and :monthly_mmbtu is assumed to be fuel, not thermal, in this function + :loads_kw is assumed to be thermal in this function, with units of kw_thermal, so needs to be converted to fuel mmbtu +""" +function get_unaddressable_fuel(addressable_load_fraction, annual_mmbtu, monthly_mmbtu, loads_kw, existing_boiler_efficiency) + # Get unaddressable fuel load, for reporting + if !isempty(monthly_mmbtu) + unaddressable_annual_fuel_mmbtu = sum(monthly_mmbtu .* (1 .- addressable_load_fraction)) + elseif !isnothing(annual_mmbtu) + unaddressable_annual_fuel_mmbtu = annual_mmbtu * (1 - addressable_load_fraction) + else # using the default CRB annual_mmbtu, so rely on loads_kw (thermal) assuming single addressable_load_fraction + unaddressable_annual_fuel_mmbtu = sum(loads_kw) / (KWH_PER_MMBTU * existing_boiler_efficiency) + end + return unaddressable_annual_fuel_mmbtu end \ No newline at end of file diff --git a/src/core/production_factor.jl b/src/core/production_factor.jl index 51b8c18e8..b0312aad2 100644 --- a/src/core/production_factor.jl +++ b/src/core/production_factor.jl @@ -7,7 +7,7 @@ function get_production_factor(pv::PV, latitude::Real, longitude::Real; timefram return pv.production_factor_series end - watts, ambient_temp_celcius = call_pvwatts_api(latitude, longitude; tilt=pv.tilt, azimuth=pv.azimuth, module_type=pv.module_type, + watts, ambient_temp_celsius = call_pvwatts_api(latitude, longitude; tilt=pv.tilt, azimuth=pv.azimuth, module_type=pv.module_type, array_type=pv.array_type, losses=round(pv.losses*100, digits=3), dc_ac_ratio=pv.dc_ac_ratio, gcr=pv.gcr, inv_eff=pv.inv_eff*100, timeframe=timeframe, radius=pv.radius, time_steps_per_hour=time_steps_per_hour) @@ -72,7 +72,7 @@ function get_production_factor(wind::Wind, latitude::Real, longitude::Real, time resource = [] try @info "Querying Wind Toolkit for resource data ..." - r = HTTP.get(url; retries=5) + r = HTTP.get(url, ["User-Agent" => "REopt.jl"]; retries=5) if r.status != 200 throw(@error("Bad response from Wind Toolkit: $(response["errors"])")) end diff --git a/src/core/reopt.jl b/src/core/reopt.jl index e9344b703..e5e1d11ef 100644 --- a/src/core/reopt.jl +++ b/src/core/reopt.jl @@ -263,6 +263,7 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) m[:GHPCapCosts] = 0.0 m[:GHPOMCosts] = 0.0 m[:AvoidedCapexByGHP] = 0.0 + m[:AvoidedCapexByASHP] = 0.0 m[:ResidualGHXCapCost] = 0.0 m[:ObjectivePenalties] = 0.0 @@ -296,13 +297,11 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) add_heating_tech_constraints(m, p) end - # Zero out ExistingBoiler production if retire_in_optimal; new_heating_techs avoids zeroing for BAU - new_heating_techs = ["CHP", "Boiler", "ElectricHeater", "SteamTurbine"] - if !isempty(intersect(new_heating_techs, p.techs.all)) - if !isnothing(p.s.existing_boiler) && p.s.existing_boiler.retire_in_optimal - no_existing_boiler_production(m, p) - end + # Zero out ExistingBoiler production if retire_in_optimal + if !isnothing(p.s.existing_boiler) && p.s.existing_boiler.retire_in_optimal && !isempty(setdiff(union(p.techs.chp,p.techs.heating), ["ExistingBoiler"])) + no_existing_boiler_production(m, p) end + if !isempty(p.techs.boiler) add_boiler_tech_constraints(m, p) @@ -313,6 +312,23 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) if !isempty(p.techs.cooling) add_cooling_tech_constraints(m, p) end + + # Zero out ExistingChiller production if retire_in_optimal; setdiff avoids zeroing for BAU + if !isnothing(p.s.existing_chiller) && p.s.existing_chiller.retire_in_optimal && !isempty(setdiff(p.techs.cooling, ["ExistingChiller"])) + no_existing_chiller_production(m, p) + end + + if !isempty(setdiff(intersect(p.techs.cooling, p.techs.heating), p.techs.ghp)) + 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 if !isempty(p.techs.thermal) add_thermal_load_constraints(m, p) # split into heating and cooling constraints? @@ -480,7 +496,10 @@ function build_reopt!(m::JuMP.AbstractModel, p::REoptInputs) m[:OffgridOtherCapexAfterDepr] - # Subtract capital expenditures avoided by inclusion of GHP and residual present value of GHX. - m[:AvoidedCapexByGHP] - m[:ResidualGHXCapCost] + m[:AvoidedCapexByGHP] - m[:ResidualGHXCapCost] - + + # Subtract capital expenditures avoided by inclusion of ASHP + m[:AvoidedCapexByASHP] ); if !isempty(p.s.electric_utility.outage_durations) diff --git a/src/core/reopt_inputs.jl b/src/core/reopt_inputs.jl index fa0969da2..409458e6c 100644 --- a/src/core/reopt_inputs.jl +++ b/src/core/reopt_inputs.jl @@ -12,7 +12,6 @@ struct REoptInputs <: AbstractInputs existing_sizes::Dict{String, <:Real} # (techs) cap_cost_slope::Dict{String, Any} # (techs) om_cost_per_kw::Dict{String, <:Real} # (techs) - cop::Dict{String, <:Real} # (techs.cooling) thermal_cop::Dict{String, <:Real} # (techs.absorption_chiller) time_steps::UnitRange time_steps_with_grid::Array{Int, 1} @@ -61,7 +60,10 @@ struct REoptInputs <: AbstractInputs tech_emissions_factors_SO2::Dict{String, <:Real} # (techs) tech_emissions_factors_PM25::Dict{String, <:Real} # (techs) techs_operating_reserve_req_fraction::Dict{String, <:Real} # (techs.all) - heating_cop::Dict{String, <:Real} # (techs.electric_heater) + heating_cop::Dict{String, Array{<:Real, 1}} # (techs.ashp) + cooling_cop::Dict{String, Array{<:Real, 1}} # (techs.ashp) + heating_cf::Dict{String, Array{<:Real, 1}} # (techs.ashp) + cooling_cf::Dict{String, Array{<:Real, 1}} # (techs.ashp) heating_loads_kw::Dict{String, <:Real} # (heating_loads) unavailability::Dict{String, Array{Float64,1}} # Dict by tech of unavailability profile end @@ -75,7 +77,6 @@ struct REoptInputs{ScenarioType <: AbstractScenario} <: AbstractInputs existing_sizes::Dict{String, <:Real} # (techs) cap_cost_slope::Dict{String, Any} # (techs) om_cost_per_kw::Dict{String, <:Real} # (techs) - cop::Dict{String, <:Real} # (techs.cooling) thermal_cop::Dict{String, <:Real} # (techs.absorption_chiller) time_steps::UnitRange time_steps_with_grid::Array{Int, 1} @@ -118,7 +119,7 @@ struct REoptInputs{ScenarioType <: AbstractScenario} <: AbstractInputs ghp_electric_consumption_kw::Array{Float64,2} # Array of electric load profiles consumed by GHP ghp_installed_cost::Array{Float64,1} # Array of installed cost for GHP options ghp_om_cost_year_one::Array{Float64,1} # Array of O&M cost for GHP options - avoided_capex_by_ghp_present_value::Array{Float64,1} # HVAC upgrade costs avoided + avoided_capex_by_ghp_present_value::Array{Float64,1} # HVAC upgrade costs avoided (GHP) ghx_useful_life_years::Array{Float64,1} # GHX useful life years ghx_residual_value::Array{Float64,1} # Residual value of each GHX options tech_renewable_energy_fraction::Dict{String, <:Real} # (techs) @@ -127,12 +128,16 @@ struct REoptInputs{ScenarioType <: AbstractScenario} <: AbstractInputs tech_emissions_factors_SO2::Dict{String, <:Real} # (techs) tech_emissions_factors_PM25::Dict{String, <:Real} # (techs) techs_operating_reserve_req_fraction::Dict{String, <:Real} # (techs.all) - heating_cop::Dict{String, <:Real} # (techs.electric_heater) + heating_cop::Dict{String, Array{Float64,1}} # (techs.ashp, time_steps) + cooling_cop::Dict{String, Array{Float64,1}} # (techs.ashp, time_steps) + heating_cf::Dict{String, Array{Float64,1}} # (techs.heating, time_steps) + cooling_cf::Dict{String, Array{Float64,1}} # (techs.cooling, time_steps) heating_loads::Vector{String} # list of heating loads heating_loads_kw::Dict{String, Array{Real,1}} # (heating_loads) heating_loads_served_by_tes::Dict{String, Array{String,1}} # ("HotThermalStorage" or empty) unavailability::Dict{String, Array{Float64,1}} # (techs.elec) absorption_chillers_using_heating_load::Dict{String,Array{String,1}} # ("AbsorptionChiller" or empty) + avoided_capex_by_ashp_present_value::Dict{String, <:Real} # HVAC upgrade costs avoided (ASHP) end @@ -167,8 +172,8 @@ function REoptInputs(s::AbstractScenario) production_factor, max_sizes, min_sizes, existing_sizes, cap_cost_slope, om_cost_per_kw, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint, techs_by_exportbin, export_bins_by_tech, boiler_efficiency, tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, - tech_emissions_factors_PM25, cop, techs_operating_reserve_req_fraction, thermal_cop, fuel_cost_per_kwh, - heating_cop = setup_tech_inputs(s) + tech_emissions_factors_PM25, techs_operating_reserve_req_fraction, thermal_cop, fuel_cost_per_kwh, + heating_cop, cooling_cop, heating_cf, cooling_cf, avoided_capex_by_ashp_present_value = setup_tech_inputs(s,time_steps) pbi_pwf, pbi_max_benefit, pbi_max_kw, pbi_benefit_per_kwh = setup_pbi_inputs(s, techs) @@ -263,7 +268,6 @@ function REoptInputs(s::AbstractScenario) existing_sizes, cap_cost_slope, om_cost_per_kw, - cop, thermal_cop, time_steps, time_steps_with_grid, @@ -316,11 +320,15 @@ function REoptInputs(s::AbstractScenario) tech_emissions_factors_PM25, techs_operating_reserve_req_fraction, heating_cop, + cooling_cop, + heating_cf, + cooling_cf, heating_loads, heating_loads_kw, heating_loads_served_by_tes, unavailability, - absorption_chillers_using_heating_load + absorption_chillers_using_heating_load, + avoided_capex_by_ashp_present_value ) end @@ -330,9 +338,9 @@ end Create data arrays associated with techs necessary to build the JuMP model. """ -function setup_tech_inputs(s::AbstractScenario) +function setup_tech_inputs(s::AbstractScenario, time_steps) #TODO: create om_cost_per_kwh in here as well as om_cost_per_kw? (Generator, CHP, SteamTurbine, and Boiler have this) - + techs = Techs(s) boiler_efficiency = Dict{String, Float64}() @@ -351,10 +359,13 @@ function setup_tech_inputs(s::AbstractScenario) tech_emissions_factors_NOx = Dict(t => 0.0 for t in techs.all) tech_emissions_factors_SO2 = Dict(t => 0.0 for t in techs.all) tech_emissions_factors_PM25 = Dict(t => 0.0 for t in techs.all) - cop = Dict(t => 0.0 for t in techs.cooling) techs_operating_reserve_req_fraction = Dict(t => 0.0 for t in techs.all) thermal_cop = Dict(t => 0.0 for t in techs.absorption_chiller) - heating_cop = Dict(t => 0.0 for t in techs.electric_heater) + heating_cop = Dict(t => zeros(length(time_steps)) for t in union(techs.heating, techs.chp)) + heating_cf = Dict(t => zeros(length(time_steps)) for t in union(techs.heating, techs.chp)) + cooling_cf = Dict(t => zeros(length(time_steps)) for t in techs.cooling) + cooling_cop = Dict(t => zeros(length(time_steps)) for t in techs.cooling) + avoided_capex_by_ashp_present_value = Dict(t => 0.0 for t in techs.all) # export related inputs techs_by_exportbin = Dict{Symbol, AbstractArray}(k => [] for k in s.electric_tariff.export_bins) @@ -392,42 +403,72 @@ function setup_tech_inputs(s::AbstractScenario) if "ExistingBoiler" in techs.all setup_existing_boiler_inputs(s, max_sizes, min_sizes, existing_sizes, cap_cost_slope, boiler_efficiency, - tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh) + tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh, + heating_cf) end if "Boiler" in techs.all - setup_boiler_inputs(s, max_sizes, min_sizes, existing_sizes, cap_cost_slope, - boiler_efficiency, production_factor, fuel_cost_per_kwh) + setup_boiler_inputs(s, max_sizes, min_sizes, existing_sizes, cap_cost_slope, boiler_efficiency, + om_cost_per_kw, production_factor, fuel_cost_per_kwh, heating_cf) end if "CHP" in techs.all setup_chp_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, production_factor, techs_by_exportbin, techs.segmented, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint, techs, - tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh) + tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh, + heating_cf) end if "ExistingChiller" in techs.all - setup_existing_chiller_inputs(s, max_sizes, min_sizes, existing_sizes, cap_cost_slope, cop) + setup_existing_chiller_inputs(s, max_sizes, min_sizes, existing_sizes, cap_cost_slope, cooling_cop, cooling_cf) else - cop["ExistingChiller"] = 1.0 + cooling_cop["ExistingChiller"] = ones(length(time_steps)) + cooling_cf["ExistingChiller"] = zeros(length(time_steps)) end if "AbsorptionChiller" in techs.all - setup_absorption_chiller_inputs(s, max_sizes, min_sizes, cap_cost_slope, cop, thermal_cop, om_cost_per_kw) + setup_absorption_chiller_inputs(s, max_sizes, min_sizes, cap_cost_slope, cooling_cop, thermal_cop, om_cost_per_kw, cooling_cf) else - cop["AbsorptionChiller"] = 1.0 + cooling_cop["AbsorptionChiller"] = ones(length(time_steps)) thermal_cop["AbsorptionChiller"] = 1.0 + cooling_cf["AbsorptionChiller"] = zeros(length(time_steps)) end if "SteamTurbine" in techs.all - setup_steam_turbine_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, production_factor, techs_by_exportbin, techs) + setup_steam_turbine_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, production_factor, techs_by_exportbin, techs, heating_cf) end if "ElectricHeater" in techs.all - setup_electric_heater_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, heating_cop) + setup_electric_heater_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, heating_cop, heating_cf) + else + heating_cop["ElectricHeater"] = ones(length(time_steps)) + heating_cf["ElectricHeater"] = zeros(length(time_steps)) + end + + if "ASHPSpaceHeater" in techs.all + setup_ASHPSpaceHeater_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, heating_cop, cooling_cop, heating_cf, cooling_cf, + techs.segmented, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint, avoided_capex_by_ashp_present_value) else - heating_cop["ElectricHeater"] = 1.0 + heating_cop["ASHPSpaceHeater"] = ones(length(time_steps)) + cooling_cop["ASHPSpaceHeater"] = ones(length(time_steps)) + heating_cf["ASHPSpaceHeater"] = zeros(length(time_steps)) + cooling_cf["ASHPSpaceHeater"] = zeros(length(time_steps)) + end + + if "ASHPWaterHeater" in techs.all + setup_ASHPWaterHeater_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, heating_cop, heating_cf, + techs.segmented, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint, avoided_capex_by_ashp_present_value) + else + heating_cop["ASHPWaterHeater"] = ones(length(time_steps)) + heating_cf["ASHPWaterHeater"] = zeros(length(time_steps)) + end + + if !isempty(techs.ghp) + cooling_cop["GHP"] = ones(length(time_steps)) + heating_cop["GHP"] = ones(length(time_steps)) + heating_cf["GHP"] = ones(length(time_steps)) + cooling_cf["GHP"] = ones(length(time_steps)) end # filling export_bins_by_tech MUST be done after techs_by_exportbin has been filled in @@ -443,7 +484,8 @@ function setup_tech_inputs(s::AbstractScenario) production_factor, max_sizes, min_sizes, existing_sizes, cap_cost_slope, om_cost_per_kw, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint, techs_by_exportbin, export_bins_by_tech, boiler_efficiency, tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, - tech_emissions_factors_PM25, cop, techs_operating_reserve_req_fraction, thermal_cop, fuel_cost_per_kwh, heating_cop + tech_emissions_factors_PM25, techs_operating_reserve_req_fraction, thermal_cop, fuel_cost_per_kwh, + heating_cop, cooling_cop, heating_cf, cooling_cf, avoided_capex_by_ashp_present_value end @@ -667,14 +709,16 @@ end """ function setup_existing_boiler_inputs(s::AbstractScenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, boiler_efficiency, - tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh) + tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh, + heating_cf) Update tech-indexed data arrays necessary to build the JuMP model with the values for existing boiler. This version of this function, used in BAUInputs(), doesn't update renewable energy and emissions arrays. """ function setup_existing_boiler_inputs(s::AbstractScenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, boiler_efficiency, - tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh) + tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh, + heating_cf) max_sizes["ExistingBoiler"] = s.existing_boiler.max_kw min_sizes["ExistingBoiler"] = 0.0 existing_sizes["ExistingBoiler"] = 0.0 @@ -687,20 +731,23 @@ function setup_existing_boiler_inputs(s::AbstractScenario, max_sizes, min_sizes, tech_emissions_factors_SO2["ExistingBoiler"] = s.existing_boiler.emissions_factor_lb_SO2_per_mmbtu / KWH_PER_MMBTU tech_emissions_factors_PM25["ExistingBoiler"] = s.existing_boiler.emissions_factor_lb_PM25_per_mmbtu / KWH_PER_MMBTU existing_boiler_fuel_cost_per_kwh = s.existing_boiler.fuel_cost_per_mmbtu ./ KWH_PER_MMBTU - fuel_cost_per_kwh["ExistingBoiler"] = per_hour_value_to_time_series(existing_boiler_fuel_cost_per_kwh, s.settings.time_steps_per_hour, "ExistingBoiler") + fuel_cost_per_kwh["ExistingBoiler"] = per_hour_value_to_time_series(existing_boiler_fuel_cost_per_kwh, s.settings.time_steps_per_hour, "ExistingBoiler") + heating_cf["ExistingBoiler"] = ones(8760*s.settings.time_steps_per_hour) return nothing end """ function setup_boiler_inputs(s::AbstractScenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, boiler_efficiency, - production_factor, fuel_cost_per_kwh) + om_cost_per_kw, production_factor, fuel_cost_per_kwh, heating_cf) Update tech-indexed data arrays necessary to build the JuMP model with the values for (new) boiler. This version of this function, used in BAUInputs(), doesn't update renewable energy and emissions arrays. """ -function setup_boiler_inputs(s::AbstractScenario, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, boiler_efficiency, production_factor, fuel_cost_per_kwh) +function setup_boiler_inputs(s::AbstractScenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, boiler_efficiency, + om_cost_per_kw, production_factor, fuel_cost_per_kwh, heating_cf) max_sizes["Boiler"] = s.boiler.max_kw min_sizes["Boiler"] = s.boiler.min_kw + existing_sizes["Boiler"] = 0.0 boiler_efficiency["Boiler"] = s.boiler.efficiency # The Boiler only has a MACRS benefit, no ITC etc. @@ -727,28 +774,30 @@ function setup_boiler_inputs(s::AbstractScenario, max_sizes, min_sizes, cap_cost production_factor["Boiler", :] = get_production_factor(s.boiler) boiler_fuel_cost_per_kwh = s.boiler.fuel_cost_per_mmbtu ./ KWH_PER_MMBTU fuel_cost_per_kwh["Boiler"] = per_hour_value_to_time_series(boiler_fuel_cost_per_kwh, s.settings.time_steps_per_hour, "Boiler") + heating_cf["Boiler"] = ones(8760*s.settings.time_steps_per_hour) return nothing end """ - function setup_existing_chiller_inputs(s::AbstractScenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, cop) + function setup_existing_chiller_inputs(s::AbstractScenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, cooling_cop, cooling_cf) Update tech-indexed data arrays necessary to build the JuMP model with the values for existing chiller. """ -function setup_existing_chiller_inputs(s::AbstractScenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, cop) +function setup_existing_chiller_inputs(s::AbstractScenario, max_sizes, min_sizes, existing_sizes, cap_cost_slope, cooling_cop, cooling_cf) max_sizes["ExistingChiller"] = s.existing_chiller.max_kw min_sizes["ExistingChiller"] = 0.0 existing_sizes["ExistingChiller"] = 0.0 cap_cost_slope["ExistingChiller"] = 0.0 - cop["ExistingChiller"] = s.existing_chiller.cop + cooling_cop["ExistingChiller"] .= s.existing_chiller.cop + cooling_cf["ExistingChiller"] = ones(8760*s.settings.time_steps_per_hour) # om_cost_per_kw["ExistingChiller"] = 0.0 return nothing end function setup_absorption_chiller_inputs(s::AbstractScenario, max_sizes, min_sizes, cap_cost_slope, - cop, thermal_cop, om_cost_per_kw + cooling_cop, thermal_cop, om_cost_per_kw, cooling_cf ) max_sizes["AbsorptionChiller"] = s.absorption_chiller.max_kw min_sizes["AbsorptionChiller"] = s.absorption_chiller.min_kw @@ -773,7 +822,8 @@ function setup_absorption_chiller_inputs(s::AbstractScenario, max_sizes, min_siz cap_cost_slope["AbsorptionChiller"] = s.absorption_chiller.installed_cost_per_kw end - cop["AbsorptionChiller"] = s.absorption_chiller.cop_electric + cooling_cop["AbsorptionChiller"] .= s.absorption_chiller.cop_electric + cooling_cf["AbsorptionChiller"] .= 1.0 if isnothing(s.chp) thermal_factor = 1.0 elseif s.chp.cooling_thermal_factor == 0.0 @@ -790,14 +840,16 @@ end """ function setup_chp_inputs(s::AbstractScenario, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, production_factor, techs_by_exportbin, segmented_techs, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint, techs, - tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh + tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh, + heating_cf ) Update tech-indexed data arrays necessary to build the JuMP model with the values for CHP. """ function setup_chp_inputs(s::AbstractScenario, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, production_factor, techs_by_exportbin, segmented_techs, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint, techs, - tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh + tech_renewable_energy_fraction, tech_emissions_factors_CO2, tech_emissions_factors_NOx, tech_emissions_factors_SO2, tech_emissions_factors_PM25, fuel_cost_per_kwh, + heating_cf ) max_sizes["CHP"] = s.chp.max_kw min_sizes["CHP"] = s.chp.min_kw @@ -817,12 +869,13 @@ function setup_chp_inputs(s::AbstractScenario, max_sizes, min_sizes, cap_cost_sl tech_emissions_factors_SO2["CHP"] = s.chp.emissions_factor_lb_SO2_per_mmbtu / KWH_PER_MMBTU tech_emissions_factors_PM25["CHP"] = s.chp.emissions_factor_lb_PM25_per_mmbtu / KWH_PER_MMBTU chp_fuel_cost_per_kwh = s.chp.fuel_cost_per_mmbtu ./ KWH_PER_MMBTU - fuel_cost_per_kwh["CHP"] = per_hour_value_to_time_series(chp_fuel_cost_per_kwh, s.settings.time_steps_per_hour, "CHP") + fuel_cost_per_kwh["CHP"] = per_hour_value_to_time_series(chp_fuel_cost_per_kwh, s.settings.time_steps_per_hour, "CHP") + heating_cf["CHP"] = ones(8760*s.settings.time_steps_per_hour) return nothing end function setup_steam_turbine_inputs(s::AbstractScenario, max_sizes, min_sizes, cap_cost_slope, - om_cost_per_kw, production_factor, techs_by_exportbin, techs + om_cost_per_kw, production_factor, techs_by_exportbin, techs, heating_cf ) max_sizes["SteamTurbine"] = s.steam_turbine.max_kw @@ -856,14 +909,17 @@ function setup_steam_turbine_inputs(s::AbstractScenario, max_sizes, min_sizes, c push!(techs.no_curtail, "SteamTurbine") end + heating_cf["SteamTurbine"] = ones(8760*s.settings.time_steps_per_hour) + return nothing end -function setup_electric_heater_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, heating_cop) +function setup_electric_heater_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, heating_cop, heating_cf) max_sizes["ElectricHeater"] = s.electric_heater.max_kw min_sizes["ElectricHeater"] = s.electric_heater.min_kw om_cost_per_kw["ElectricHeater"] = s.electric_heater.om_cost_per_kw - heating_cop["ElectricHeater"] = s.electric_heater.cop + heating_cop["ElectricHeater"] .= s.electric_heater.cop + heating_cf["ElectricHeater"] = ones(8760*s.settings.time_steps_per_hour) #TODO: add timem series input for Electric Heater if using as AShP DHW heater? or use ASHP object? if s.electric_heater.macrs_option_years in [5, 7] cap_cost_slope["ElectricHeater"] = effective_cost(; @@ -884,6 +940,82 @@ function setup_electric_heater_inputs(s, max_sizes, min_sizes, cap_cost_slope, o end +function setup_ASHPSpaceHeater_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, heating_cop, cooling_cop, heating_cf, cooling_cf, + segmented_techs, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint, avoided_capex_by_ashp_present_value) + max_sizes["ASHPSpaceHeater"] = s.ashp.max_kw + min_sizes["ASHPSpaceHeater"] = s.ashp.min_kw + om_cost_per_kw["ASHPSpaceHeater"] = s.ashp.om_cost_per_kw + heating_cop["ASHPSpaceHeater"] = s.ashp.heating_cop + cooling_cop["ASHPSpaceHeater"] = s.ashp.cooling_cop + heating_cf["ASHPSpaceHeater"] = s.ashp.heating_cf + cooling_cf["ASHPSpaceHeater"] = s.ashp.cooling_cf + + if s.ashp.min_allowable_kw > 0.0 + cap_cost_slope["ASHPSpaceHeater"] = s.ashp.installed_cost_per_kw + push!(segmented_techs, "ASHPSpaceHeater") + seg_max_size["ASHPSpaceHeater"] = Dict{Int,Float64}(1 => s.ashp.max_kw) + seg_min_size["ASHPSpaceHeater"] = Dict{Int,Float64}(1 => s.ashp.min_allowable_kw) + n_segs_by_tech["ASHPSpaceHeater"] = 1 + seg_yint["ASHPSpaceHeater"] = Dict{Int,Float64}(1 => 0.0) + end + + if s.ashp.macrs_option_years in [5, 7] + cap_cost_slope["ASHPSpaceHeater"] = effective_cost(; + itc_basis = s.ashp.installed_cost_per_kw, + replacement_cost = 0.0, + replacement_year = s.financial.analysis_years, + discount_rate = s.financial.owner_discount_rate_fraction, + tax_rate = s.financial.owner_tax_rate_fraction, + itc = 0.0, + macrs_schedule = s.ashp.macrs_option_years == 5 ? s.financial.macrs_five_year : s.financial.macrs_seven_year, + macrs_bonus_fraction = s.ashp.macrs_bonus_fraction, + macrs_itc_reduction = 0.0, + rebate_per_kw = 0.0 + ) + else + cap_cost_slope["ASHPSpaceHeater"] = s.ashp.installed_cost_per_kw + end + + avoided_capex_by_ashp_present_value["ASHPSpaceHeater"] = s.ashp.avoided_capex_by_ashp_present_value +end + +function setup_ASHPWaterHeater_inputs(s, max_sizes, min_sizes, cap_cost_slope, om_cost_per_kw, heating_cop, heating_cf, + segmented_techs, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint, avoided_capex_by_ashp_present_value) + max_sizes["ASHPWaterHeater"] = s.ashp_wh.max_kw + min_sizes["ASHPWaterHeater"] = s.ashp_wh.min_kw + om_cost_per_kw["ASHPWaterHeater"] = s.ashp_wh.om_cost_per_kw + heating_cop["ASHPWaterHeater"] = s.ashp_wh.heating_cop + heating_cf["ASHPWaterHeater"] = s.ashp_wh.heating_cf + + if s.ashp_wh.min_allowable_kw > 0.0 + cap_cost_slope["ASHPWaterHeater"] = s.ashp_wh.installed_cost_per_kw + push!(segmented_techs, "ASHPWaterHeater") + seg_max_size["ASHPWaterHeater"] = Dict{Int,Float64}(1 => s.ashp_wh.max_kw) + seg_min_size["ASHPWaterHeater"] = Dict{Int,Float64}(1 => s.ashp_wh.min_allowable_kw) + n_segs_by_tech["ASHPWaterHeater"] = 1 + seg_yint["ASHPWaterHeater"] = Dict{Int,Float64}(1 => 0.0) + end + + if s.ashp_wh.macrs_option_years in [5, 7] + cap_cost_slope["ASHPWaterHeater"] = effective_cost(; + itc_basis = s.ashp_wh.installed_cost_per_kw, + replacement_cost = 0.0, + replacement_year = s.financial.analysis_years, + discount_rate = s.financial.owner_discount_rate_fraction, + tax_rate = s.financial.owner_tax_rate_fraction, + itc = 0.0, + macrs_schedule = s.ashp_wh.macrs_option_years == 5 ? s.financial.macrs_five_year : s.financial.macrs_seven_year, + macrs_bonus_fraction = s.ashp_wh.macrs_bonus_fraction, + macrs_itc_reduction = 0.0, + rebate_per_kw = 0.0 + ) + else + cap_cost_slope["ASHPWaterHeater"] = s.ashp_wh.installed_cost_per_kw + end + avoided_capex_by_ashp_present_value["ASHPWaterHeater"] = s.ashp_wh.avoided_capex_by_ashp_present_value +end + + function setup_present_worth_factors(s::AbstractScenario, techs::Techs) lvl_factor = Dict(t => 1.0 for t in techs.all) # default levelization_factor of 1.0 diff --git a/src/core/scenario.jl b/src/core/scenario.jl index cf8197df2..5e5c83c42 100644 --- a/src/core/scenario.jl +++ b/src/core/scenario.jl @@ -25,6 +25,8 @@ struct Scenario <: AbstractScenario cooling_thermal_load_reduction_with_ghp_kw::Union{Vector{Float64}, Nothing} steam_turbine::Union{SteamTurbine, Nothing} electric_heater::Union{ElectricHeater, Nothing} + ashp::Union{ASHP, Nothing} + ashp_wh::Union{ASHP, Nothing} end """ @@ -52,6 +54,8 @@ A Scenario struct can contain the following keys: - [GHP](@ref) (optional, can be Array) - [SteamTurbine](@ref) (optional) - [ElectricHeater](@ref) (optional) +- [ASHPSpaceHeater](@ref) (optional) +- [ASHPWaterHeater](@ref) (optional) All values of `d` are expected to be `Dicts` except for `PV` and `GHP`, which can be either a `Dict` or `Dict[]` (for multiple PV arrays or GHP options). @@ -443,6 +447,7 @@ function Scenario(d::Dict; flex_hvac_from_json=false) end # GHP + ambient_temp_celsius = nothing ghp_option_list = [] space_heating_thermal_load_reduction_with_ghp_kw = zeros(8760 * settings.time_steps_per_hour) cooling_thermal_load_reduction_with_ghp_kw = zeros(8760 * settings.time_steps_per_hour) @@ -477,26 +482,27 @@ function Scenario(d::Dict; flex_hvac_from_json=false) end # Call PVWatts for hourly dry-bulb outdoor air temperature ambient_temp_degF = [] - if !haskey(d["GHP"]["ghpghx_inputs"][1], "ambient_temperature_f") || isempty(d["GHP"]["ghpghx_inputs"][1]["ambient_temperature_f"]) + if (!haskey(d["GHP"]["ghpghx_inputs"][1], "ambient_temperature_f") || isempty(d["GHP"]["ghpghx_inputs"][1]["ambient_temperature_f"])) && + isnothing(site.outdoor_air_temperature_degF) # If PV is evaluated and we need to call PVWatts for ambient temperature, assign PV production factor here too with the same call # By assigning pv.production_factor_series here, it will skip the PVWatts call in get_production_factor(PV) call from reopt_input.jl if !isempty(pvs) for pv in pvs - pv.production_factor_series, ambient_temp_celcius = call_pvwatts_api(site.latitude, site.longitude; tilt=pv.tilt, azimuth=pv.azimuth, module_type=pv.module_type, + pv.production_factor_series, ambient_temp_celsius = call_pvwatts_api(site.latitude, site.longitude; tilt=pv.tilt, azimuth=pv.azimuth, module_type=pv.module_type, array_type=pv.array_type, losses=round(pv.losses*100, digits=3), dc_ac_ratio=pv.dc_ac_ratio, gcr=pv.gcr, inv_eff=pv.inv_eff*100, timeframe="hourly", radius=pv.radius, time_steps_per_hour=settings.time_steps_per_hour) end else - pv_prodfactor, ambient_temp_celcius = call_pvwatts_api(site.latitude, site.longitude; time_steps_per_hour=settings.time_steps_per_hour) + pv_prodfactor, ambient_temp_celsius = call_pvwatts_api(site.latitude, site.longitude; time_steps_per_hour=settings.time_steps_per_hour) end - ambient_temp_degF = ambient_temp_celcius * 1.8 .+ 32.0 - else - ambient_temp_degF = d["GHP"]["ghpghx_inputs"][1]["ambient_temperature_f"] + site.outdoor_air_temperature_degF = ambient_temp_celsius * 1.8 .+ 32.0 + elseif isnothing(site.outdoor_air_temperature_degF) + site.outdoor_air_temperature_degF = d["GHP"]["ghpghx_inputs"][1]["ambient_temperature_f"] end for i in 1:number_of_ghpghx ghpghx_inputs = d["GHP"]["ghpghx_inputs"][i] - d["GHP"]["ghpghx_inputs"][i]["ambient_temperature_f"] = ambient_temp_degF + d["GHP"]["ghpghx_inputs"][i]["ambient_temperature_f"] = site.outdoor_air_temperature_degF # Only SpaceHeating portion of Heating Load gets served by GHP, unless allowed by can_serve_dhw if get(ghpghx_inputs, "heating_thermal_load_mmbtu_per_hr", []) in [nothing, []] if get(d["GHP"], "can_serve_dhw", false) # This is assuming the default stays false @@ -610,9 +616,9 @@ function Scenario(d::Dict; flex_hvac_from_json=false) end append!(ghp_option_list, [GHP(ghpghx_response, ghp_inputs_removed_ghpghx_params)]) # Print out ghpghx_response for loading into a future run without running GhpGhx.jl again - # open("scenarios/ghpghx_response.json","w") do f - # JSON.print(f, ghpghx_response) - # end + #open("scenarios/ghpghx_response.json","w") do f + # JSON.print(f, ghpghx_response) + #end end # If ghpghx_responses is included in inputs, do NOT run GhpGhx.jl model and use already-run ghpghx result as input to REopt elseif eval_ghp && get_ghpghx_from_input @@ -643,11 +649,92 @@ function Scenario(d::Dict; flex_hvac_from_json=false) end end + # Electric Heater electric_heater = nothing if haskey(d, "ElectricHeater") && d["ElectricHeater"]["max_mmbtu_per_hour"] > 0.0 electric_heater = ElectricHeater(;dictkeys_tosymbols(d["ElectricHeater"])...) end + # ASHP + ashp = nothing + if haskey(d, "ASHPSpaceHeater") + if !haskey(d["ASHPSpaceHeater"], "max_ton") + max_ton = get_ashp_defaults("SpaceHeating")["max_ton"] + else + max_ton = d["ASHPSpaceHeater"]["max_ton"] + end + + if max_ton > 0 + # ASHP Space Heater's temp back_up_temp_threshold_degF + if !haskey(d["ASHPSpaceHeater"], "back_up_temp_threshold_degF") + ambient_temp_thres_fahrenheit = get_ashp_defaults("SpaceHeating")["back_up_temp_threshold_degF"] + else + ambient_temp_thres_fahrenheit = d["ASHPSpaceHeater"]["back_up_temp_threshold_degF"] + end + + # If PV is evaluated, get ambient temperature series from PVWatts and assign PV production factor + if isnothing(site.outdoor_air_temperature_degF) + if !isempty(pvs) + for pv in pvs + pv.production_factor_series, ambient_temp_celsius = call_pvwatts_api(site.latitude, site.longitude; tilt=pv.tilt, azimuth=pv.azimuth, module_type=pv.module_type, + array_type=pv.array_type, losses=round(pv.losses*100, digits=3), dc_ac_ratio=pv.dc_ac_ratio, + gcr=pv.gcr, inv_eff=pv.inv_eff*100, timeframe="hourly", radius=pv.radius, time_steps_per_hour=settings.time_steps_per_hour) + end + else + # if PV is not evaluated, call PVWatts to get ambient temperature series + pv_prodfactor, ambient_temp_celsius = call_pvwatts_api(site.latitude, site.longitude; time_steps_per_hour=settings.time_steps_per_hour) + end + site.outdoor_air_temperature_degF = (9/5 .* ambient_temp_celsius) .+ 32 + end + + d["ASHPSpaceHeater"]["ambient_temp_degF"] = site.outdoor_air_temperature_degF + d["ASHPSpaceHeater"]["heating_load"] = space_heating_load.loads_kw + d["ASHPSpaceHeater"]["cooling_load"] = cooling_load.loads_kw_thermal + + ashp = ASHPSpaceHeater(;dictkeys_tosymbols(d["ASHPSpaceHeater"])...) + end + end + + # ASHP Water Heater: + ashp_wh = nothing + + if haskey(d, "ASHPWaterHeater") + if !haskey(d["ASHPWaterHeater"], "max_ton") + max_ton = get_ashp_defaults("DomesticHotWater")["max_ton"] + else + max_ton = d["ASHPWaterHeater"]["max_ton"] + end + + if max_ton > 0.0 + # ASHP Space Heater's temp back_up_temp_threshold_degF + if !haskey(d["ASHPWaterHeater"], "back_up_temp_threshold_degF") + ambient_temp_thres_fahrenheit = get_ashp_defaults("DomesticHotWater")["back_up_temp_threshold_degF"] + else + ambient_temp_thres_fahrenheit = d["ASHPWaterHeater"]["back_up_temp_threshold_degF"] + end + + # If PV is evaluated, get ambient temperature series from PVWatts and assign PV production factor + if isnothing(site.outdoor_air_temperature_degF) + if !isempty(pvs) + for pv in pvs + pv.production_factor_series, ambient_temp_celsius = call_pvwatts_api(site.latitude, site.longitude; tilt=pv.tilt, azimuth=pv.azimuth, module_type=pv.module_type, + array_type=pv.array_type, losses=round(pv.losses*100, digits=3), dc_ac_ratio=pv.dc_ac_ratio, + gcr=pv.gcr, inv_eff=pv.inv_eff*100, timeframe="hourly", radius=pv.radius, time_steps_per_hour=settings.time_steps_per_hour) + end + else + # if PV is not evaluated, call PVWatts to get ambient temperature series + pv_prodfactor, ambient_temp_celsius = call_pvwatts_api(site.latitude, site.longitude; time_steps_per_hour=settings.time_steps_per_hour) + end + site.outdoor_air_temperature_degF = (9/5 .* ambient_temp_celsius) .+ 32 + end + + d["ASHPWaterHeater"]["ambient_temp_degF"] = site.outdoor_air_temperature_degF + d["ASHPWaterHeater"]["heating_load"] = dhw_load.loads_kw + + ashp_wh = ASHPWaterHeater(;dictkeys_tosymbols(d["ASHPWaterHeater"])...) + end + end + return Scenario( settings, site, @@ -673,7 +760,9 @@ function Scenario(d::Dict; flex_hvac_from_json=false) space_heating_thermal_load_reduction_with_ghp_kw, cooling_thermal_load_reduction_with_ghp_kw, steam_turbine, - electric_heater + electric_heater, + ashp, + ashp_wh ) end diff --git a/src/core/site.jl b/src/core/site.jl index b9185cadb..0ecb42752 100644 --- a/src/core/site.jl +++ b/src/core/site.jl @@ -19,6 +19,7 @@ Inputs related to the physical location: renewable_electricity_max_fraction::Union{Float64, Nothing} = nothing, include_exported_elec_emissions_in_total::Bool = true, include_exported_renewable_electricity_in_total::Bool = true, + outdoor_air_temperature_degF::Union{Nothing, Array{<:Real,1}} = nothing, ``` """ mutable struct Site @@ -38,6 +39,7 @@ mutable struct Site renewable_electricity_max_fraction include_exported_elec_emissions_in_total include_exported_renewable_electricity_in_total + outdoor_air_temperature_degF node # TODO validate that multinode Sites do not share node numbers? Or just raise warning function Site(; latitude::Real, @@ -54,6 +56,7 @@ mutable struct Site renewable_electricity_max_fraction::Union{Float64, Nothing} = nothing, include_exported_elec_emissions_in_total::Bool = true, include_exported_renewable_electricity_in_total::Bool = true, + outdoor_air_temperature_degF::Union{Nothing, Array{<:Real,1}} = nothing, node::Int = 1, ) invalid_args = String[] @@ -77,6 +80,6 @@ mutable struct Site CO2_emissions_reduction_max_fraction, bau_emissions_lb_CO2_per_year, bau_grid_emissions_lb_CO2_per_year, renewable_electricity_min_fraction, renewable_electricity_max_fraction, include_exported_elec_emissions_in_total, - include_exported_renewable_electricity_in_total, node) + include_exported_renewable_electricity_in_total, outdoor_air_temperature_degF, node) end end \ No newline at end of file diff --git a/src/core/techs.jl b/src/core/techs.jl index 76f9b0a85..b8e82261e 100644 --- a/src/core/techs.jl +++ b/src/core/techs.jl @@ -29,6 +29,8 @@ function Techs(p::REoptInputs, s::BAUScenario) techs_can_serve_dhw = String[] techs_can_serve_process_heat = String[] ghp_techs = String[] + ashp_techs = String[] + ashp_wh_techs = String[] if p.s.generator.existing_kw > 0 push!(all_techs, "Generator") @@ -85,7 +87,9 @@ function Techs(p::REoptInputs, s::BAUScenario) techs_can_serve_space_heating, techs_can_serve_dhw, techs_can_serve_process_heat, - ghp_techs + ghp_techs, + ashp_techs, + ashp_wh_techs ) end @@ -124,6 +128,8 @@ function Techs(s::Scenario) techs_can_serve_dhw = String[] techs_can_serve_process_heat = String[] ghp_techs = String[] + ashp_techs = String[] + ashp_wh_techs = String[] if s.wind.max_kw > 0 push!(all_techs, "Wind") @@ -263,6 +269,51 @@ function Techs(s::Scenario) end end + if !isnothing(s.ashp) + push!(all_techs, "ASHPSpaceHeater") + push!(heating_techs, "ASHPSpaceHeater") + push!(electric_heaters, "ASHPSpaceHeater") + push!(ashp_techs, "ASHPSpaceHeater") + if s.ashp.can_supply_steam_turbine + push!(techs_can_supply_steam_turbine, "ASHPSpaceHeater") + end + if s.ashp.can_serve_space_heating + push!(techs_can_serve_space_heating, "ASHPSpaceHeater") + end + if s.ashp.can_serve_dhw + push!(techs_can_serve_dhw, "ASHPSpaceHeater") + end + if s.ashp.can_serve_process_heat + push!(techs_can_serve_process_heat, "ASHPSpaceHeater") + end + if s.ashp.can_serve_cooling + push!(cooling_techs, "ASHPSpaceHeater") + end + end + + if !isnothing(s.ashp_wh) + push!(all_techs, "ASHPWaterHeater") + push!(heating_techs, "ASHPWaterHeater") + push!(electric_heaters, "ASHPWaterHeater") + push!(ashp_techs, "ASHPWaterHeater") + push!(ashp_wh_techs, "ASHPWaterHeater") + if s.ashp_wh.can_supply_steam_turbine + push!(techs_can_supply_steam_turbine, "ASHPWaterHeater") + end + if s.ashp_wh.can_serve_space_heating + push!(techs_can_serve_space_heating, "ASHPWaterHeater") + end + if s.ashp_wh.can_serve_dhw + push!(techs_can_serve_dhw, "ASHPWaterHeater") + end + if s.ashp_wh.can_serve_process_heat + push!(techs_can_serve_process_heat, "ASHPWaterHeater") + end + if s.ashp_wh.can_serve_cooling + push!(cooling_techs, "ASHPWaterHeater") + end + end + if s.settings.off_grid_flag append!(requiring_oper_res, pvtechs) append!(providing_oper_res, pvtechs) @@ -271,6 +322,24 @@ function Techs(s::Scenario) thermal_techs = union(heating_techs, boiler_techs, chp_techs, cooling_techs) fuel_burning_techs = union(gentechs, boiler_techs, chp_techs) + # check for ability of new technologies to meet heating loads if retire_in_optimal + if !isnothing(s.existing_boiler) && s.existing_boiler.retire_in_optimal + if !isnothing(s.dhw_load) && s.dhw_load.annual_mmbtu > 0 && isempty(setdiff(techs_can_serve_dhw, "ExistingBoiler")) + throw(@error("ExisitingBoiler.retire_in_optimal is true, but no other heating technologies can meet DomesticHotWater load.")) + end + if !isnothing(s.space_heating_load) && s.space_heating_load.annual_mmbtu > 0 && isempty(setdiff(techs_can_serve_space_heating, "ExistingBoiler")) + throw(@error("ExisitingBoiler.retire_in_optimal is true, but no other heating technologies can meet SpaceHeating load.")) + end + if !isnothing(s.process_heat_load) && s.process_heat_load.annual_mmbtu > 0 && isempty(setdiff(techs_can_serve_process_heat, "ExistingBoiler")) + throw(@error("ExisitingBoiler.retire_in_optimal is true, but no other heating technologies can meet ProcessHeat load.")) + end + end + if !isnothing(s.existing_chiller) && s.existing_chiller.retire_in_optimal + if !isnothing(s.cooling_load) && sum(s.cooling_load.loads_kw_thermal) > 0 && isempty(setdiff(cooling_techs, "ExistingChiller")) + throw(@error("ExisitingChiller.retire_in_optimal is true, but no other technologies can meet cooling load.")) + end + end + Techs( all_techs, elec, @@ -296,7 +365,9 @@ function Techs(s::Scenario) techs_can_serve_space_heating, techs_can_serve_dhw, techs_can_serve_process_heat, - ghp_techs + ghp_techs, + ashp_techs, + ashp_wh_techs ) end @@ -345,6 +416,8 @@ function Techs(s::MPCScenario) String[], String[], String[], + String[], + String[], String[] ) end \ No newline at end of file diff --git a/src/core/types.jl b/src/core/types.jl index 4d31ac61f..6ed1eb96b 100644 --- a/src/core/types.jl +++ b/src/core/types.jl @@ -42,10 +42,11 @@ mutable struct Techs steam_turbine::Vector{String} can_supply_steam_turbine::Vector{String} electric_heater::Vector{String} - can_serve_dhw::Vector{String} can_serve_space_heating::Vector{String} + can_serve_dhw::Vector{String} can_serve_process_heat::Vector{String} - ghp_techs::Vector{String} + ghp::Vector{String} + ashp::Vector{String} end ``` """ @@ -71,8 +72,10 @@ mutable struct Techs steam_turbine::Vector{String} can_supply_steam_turbine::Vector{String} electric_heater::Vector{String} - can_serve_dhw::Vector{String} can_serve_space_heating::Vector{String} + can_serve_dhw::Vector{String} can_serve_process_heat::Vector{String} ghp::Vector{String} + ashp::Vector{String} + ashp_wh::Vector{String} end diff --git a/src/core/utils.jl b/src/core/utils.jl index 029050626..2ba9f4d4b 100644 --- a/src/core/utils.jl +++ b/src/core/utils.jl @@ -159,6 +159,12 @@ function dictkeys_tosymbols(d::Dict) "emissions_factor_series_lb_NOx_per_kwh", "emissions_factor_series_lb_SO2_per_kwh", "emissions_factor_series_lb_PM25_per_kwh", + "heating_cop_reference", + "heating_cf_reference", + "heating_reference_temps_degF", + "cooling_cop_reference", + "cooling_cf_reference", + "cooling_reference_temps_degF", #for ERP "pv_production_factor_series", "wind_production_factor_series", "battery_starting_soc_series_fraction", @@ -461,7 +467,7 @@ function call_pvwatts_api(latitude::Real, longitude::Real; tilt=latitude, azimut try @info "Querying PVWatts for production factor and ambient air temperature... " - r = HTTP.get(url, keepalive=true, readtimeout=10) + r = HTTP.get(url, ["User-Agent" => "REopt.jl"]; keepalive=true, readtimeout=10) response = JSON.parse(String(r.body)) if r.status != 200 throw(@error("Bad response from PVWatts: $(response["errors"])")) diff --git a/src/mpc/inputs.jl b/src/mpc/inputs.jl index 9bf13a105..3aeece174 100644 --- a/src/mpc/inputs.jl +++ b/src/mpc/inputs.jl @@ -20,7 +20,7 @@ struct MPCInputs <: AbstractInputs ratchets::UnitRange techs_by_exportbin::DenseAxisArray{Array{String,1}} # indexed on [:NEM, :WHL] export_bins_by_tech::Dict{String, Array{Symbol, 1}} - cop::Dict{String, Float64} # (techs.cooling) + cooling_cop::Dict{String, Array{Float64,1}} # (techs.cooling, time_steps) thermal_cop::Dict{String, Float64} # (techs.absorption_chiller) ghp_options::UnitRange{Int64} # Range of the number of GHP options fuel_cost_per_kwh::Dict{String, AbstractArray} # Fuel cost array for all time_steps @@ -66,7 +66,7 @@ function MPCInputs(s::MPCScenario) # TODO implement export bins by tech (rather than assuming that all techs share the export_bins) #Placeholder COP because the REopt model expects it - cop = Dict("ExistingChiller" => s.cooling_load.cop) + cooling_cop = Dict("ExistingChiller" => ones(length(s.electric_load.loads_kw)) .* s.cooling_load.cop) thermal_cop = Dict{String, Float64}() ghp_options = 1:0 heating_loads = Vector{String}() @@ -92,7 +92,7 @@ function MPCInputs(s::MPCScenario) 1:length(s.electric_tariff.tou_demand_ratchet_time_steps), # ratchets techs_by_exportbin, export_bins_by_tech, - cop, + cooling_cop, thermal_cop, ghp_options, # s.site.min_resil_time_steps, diff --git a/src/results/absorption_chiller.jl b/src/results/absorption_chiller.jl index 86f424b99..8bdd47069 100644 --- a/src/results/absorption_chiller.jl +++ b/src/results/absorption_chiller.jl @@ -44,10 +44,10 @@ function add_absorption_chiller_results(m::JuMP.AbstractModel, p::REoptInputs, d for t in p.techs.absorption_chiller, ts in p.time_steps)) r["annual_thermal_production_tonhour"] = round(value(Year1ABSORPCHLThermalProdKWH) / KWH_THERMAL_PER_TONHOUR, digits=5) @expression(m, ABSORPCHLElectricConsumptionSeries[ts in p.time_steps], - sum(m[:dvCoolingProduction][t,ts] / p.cop[t] for t in p.techs.absorption_chiller) ) + sum(m[:dvCoolingProduction][t,ts] / p.cooling_cop[t][ts] for t in p.techs.absorption_chiller) ) r["electric_consumption_series_kw"] = round.(value.(ABSORPCHLElectricConsumptionSeries), digits=3) @expression(m, Year1ABSORPCHLElectricConsumption, - p.hours_per_time_step * sum(m[:dvCoolingProduction][t,ts] / p.cop[t] + p.hours_per_time_step * sum(m[:dvCoolingProduction][t,ts] / p.cooling_cop[t][ts] for t in p.techs.absorption_chiller, ts in p.time_steps)) r["annual_electric_consumption_kwh"] = round(value(Year1ABSORPCHLElectricConsumption), digits=3) diff --git a/src/results/ashp.jl b/src/results/ashp.jl new file mode 100644 index 000000000..9a34682c0 --- /dev/null +++ b/src/results/ashp.jl @@ -0,0 +1,183 @@ +# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE. + +""" +`ASHPSpaceHeater` results keys: +- `size_ton` # Thermal production capacity size of the ASHP [ton/hr] +- `electric_consumption_series_kw` # Fuel consumption series [kW] +- `annual_electric_consumption_kwh` # Fuel consumed in a year [kWh] +- `thermal_production_series_mmbtu_per_hour` # Thermal heating energy production series [MMBtu/hr] +- `annual_thermal_production_mmbtu` # Thermal heating energy produced in a year [MMBtu] +- `thermal_to_storage_series_mmbtu_per_hour` # Thermal power production to TES (HotThermalStorage) series [MMBtu/hr] +- `thermal_to_load_series_mmbtu_per_hour` # Thermal power production to serve the heating load series [MMBtu/hr] +- `thermal_to_space_heating_load_series_mmbtu_per_hour` # Thermal production to space heating load [MMBTU/hr] +- `thermal_to_storage_series_ton` # Thermal production to ColdThermalStorage +- `thermal_to_load_series_ton` # Thermal production to cooling load +- `annual_thermal_production_tonhour` Thermal cooling energy produced in a year + + +!!! note "'Series' and 'Annual' energy outputs are average annual" + REopt performs load balances using average annual production values for technologies that include degradation. + Therefore, all timeseries (`_series`) and `annual_` results should be interpretted as energy outputs averaged over the analysis period. + +""" + +function add_ashp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") + r = Dict{String, Any}() + r["size_ton"] = round(p.s.ashp.sizing_factor * value(m[Symbol("dvSize"*_n)]["ASHPSpaceHeater"]) / KWH_THERMAL_PER_TONHOUR, digits=3) + @expression(m, ASHPElectricConsumptionSeries[ts in p.time_steps], + p.hours_per_time_step * sum(m[:dvHeatingProduction]["ASHPSpaceHeater",q,ts] for q in p.heating_loads) + / p.heating_cop["ASHPSpaceHeater"][ts] + ) + + @expression(m, ASHPThermalProductionSeries[ts in p.time_steps], + sum(m[:dvHeatingProduction]["ASHPSpaceHeater",q,ts] for q in p.heating_loads)) # TODO add cooling + r["thermal_production_series_mmbtu_per_hour"] = + round.(value.(ASHPThermalProductionSeries) / KWH_PER_MMBTU, digits=5) + r["annual_thermal_production_mmbtu"] = round(sum(r["thermal_production_series_mmbtu_per_hour"]), digits=3) + + if !isempty(p.s.storage.types.hot) + @expression(m, ASHPToHotTESKW[ts in p.time_steps], + sum(m[:dvHeatToStorage][b,"ASHPSpaceHeater",q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + ) + @expression(m, ASHPToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], + sum(m[:dvHeatToStorage][b,"ASHPSpaceHeater",q,ts] for b in p.s.storage.types.hot) + ) + else + @expression(m, ASHPToHotTESKW[ts in p.time_steps], 0.0) + @expression(m, ASHPToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], 0.0) + end + r["thermal_to_storage_series_mmbtu_per_hour"] = round.(value.(ASHPToHotTESKW) / KWH_PER_MMBTU, digits=3) + @expression(m, ASHPToWaste[ts in p.time_steps], + sum(m[:dvProductionToWaste]["ASHPSpaceHeater", q, ts] for q in p.heating_loads) + ) + @expression(m, ASHPToWasteByQualityKW[q in p.heating_loads, ts in p.time_steps], + m[:dvProductionToWaste]["ASHPSpaceHeater",q,ts] + ) + @expression(m, ASHPToLoad[ts in p.time_steps], + sum(m[:dvHeatingProduction]["ASHPSpaceHeater", q, ts] for q in p.heating_loads) - ASHPToHotTESKW[ts] - ASHPToWaste[ts] + ) + r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(ASHPToLoad) ./ KWH_PER_MMBTU, digits=3) + + if "SpaceHeating" in p.heating_loads && p.s.ashp.can_serve_space_heating + @expression(m, ASHPToSpaceHeatingKW[ts in p.time_steps], + m[:dvHeatingProduction]["ASHPSpaceHeater","SpaceHeating",ts] - ASHPToHotTESByQualityKW["SpaceHeating",ts] - ASHPToWasteByQualityKW["SpaceHeating",ts] + ) + else + @expression(m, ASHPToSpaceHeatingKW[ts in p.time_steps], 0.0) + end + r["thermal_to_space_heating_load_series_mmbtu_per_hour"] = round.(value.(ASHPToSpaceHeatingKW ./ KWH_PER_MMBTU), digits=5) + + if "ASHPSpaceHeater" in p.techs.cooling && sum(p.s.cooling_load.loads_kw_thermal) > 0.0 + + @expression(m, ASHPtoColdTES[ts in p.time_steps], + sum(m[:dvProductionToStorage][b,"ASHPSpaceHeater",ts] for b in p.s.storage.types.cold) + ) + r["thermal_to_storage_series_ton"] = round.(value.(ASHPtoColdTES ./ KWH_THERMAL_PER_TONHOUR), digits=3) + + @expression(m, ASHPtoColdLoad[ts in p.time_steps], + sum(m[:dvCoolingProduction]["ASHPSpaceHeater", ts]) - ASHPtoColdTES[ts] + ) + r["thermal_to_load_series_ton"] = round.(value.(ASHPtoColdLoad ./ KWH_THERMAL_PER_TONHOUR), digits=3) + + @expression(m, Year1ASHPColdThermalProd, + p.hours_per_time_step * sum(m[:dvCoolingProduction]["ASHPSpaceHeater", ts] for ts in p.time_steps) + ) + r["annual_thermal_production_tonhour"] = round(value(Year1ASHPColdThermalProd / KWH_THERMAL_PER_TONHOUR), digits=3) + + @expression(m, ASHPColdElectricConsumptionSeries[ts in p.time_steps], + p.hours_per_time_step * m[:dvCoolingProduction]["ASHPSpaceHeater",ts] / p.cooling_cop["ASHPSpaceHeater"][ts] + ) + r["cooling_cop"] = p.cooling_cop["ASHPSpaceHeater"] + r["cooling_cf"] = p.cooling_cf["ASHPSpaceHeater"] + else + r["thermal_to_storage_series_ton"] = zeros(length(p.time_steps)) + r["thermal_to_load_series_ton"] = zeros(length(p.time_steps)) + r["annual_thermal_production_tonhour"] = 0.0 + @expression(m, ASHPColdElectricConsumptionSeries[ts in p.time_steps], 0.0) + r["cooling_cop"] = zeros(length(p.time_steps)) + r["cooling_cf"] = zeros(length(p.time_steps)) + end + r["electric_consumption_series_kw"] = round.(value.(ASHPElectricConsumptionSeries .+ ASHPColdElectricConsumptionSeries), digits=3) + r["electric_consumption_for_cooling_series_kw"] = round.(value.(ASHPColdElectricConsumptionSeries), digits=3) + r["electric_consumption_for_heating_series_kw"] = round.(value.(ASHPElectricConsumptionSeries), digits=3) + r["annual_electric_consumption_kwh"] = p.hours_per_time_step * sum(r["electric_consumption_series_kw"]) + r["annual_electric_consumption_for_cooling_kwh"] = p.hours_per_time_step * sum(r["electric_consumption_for_cooling_series_kw"]) + r["annual_electric_consumption_for_heating_kwh"] = p.hours_per_time_step * sum(r["electric_consumption_for_heating_series_kw"]) + r["heating_cop"] = p.heating_cop["ASHPSpaceHeater"] + r["heating_cf"] = p.heating_cf["ASHPSpaceHeater"] + + d["ASHPSpaceHeater"] = r + nothing +end + +""" +`ASHPWaterHeater` results keys: +- `size_ton` # Thermal production capacity size of the ASHPWaterHeater [ton/hr] +- `electric_consumption_series_kw` # Fuel consumption series [kW] +- `annual_electric_consumption_kwh` # Fuel consumed in a year [kWh] +- `thermal_production_series_mmbtu_per_hour` # Thermal heating energy production series [MMBtu/hr] +- `annual_thermal_production_mmbtu` # Thermal heating energy produced in a year [MMBtu] +- `thermal_to_storage_series_mmbtu_per_hour` # Thermal power production to TES (HotThermalStorage) series [MMBtu/hr] +- `thermal_to_load_series_mmbtu_per_hour` # Thermal power production to serve the heating load series [MMBtu/hr] + + +!!! note "'Series' and 'Annual' energy outputs are average annual" + REopt performs load balances using average annual production values for technologies that include degradation. + Therefore, all timeseries (`_series`) and `annual_` results should be interpretted as energy outputs averaged over the analysis period. + +""" + +function add_ashp_wh_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") + r = Dict{String, Any}() + r["size_ton"] = round(p.s.ashp_wh.sizing_factor * value(m[Symbol("dvSize"*_n)]["ASHPWaterHeater"]) / KWH_THERMAL_PER_TONHOUR, digits=3) + @expression(m, ASHPWHElectricConsumptionSeries[ts in p.time_steps], + p.hours_per_time_step * sum(m[:dvHeatingProduction][t,q,ts] / p.heating_cop[t][ts] + for q in p.heating_loads, t in p.techs.ashp_wh) + ) + + @expression(m, ASHPWHThermalProductionSeries[ts in p.time_steps], + sum(m[:dvHeatingProduction][t,q,ts] for q in p.heating_loads, t in p.techs.ashp_wh)) + r["thermal_production_series_mmbtu_per_hour"] = + round.(value.(ASHPWHThermalProductionSeries) / KWH_PER_MMBTU, digits=5) + r["annual_thermal_production_mmbtu"] = round(sum(r["thermal_production_series_mmbtu_per_hour"]), digits=3) + + if !isempty(p.s.storage.types.hot) + @expression(m, ASHPWHToHotTESKW[ts in p.time_steps], + sum(m[:dvHeatToStorage][b,"ASHPWaterHeater",q,ts] for b in p.s.storage.types.hot, q in p.heating_loads) + ) + @expression(m, ASHPWHToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], + sum(m[:dvHeatToStorage][b,"ASHPWaterHeater",q,ts] for b in p.s.storage.types.hot) + ) + else + @expression(m, ASHPWHToHotTESKW[ts in p.time_steps], 0.0) + @expression(m, ASHPWHToHotTESByQualityKW[q in p.heating_loads, ts in p.time_steps], 0.0) + end + r["thermal_to_storage_series_mmbtu_per_hour"] = round.(value.(ASHPWHToHotTESKW) / KWH_PER_MMBTU, digits=3) + @expression(m, ASHPWHToWaste[ts in p.time_steps], + sum(m[:dvProductionToWaste]["ASHPWaterHeater", q, ts] for q in p.heating_loads) + ) + @expression(m, ASHPWHToWasteByQualityKW[q in p.heating_loads, ts in p.time_steps], + m[:dvProductionToWaste]["ASHPWaterHeater",q,ts] + ) + @expression(m, ASHPWHToLoad[ts in p.time_steps], + sum(m[:dvHeatingProduction]["ASHPWaterHeater", q, ts] for q in p.heating_loads) - ASHPWHToHotTESKW[ts] - ASHPWHToWaste[ts] + ) + r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(ASHPWHToLoad) ./ KWH_PER_MMBTU, digits=3) + + if "DomesticHotWater" in p.heating_loads && p.s.ashp_wh.can_serve_dhw + @expression(m, ASHPWHToDHWKW[ts in p.time_steps], + m[:dvHeatingProduction]["ASHPWaterHeater","DomesticHotWater",ts] - ASHPWHToHotTESByQualityKW["DomesticHotWater",ts] - ASHPWHToWasteByQualityKW["DomesticHotWater",ts] + ) + else + @expression(m, ASHPWHToDHWKW[ts in p.time_steps], 0.0) + end + r["thermal_to_dhw_load_series_mmbtu_per_hour"] = round.(value.(ASHPWHToDHWKW ./ KWH_PER_MMBTU), digits=5) + + r["electric_consumption_series_kw"] = round.(value.(ASHPWHElectricConsumptionSeries), digits=3) + r["annual_electric_consumption_kwh"] = p.hours_per_time_step * sum(r["electric_consumption_series_kw"]) + r["heating_cop"] = p.heating_cop["ASHPSpaceHeater"] + r["heating_cf"] = p.heating_cf["ASHPSpaceHeater"] + + d["ASHPWaterHeater"] = r + nothing +end \ No newline at end of file diff --git a/src/results/electric_heater.jl b/src/results/electric_heater.jl index af6e4bad2..024804423 100644 --- a/src/results/electric_heater.jl +++ b/src/results/electric_heater.jl @@ -21,7 +21,7 @@ function add_electric_heater_results(m::JuMP.AbstractModel, p::REoptInputs, d::D r = Dict{String, Any}() r["size_mmbtu_per_hour"] = round(value(m[Symbol("dvSize"*_n)]["ElectricHeater"]) / KWH_PER_MMBTU, digits=3) @expression(m, ElectricHeaterElectricConsumptionSeries[ts in p.time_steps], - p.hours_per_time_step * sum(m[:dvHeatingProduction][t,q,ts] / p.heating_cop[t] + p.hours_per_time_step * sum(m[:dvHeatingProduction][t,q,ts] / p.heating_cop[t][ts] for q in p.heating_loads, t in p.techs.electric_heater)) r["electric_consumption_series_kw"] = round.(value.(ElectricHeaterElectricConsumptionSeries), digits=3) r["annual_electric_consumption_kwh"] = sum(r["electric_consumption_series_kw"]) @@ -54,14 +54,21 @@ function add_electric_heater_results(m::JuMP.AbstractModel, p::REoptInputs, d::D end r["thermal_to_steamturbine_series_mmbtu_per_hour"] = round.(value.(ElectricHeaterToSteamTurbine) / KWH_PER_MMBTU, digits=3) + @expression(m, ElectricHeaterToWaste[ts in p.time_steps], + sum(m[:dvProductionToWaste]["ElectricHeater", q, ts] for q in p.heating_loads) + ) + @expression(m, ElectricHeaterToWasteByQualityKW[q in p.heating_loads, ts in p.time_steps], + m[:dvProductionToWaste]["ElectricHeater",q,ts] + ) + @expression(m, ElectricHeaterToLoad[ts in p.time_steps], - sum(m[:dvHeatingProduction]["ElectricHeater", q, ts] for q in p.heating_loads) - ElectricHeaterToHotTESKW[ts] - ElectricHeaterToSteamTurbine[ts] + sum(m[:dvHeatingProduction]["ElectricHeater", q, ts] for q in p.heating_loads) - ElectricHeaterToHotTESKW[ts] - ElectricHeaterToSteamTurbine[ts] - ElectricHeaterToWaste[ts] ) r["thermal_to_load_series_mmbtu_per_hour"] = round.(value.(ElectricHeaterToLoad) / KWH_PER_MMBTU, digits=3) if "DomesticHotWater" in p.heating_loads && p.s.electric_heater.can_serve_dhw @expression(m, ElectricHeaterToDHWKW[ts in p.time_steps], - m[:dvHeatingProduction]["ElectricHeater","DomesticHotWater",ts] - ElectricHeaterToHotTESByQualityKW["DomesticHotWater",ts] - ElectricHeaterToSteamTurbineByQuality["DomesticHotWater",ts] + m[:dvHeatingProduction]["ElectricHeater","DomesticHotWater",ts] - ElectricHeaterToHotTESByQualityKW["DomesticHotWater",ts] - ElectricHeaterToSteamTurbineByQuality["DomesticHotWater",ts] - ElectricHeaterToWasteByQualityKW["DomesticHotWater",ts] ) else @expression(m, ElectricHeaterToDHWKW[ts in p.time_steps], 0.0) @@ -70,7 +77,7 @@ function add_electric_heater_results(m::JuMP.AbstractModel, p::REoptInputs, d::D if "SpaceHeating" in p.heating_loads && p.s.electric_heater.can_serve_space_heating @expression(m, ElectricHeaterToSpaceHeatingKW[ts in p.time_steps], - m[:dvHeatingProduction]["ElectricHeater","SpaceHeating",ts] - ElectricHeaterToHotTESByQualityKW["SpaceHeating",ts] - ElectricHeaterToSteamTurbineByQuality["SpaceHeating",ts] + m[:dvHeatingProduction]["ElectricHeater","SpaceHeating",ts] - ElectricHeaterToHotTESByQualityKW["SpaceHeating",ts] - ElectricHeaterToSteamTurbineByQuality["SpaceHeating",ts] - ElectricHeaterToWasteByQualityKW["SpaceHeating",ts] ) else @expression(m, ElectricHeaterToSpaceHeatingKW[ts in p.time_steps], 0.0) @@ -79,7 +86,7 @@ function add_electric_heater_results(m::JuMP.AbstractModel, p::REoptInputs, d::D if "ProcessHeat" in p.heating_loads && p.s.electric_heater.can_serve_process_heat @expression(m, ElectricHeaterToProcessHeatKW[ts in p.time_steps], - m[:dvHeatingProduction]["ElectricHeater","ProcessHeat",ts] - ElectricHeaterToHotTESByQualityKW["ProcessHeat",ts] - ElectricHeaterToSteamTurbineByQuality["ProcessHeat",ts] + m[:dvHeatingProduction]["ElectricHeater","ProcessHeat",ts] - ElectricHeaterToHotTESByQualityKW["ProcessHeat",ts] - ElectricHeaterToSteamTurbineByQuality["ProcessHeat",ts] - ElectricHeaterToWasteByQualityKW["ProcessHeat",ts] ) else @expression(m, ElectricHeaterToProcessHeatKW[ts in p.time_steps], 0.0) diff --git a/src/results/existing_boiler.jl b/src/results/existing_boiler.jl index 32dfa01ba..3fdac87b6 100644 --- a/src/results/existing_boiler.jl +++ b/src/results/existing_boiler.jl @@ -1,6 +1,7 @@ # REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE. """ `ExistingBoiler` results keys: +- `size_mmbtu_per_hour` - `fuel_consumption_series_mmbtu_per_hour` - `annual_fuel_consumption_mmbtu` - `thermal_production_series_mmbtu_per_hour` @@ -18,7 +19,7 @@ """ function add_existing_boiler_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") r = Dict{String, Any}() - + r["size_mmbtu_per_hour"] = round(value(m[Symbol("dvSize"*_n)]["ExistingBoiler"]) / KWH_PER_MMBTU, digits=3) r["fuel_consumption_series_mmbtu_per_hour"] = round.(value.(m[:dvFuelUsage]["ExistingBoiler", ts] for ts in p.time_steps) ./ KWH_PER_MMBTU, digits=5) r["annual_fuel_consumption_mmbtu"] = round(sum(r["fuel_consumption_series_mmbtu_per_hour"]), digits=5) diff --git a/src/results/existing_chiller.jl b/src/results/existing_chiller.jl index 7ea5eb592..63fb81603 100644 --- a/src/results/existing_chiller.jl +++ b/src/results/existing_chiller.jl @@ -23,12 +23,12 @@ function add_existing_chiller_results(m::JuMP.AbstractModel, p::REoptInputs, d:: r["thermal_to_load_series_ton"] = round.(value.(ELECCHLtoLoad / KWH_THERMAL_PER_TONHOUR).data, digits=3) @expression(m, ELECCHLElecConsumptionSeries[ts in p.time_steps], - sum(m[:dvCoolingProduction]["ExistingChiller", ts] / p.cop["ExistingChiller"]) + sum(m[:dvCoolingProduction]["ExistingChiller", ts] / p.cooling_cop["ExistingChiller"][ts]) ) r["electric_consumption_series_kw"] = round.(value.(ELECCHLElecConsumptionSeries).data, digits=3) @expression(m, Year1ELECCHLElecConsumption, - p.hours_per_time_step * sum(m[:dvCoolingProduction]["ExistingChiller", ts] / p.cop["ExistingChiller"] + p.hours_per_time_step * sum(m[:dvCoolingProduction]["ExistingChiller", ts] / p.cooling_cop["ExistingChiller"][ts] for ts in p.time_steps) ) r["annual_electric_consumption_kwh"] = round(value(Year1ELECCHLElecConsumption), digits=3) diff --git a/src/results/financial.jl b/src/results/financial.jl index 6d17db100..9f40459fc 100644 --- a/src/results/financial.jl +++ b/src/results/financial.jl @@ -161,34 +161,21 @@ function initial_capex(m::JuMP.AbstractModel, p::REoptInputs; _n="") end if "CHP" in p.techs.all - # CHP.installed_cost_per_kw is now a list with potentially > 1 elements - cost_list = p.s.chp.installed_cost_per_kw - size_list = p.s.chp.tech_sizes_for_cost_curve - chp_size = value.(m[Symbol("dvPurchaseSize"*_n)])["CHP"] - if typeof(cost_list) == Vector{Float64} - if chp_size <= size_list[1] - initial_capex += chp_size * cost_list[1] # Currently not handling non-zero cost ($) for 0 kW size input - elseif chp_size > size_list[end] - initial_capex += chp_size * cost_list[end] - else - for s in 2:length(size_list) - if (chp_size > size_list[s-1]) && (chp_size <= size_list[s]) - slope = (cost_list[s] * size_list[s] - cost_list[s-1] * size_list[s-1]) / - (size_list[s] - size_list[s-1]) - initial_capex += cost_list[s-1] * size_list[s-1] + (chp_size - size_list[s-1]) * slope - end - end - end - else - initial_capex += cost_list * chp_size - #Add supplementary firing capital cost - # chp_supp_firing_size = self.nested_outputs["Scenario"]["Site"][tech].get("size_supplementary_firing_kw") - # chp_supp_firing_cost = self.inputs[tech].get("supplementary_firing_capital_cost_per_kw") or 0 - # initial_capex += chp_supp_firing_size * chp_supp_firing_cost - end + chp_size_kw = value.(m[Symbol("dvPurchaseSize"*_n)])["CHP"] + initial_capex += get_chp_initial_capex(p, chp_size_kw) end - # TODO thermal tech costs + if "SteamTurbine" in p.techs.all + initial_capex += p.s.steam_turbine.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["SteamTurbine"] + end + + if "Boiler" in p.techs.all + initial_capex += p.s.boiler.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["Boiler"] + end + + if "AbsorptionChiller" in p.techs.all + initial_capex += p.s.absorption_chiller.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["AbsorptionChiller"] + end if !isempty(p.s.ghp_option_list) @@ -204,6 +191,14 @@ function initial_capex(m::JuMP.AbstractModel, p::REoptInputs; _n="") end end + if "ASHPSpaceHeater" in p.techs.all + initial_capex += p.s.ashp.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["ASHPSpaceHeater"] + end + + if "ASHPWaterHeater" in p.techs.all + initial_capex += p.s.ashp_wh.installed_cost_per_kw * value.(m[Symbol("dvPurchaseSize"*_n)])["ASHPWaterHeater"] + end + return initial_capex end @@ -286,10 +281,10 @@ function calculate_lcoe(p::REoptInputs, tech_results::Dict, tech::AbstractTech) capital_costs = new_kw * tech.installed_cost_per_kw # pre-incentive capital costs - annual_om = new_kw * tech.om_cost_per_kw # NPV of O&M charges escalated over financial life + annual_om = new_kw * tech.om_cost_per_kw om_series = [annual_om * (1+p.s.financial.om_cost_escalation_rate_fraction)^yr for yr in 1:years] - npv_om = sum([om * (1.0/(1.0+discount_rate_fraction))^yr for (yr, om) in enumerate(om_series)]) + npv_om = sum([om * (1.0/(1.0+discount_rate_fraction))^yr for (yr, om) in enumerate(om_series)]) # NPV of O&M charges escalated over financial life #Incentives as calculated in the spreadsheet, note utility incentives are applied before state incentives utility_ibi = min(capital_costs * tech.utility_ibi_fraction, tech.utility_ibi_max) @@ -325,21 +320,10 @@ function calculate_lcoe(p::REoptInputs, tech_results::Dict, tech::AbstractTech) federal_itc_amount = tech.federal_itc_fraction * federal_itc_basis npv_federal_itc = federal_itc_amount * (1.0/(1.0+discount_rate_fraction)) - depreciation_schedule = zeros(years) - if tech.macrs_option_years in [5,7] - if tech.macrs_option_years == 5 - schedule = p.s.financial.macrs_five_year - elseif tech.macrs_option_years == 7 - schedule = p.s.financial.macrs_seven_year - end - macrs_bonus_basis = federal_itc_basis - (federal_itc_basis * tech.federal_itc_fraction * tech.macrs_itc_reduction) - macrs_basis = macrs_bonus_basis * (1 - tech.macrs_bonus_fraction) - for (i,r) in enumerate(schedule) - if i-1 < length(depreciation_schedule) - depreciation_schedule[i] = macrs_basis * r - end - end - depreciation_schedule[1] += tech.macrs_bonus_fraction * macrs_bonus_basis + if tech.macrs_option_years in [5 ,7] + depreciation_schedule = get_depreciation_schedule(p, tech, federal_itc_basis) + else + depreciation_schedule = zeros(years) end tax_deductions = (om_series + depreciation_schedule) * federal_tax_rate_fraction @@ -354,4 +338,76 @@ function calculate_lcoe(p::REoptInputs, tech_results::Dict, tech::AbstractTech) lcoe = (capital_costs + npv_om - npv_pbi - cbi - ibi - npv_federal_itc - npv_tax_deductions ) / npv_annual_energy return round(lcoe, digits=4) +end + +""" + get_depreciation_schedule(p::REoptInputs, tech::AbstractTech, federal_itc_basis::Float64=0.0) + +Get the depreciation schedule for MACRS. First check if tech.macrs_option_years in [5 ,7], then call function to return depreciation schedule +Used in results/financial.jl and results/proformal.jl multiple times +""" +function get_depreciation_schedule(p::REoptInputs, tech::Union{AbstractTech,AbstractStorage}, federal_itc_basis::Float64=0.0) + schedule = [] + if tech.macrs_option_years == 5 + schedule = p.s.financial.macrs_five_year + elseif tech.macrs_option_years == 7 + schedule = p.s.financial.macrs_seven_year + end + + federal_itc_fraction = 0.0 + try + federal_itc_fraction = tech.federal_itc_fraction + catch + @warn "Did not find $(tech).federal_itc_fraction so using 0.0 in calculation of depreciation_schedule." + end + + macrs_bonus_basis = federal_itc_basis - federal_itc_basis * federal_itc_fraction * tech.macrs_itc_reduction + macrs_basis = macrs_bonus_basis * (1 - tech.macrs_bonus_fraction) + + depreciation_schedule = zeros(p.s.financial.analysis_years) + for (i, r) in enumerate(schedule) + if i < length(depreciation_schedule) + depreciation_schedule[i] = macrs_basis * r + end + end + depreciation_schedule[1] += (tech.macrs_bonus_fraction * macrs_bonus_basis) + + return depreciation_schedule +end + + +""" + get_chp_initial_capex(p::REoptInputs, size_kw::Float64) + +CHP has a cost-curve input option, so calculating the initial CapEx requires more logic than typical tech CapEx calcs +""" +function get_chp_initial_capex(p::REoptInputs, size_kw::Float64) + # CHP.installed_cost_per_kw is now a list with potentially > 1 elements + cost_list = p.s.chp.installed_cost_per_kw + size_list = p.s.chp.tech_sizes_for_cost_curve + chp_size = size_kw + initial_capex = 0.0 + if typeof(cost_list) == Vector{Float64} + if chp_size <= size_list[1] + initial_capex = chp_size * cost_list[1] # Currently not handling non-zero cost ($) for 0 kW size input + elseif chp_size > size_list[end] + initial_capex = chp_size * cost_list[end] + else + for s in 2:length(size_list) + if (chp_size > size_list[s-1]) && (chp_size <= size_list[s]) + slope = (cost_list[s] * size_list[s] - cost_list[s-1] * size_list[s-1]) / + (size_list[s] - size_list[s-1]) + initial_capex = cost_list[s-1] * size_list[s-1] + (chp_size - size_list[s-1]) * slope + end + end + end + else + initial_capex = cost_list * chp_size + #Add supplementary firing capital cost + # chp_supp_firing_size = self.nested_outputs["Scenario"]["Site"][tech].get("size_supplementary_firing_kw") + # chp_supp_firing_cost = self.inputs[tech].get("supplementary_firing_capital_cost_per_kw") or 0 + # initial_capex += chp_supp_firing_size * chp_supp_firing_cost + end + + return initial_capex end \ No newline at end of file diff --git a/src/results/ghp.jl b/src/results/ghp.jl index 3e315f403..a9a8a01a7 100644 --- a/src/results/ghp.jl +++ b/src/results/ghp.jl @@ -11,6 +11,9 @@ GHP results: - `size_heat_pump_ton` Total heat pump capacity [ton] - `space_heating_thermal_load_reduction_with_ghp_mmbtu_per_hour` - `cooling_thermal_load_reduction_with_ghp_ton` +- `thermal_to_space_heating_load_series_mmbtu_per_hour` +- `thermal_to_dhw_load_series_mmbtu_per_hour` +- `thermal_to_load_series_ton` """ function add_ghp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") @@ -40,11 +43,21 @@ function add_ghp_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _n="") sum(p.cooling_thermal_load_reduction_with_ghp_kw[g,ts] * m[Symbol("binGHP"*_n)][g] for g in p.ghp_options)) r["cooling_thermal_load_reduction_with_ghp_ton"] = round.(value.(CoolingThermalReductionWithGHP) ./ KWH_THERMAL_PER_TONHOUR, digits=3) r["ghx_residual_value_present_value"] = value(m[:ResidualGHXCapCost]) + r["thermal_to_space_heating_load_series_mmbtu_per_hour"] = d["HeatingLoad"]["space_heating_thermal_load_series_mmbtu_per_hour"] .- r["space_heating_thermal_load_reduction_with_ghp_mmbtu_per_hour"] + r["thermal_to_load_series_ton"] = d["CoolingLoad"]["load_series_ton"] .- r["cooling_thermal_load_reduction_with_ghp_ton"] + if p.s.ghp_option_list[ghp_option_chosen].can_serve_dhw + r["thermal_to_dhw_load_series_mmbtu_per_hour"] = d["HeatingLoad"]["dhw_thermal_load_series_mmbtu_per_hour"] + else + r["thermal_to_dhw_load_series_mmbtu_per_hour"] = zeros(length(p.time_steps)) + end else r["ghpghx_chosen_outputs"] = Dict() r["space_heating_thermal_load_reduction_with_ghp_mmbtu_per_hour"] = zeros(length(p.time_steps)) r["cooling_thermal_load_reduction_with_ghp_ton"] = zeros(length(p.time_steps)) r["ghx_residual_value_present_value"] = 0.0 + r["thermal_to_space_heating_load_series_mmbtu_per_hour"] = zeros(length(p.time_steps)) + r["thermal_to_load_series_ton"] = zeros(length(p.time_steps)) + r["thermal_to_dhw_load_series_mmbtu_per_hour"] = zeros(length(p.time_steps)) end d["GHP"] = r nothing diff --git a/src/results/heating_cooling_load.jl b/src/results/heating_cooling_load.jl index d543b7ca4..1a1d9fe19 100644 --- a/src/results/heating_cooling_load.jl +++ b/src/results/heating_cooling_load.jl @@ -91,6 +91,12 @@ function add_heating_load_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict r["annual_calculated_process_heat_boiler_fuel_load_mmbtu"] = r["annual_calculated_process_heat_thermal_load_mmbtu"] / existing_boiler_efficiency r["annual_calculated_total_heating_boiler_fuel_load_mmbtu"] = r["annual_calculated_total_heating_thermal_load_mmbtu"] / existing_boiler_efficiency + r["annual_total_unaddressable_heating_load_mmbtu"] = (p.s.dhw_load.unaddressable_annual_fuel_mmbtu + + p.s.space_heating_load.unaddressable_annual_fuel_mmbtu + + p.s.process_heat_load.unaddressable_annual_fuel_mmbtu) + + r["annual_emissions_from_unaddressable_heating_load_tonnes_CO2"] = r["annual_total_unaddressable_heating_load_mmbtu"] * p.s.existing_boiler.emissions_factor_lb_CO2_per_mmbtu * TONNE_PER_LB + d["HeatingLoad"] = r nothing end diff --git a/src/results/proforma.jl b/src/results/proforma.jl index 9e403862e..a765573e3 100644 --- a/src/results/proforma.jl +++ b/src/results/proforma.jl @@ -8,6 +8,8 @@ mutable struct Metrics federal_itc::Float64 om_series::Array{Float64, 1} om_series_bau::Array{Float64, 1} + fuel_cost_series::Array{Float64, 1} + fuel_cost_series_bau::Array{Float64, 1} total_pbi::Array{Float64, 1} total_pbi_bau::Array{Float64, 1} total_depreciation::Array{Float64, 1} @@ -22,7 +24,7 @@ Recreates the ProForma spreadsheet calculations to get the simple payback period party case), and payment to third party (3rd party case). return Dict( - "simple_payback_years" => 0.0, + "simple_payback_years" => 0.0, # The year in which cumulative net free cashflows become positive. For a third party analysis, the SPP is for the developer. "internal_rate_of_return" => 0.0, "net_present_cost" => 0.0, "annualized_payment_to_third_party" => 0.0, @@ -30,7 +32,8 @@ return Dict( "offtaker_annual_free_cashflows_bau" => Float64[], "offtaker_discounted_annual_free_cashflows" => Float64[], "offtaker_discounted_annual_free_cashflows_bau" => Float64[], - "developer_annual_free_cashflows" => Float64[] + "developer_annual_free_cashflows" => Float64[], + "initial_capital_costs_after_incentives_without_macrs" => 0.0 # Initial capital costs after ibi, cbi, and ITC incentives ) """ function proforma_results(p::REoptInputs, d::Dict) @@ -43,15 +46,17 @@ function proforma_results(p::REoptInputs, d::Dict) "offtaker_annual_free_cashflows_bau" => Float64[], "offtaker_discounted_annual_free_cashflows" => Float64[], "offtaker_discounted_annual_free_cashflows_bau" => Float64[], - "developer_annual_free_cashflows" => Float64[] + "developer_annual_free_cashflows" => Float64[], + "initial_capital_costs_after_incentives_without_macrs" => 0.0 ) years = p.s.financial.analysis_years escalate_elec(val) = [-1 * val * (1 + p.s.financial.elec_cost_escalation_rate_fraction)^yr for yr in 1:years] escalate_om(val) = [val * (1 + p.s.financial.om_cost_escalation_rate_fraction)^yr for yr in 1:years] + escalate_fuel(val, esc_rate) = [val * (1 + esc_rate)^yr for yr in 1:years] third_party = p.s.financial.third_party_ownership # Create placeholder variables to store summed totals across all relevant techs - m = Metrics(0, zeros(years), zeros(years), zeros(years), zeros(years), zeros(years), 0) + m = Metrics(0, zeros(years), zeros(years), zeros(years), zeros(years), zeros(years), zeros(years), zeros(years), 0) # calculate PV o+m costs, incentives, and depreciation for pv in p.s.pvs @@ -85,59 +90,141 @@ function proforma_results(p::REoptInputs, d::Dict) m.federal_itc += federal_itc_amount # Depreciation - if storage.macrs_option_years in [5, 7] - schedule = [] - if storage.macrs_option_years == 5 - schedule = p.s.financial.macrs_five_year - elseif storage.macrs_option_years == 7 - schedule = p.s.financial.macrs_seven_year - end - macrs_bonus_basis = federal_itc_basis * (1 - storage.total_itc_fraction * storage.macrs_itc_reduction) - macrs_basis = macrs_bonus_basis * (1 - storage.macrs_bonus_fraction) - - depreciation_schedule = zeros(years) - for (i, r) in enumerate(schedule) - if i < length(depreciation_schedule) - depreciation_schedule[i] = macrs_basis * r - end - end - depreciation_schedule[1] += storage.macrs_bonus_fraction * macrs_bonus_basis + if storage.macrs_option_years in [5 ,7] + depreciation_schedule = get_depreciation_schedule(p, storage, federal_itc_basis) m.total_depreciation += depreciation_schedule end end # calculate Generator o+m costs, incentives, and depreciation if "Generator" in keys(d) && d["Generator"]["size_kw"] > 0 - # In the two party case the developer does not include the fuel cost in their costs - # It is assumed that the offtaker will pay for this at a rate that is not marked up - # to cover developer profits + # In the two party case the developer does not include the fuel cost or O&M costs for existing assets in their costs + # It is assumed that the offtaker will pay for this at a rate that is not marked up to cover developer profits fixed_and_var_om = d["Generator"]["year_one_fixed_om_cost_before_tax"] + d["Generator"]["year_one_variable_om_cost_before_tax"] fixed_and_var_om_bau = 0.0 year_one_fuel_cost_bau = 0.0 if p.s.generator.existing_kw > 0 fixed_and_var_om_bau = d["Generator"]["year_one_fixed_om_cost_before_tax_bau"] + d["Generator"]["year_one_variable_om_cost_before_tax_bau"] + if third_party + fixed_and_var_om -= fixed_and_var_om_bau + end year_one_fuel_cost_bau = d["Generator"]["year_one_fuel_cost_before_tax_bau"] end - if !third_party - annual_om = -1 * (fixed_and_var_om + d["Generator"]["year_one_fuel_cost_before_tax"]) - annual_om_bau = -1 * (fixed_and_var_om_bau + year_one_fuel_cost_bau) - else - annual_om = -1 * fixed_and_var_om + annual_fuel = -1 * d["Generator"]["year_one_fuel_cost_before_tax"] + annual_om = -1 * fixed_and_var_om - annual_om_bau = -1 * fixed_and_var_om_bau - end + annual_fuel_bau = -1 * year_one_fuel_cost_bau + annual_om_bau = -1 * fixed_and_var_om_bau m.om_series += escalate_om(annual_om) + m.fuel_cost_series += escalate_fuel(annual_fuel, p.s.financial.generator_fuel_cost_escalation_rate_fraction) m.om_series_bau += escalate_om(annual_om_bau) + m.fuel_cost_series_bau += escalate_fuel(annual_fuel_bau, p.s.financial.generator_fuel_cost_escalation_rate_fraction) + end + + # calculate CHP o+m costs, incentives, and depreciation + if "CHP" in keys(d) && d["CHP"]["size_kw"] > 0 + update_metrics(m, p, p.s.chp, "CHP", d, third_party) + end + + # calculate ExistingBoiler o+m costs (just fuel, no non-fuel operating costs currently) + # the optional installed_cost inputs assume net present cost so no option for MACRS or incentives + if "ExistingBoiler" in keys(d) && d["ExistingBoiler"]["size_mmbtu_per_hour"] > 0 + fuel_cost = d["ExistingBoiler"]["year_one_fuel_cost_before_tax"] + m.fuel_cost_series += escalate_fuel(-1 * fuel_cost, p.s.financial.existing_boiler_fuel_cost_escalation_rate_fraction) + var_om = 0.0 + fixed_om = 0.0 + annual_om = -1 * (var_om + fixed_om) + m.om_series += escalate_om(annual_om) + + # BAU ExistingBoiler + fuel_cost_bau = d["ExistingBoiler"]["year_one_fuel_cost_before_tax_bau"] + m.fuel_cost_series_bau += escalate_fuel(-1 * fuel_cost_bau, p.s.financial.existing_boiler_fuel_cost_escalation_rate_fraction) + var_om_bau = 0.0 + fixed_om_bau = 0.0 + annual_om_bau = -1 * (var_om_bau + fixed_om_bau) + m.om_series_bau += escalate_om(annual_om_bau) + end + + # calculate (new) Boiler o+m costs and depreciation (no incentives currently, other than MACRS) + if "Boiler" in keys(d) && d["Boiler"]["size_mmbtu_per_hour"] > 0 + fuel_cost = d["Boiler"]["year_one_fuel_cost_before_tax"] + m.fuel_cost_series += escalate_fuel(-1 * fuel_cost, p.s.financial.boiler_fuel_cost_escalation_rate_fraction) + var_om = p.s.boiler.om_cost_per_kwh * d["Boiler"]["annual_thermal_production_mmbtu"] * KWH_PER_MMBTU + fixed_om = p.s.boiler.om_cost_per_kw * d["Boiler"]["size_mmbtu_per_hour"] * KWH_PER_MMBTU + annual_om = -1 * (var_om + fixed_om) + m.om_series += escalate_om(annual_om) + + # Depreciation + if p.s.boiler.macrs_option_years in [5 ,7] + depreciation_schedule = get_depreciation_schedule(p, p.s.boiler) + m.total_depreciation += depreciation_schedule + end end + # calculate Steam Turbine o+m costs and depreciation (no incentives currently, other than MACRS) + if "SteamTurbine" in keys(d) && get(d["SteamTurbine"], "size_kw", 0) > 0 + fixed_om = p.s.steam_turbine.om_cost_per_kw * d["SteamTurbine"]["size_kw"] + var_om = p.s.steam_turbine.om_cost_per_kwh * d["SteamTurbine"]["annual_electric_production_kwh"] + annual_om = -1 * (fixed_om + var_om) + m.om_series += escalate_om(annual_om) + + # Depreciation + if p.s.steam_turbine.macrs_option_years in [5 ,7] + depreciation_schedule = get_depreciation_schedule(p, p.s.steam_turbine) + m.total_depreciation += depreciation_schedule + end + end + + # calculate Absorption Chiller o+m costs and depreciation (no incentives currently, other than MACRS) + if "AbsorptionChiller" in keys(d) && d["AbsorptionChiller"]["size_ton"] > 0 + # Some thermal techs (e.g. Boiler) only have struct fields for O&M "per_kw" (converted from e.g. per_mmbtu_per_hour or per_ton) + # but Absorption Chiller also has the input-style "per_ton" O&M, so no need to convert like for Boiler + fixed_om = p.s.absorption_chiller.om_cost_per_ton * d["AbsorptionChiller"]["size_ton"] + + annual_om = -1 * (fixed_om) + m.om_series += escalate_om(annual_om) + + # Depreciation + if p.s.absorption_chiller.macrs_option_years in [5 ,7] + depreciation_schedule = get_depreciation_schedule(p, p.s.absorption_chiller) + m.total_depreciation += depreciation_schedule + end + end + # calculate GHP incentives, and depreciation if "GHP" in keys(d) && d["GHP"]["ghp_option_chosen"] > 0 update_ghp_metrics(m, p, p.s.ghp_option_list[d["GHP"]["ghp_option_chosen"]], "GHP", d, third_party) end + # calculate ASHPSpaceHeater o+m costs and depreciation (no incentives currently, other than MACRS) + if "ASHPSpaceHeater" in keys(d) && d["ASHPSpaceHeater"]["size_ton"] > 0 + fixed_om = p.s.ashp.om_cost_per_kw * KWH_THERMAL_PER_TONHOUR * d["ASHPSpaceHeater"]["size_ton"] + annual_om = -1 * (fixed_om) + m.om_series += escalate_om(annual_om) + + # Depreciation + if p.s.ashp.macrs_option_years in [5 ,7] + depreciation_schedule = get_depreciation_schedule(p, p.s.ashp) + m.total_depreciation += depreciation_schedule + end + end + + # calculate ASHPWaterHeater o+m costs and depreciation (no incentives currently, other than MACRS) + if "ASHPWaterHeater" in keys(d) && d["ASHPWaterHeater"]["size_ton"] > 0 + fixed_om = p.s.ashp.om_cost_per_kw * KWH_THERMAL_PER_TONHOUR * d["ASHPWaterHeater"]["size_ton"] + annual_om = -1 * (fixed_om) + m.om_series += escalate_om(annual_om) + + # Depreciation + if p.s.ashp.macrs_option_years in [5 ,7] + depreciation_schedule = get_depreciation_schedule(p, p.s.ashp_wh) + m.total_depreciation += depreciation_schedule + end + end + # Optimal Case calculations electricity_bill_series = escalate_elec(d["ElectricTariff"]["year_one_bill_before_tax"]) export_credit_series = escalate_elec(-d["ElectricTariff"]["year_one_export_benefit_before_tax"]) @@ -147,7 +234,7 @@ function proforma_results(p::REoptInputs, d::Dict) total_operating_expenses = m.om_series tax_rate_fraction = p.s.financial.owner_tax_rate_fraction else - total_operating_expenses = electricity_bill_series + export_credit_series + m.om_series + total_operating_expenses = electricity_bill_series + export_credit_series + m.om_series + m.fuel_cost_series tax_rate_fraction = p.s.financial.offtaker_tax_rate_fraction end @@ -163,6 +250,7 @@ function proforma_results(p::REoptInputs, d::Dict) total_cash_incentives = m.total_pbi * (1 - tax_rate_fraction) free_cashflow_without_year_zero = m.total_depreciation * tax_rate_fraction + total_cash_incentives + operating_expenses_after_tax free_cashflow_without_year_zero[1] += m.federal_itc + r["initial_capital_costs_after_incentives_without_macrs"] = d["Financial"]["initial_capital_costs"] - m.total_ibi_and_cbi - m.federal_itc free_cashflow = append!([(-1 * d["Financial"]["initial_capital_costs"]) + m.total_ibi_and_cbi], free_cashflow_without_year_zero) # At this point the logic branches based on third-party ownership or not - see comments @@ -195,38 +283,17 @@ function proforma_results(p::REoptInputs, d::Dict) annual_income_from_host_series = repeat([-1 * r["annualized_payment_to_third_party"]], years) - if "Generator" in keys(d) && d["Generator"]["size_kw"] > 0 - generator_fuel_cost_series = escalate_om(-1 * d["Generator"]["year_one_fuel_cost_before_tax"]) - if p.s.generator.existing_kw > 0 - existing_genertor_fuel_cost_series = escalate_om(-1 * d["Generator"]["year_one_fuel_cost_before_tax_bau"]) - else - existing_genertor_fuel_cost_series = zeros(years) - end - else - existing_genertor_fuel_cost_series = zeros(years) - generator_fuel_cost_series = zeros(years) - end - net_energy_costs = -electricity_bill_series_bau - export_credit_series_bau + electricity_bill_series + - export_credit_series + annual_income_from_host_series - existing_genertor_fuel_cost_series + - generator_fuel_cost_series - - if p.s.financial.owner_tax_rate_fraction > 0 - deductable_net_energy_costs = copy(net_energy_costs) - else - deductable_net_energy_costs = zeros(years) - end - r["offtaker_annual_free_cashflows"] = append!([0.0], - electricity_bill_series + export_credit_series + generator_fuel_cost_series + annual_income_from_host_series + electricity_bill_series + export_credit_series + m.fuel_cost_series + annual_income_from_host_series + m.om_series_bau ) r["offtaker_annual_free_cashflows_bau"] = append!([0.0], - electricity_bill_series_bau + export_credit_series_bau + existing_genertor_fuel_cost_series + electricity_bill_series_bau + export_credit_series_bau + m.fuel_cost_series_bau + m.om_series_bau ) else # get cumulative cashflow for offtaker electricity_bill_series_bau = escalate_elec(d["ElectricTariff"]["year_one_bill_before_tax_bau"]) export_credit_series_bau = escalate_elec(-d["ElectricTariff"]["year_one_export_benefit_before_tax_bau"]) - total_operating_expenses_bau = electricity_bill_series_bau + export_credit_series_bau + m.om_series_bau + total_operating_expenses_bau = electricity_bill_series_bau + export_credit_series_bau + m.om_series_bau + m.fuel_cost_series_bau total_cash_incentives_bau = m.total_pbi_bau * (1 - p.s.financial.offtaker_tax_rate_fraction) if p.s.financial.offtaker_tax_rate_fraction > 0 @@ -284,10 +351,20 @@ function update_metrics(m::Metrics, p::REoptInputs, tech::AbstractTech, tech_nam total_kw = results[tech_name]["size_kw"] existing_kw = :existing_kw in fieldnames(typeof(tech)) ? tech.existing_kw : 0 new_kw = total_kw - existing_kw - capital_cost = new_kw * tech.installed_cost_per_kw + if tech_name == "CHP" + capital_cost = get_chp_initial_capex(p, results["CHP"]["size_kw"]) + else + capital_cost = new_kw * tech.installed_cost_per_kw + end - # owner is responsible for both new and existing PV maintenance in optimal case - if third_party + # owner is responsible for only new technologies operating and maintenance cost in optimal case + # CHP doesn't have existing CHP, and it has different O&M cost parameters + if tech_name == "CHP" + hours_operating = sum(results["CHP"]["electric_production_series_kw"] .> 0.0) / (8760 * p.s.settings.time_steps_per_hour) + annual_om = -1 * (results["CHP"]["annual_electric_production_kwh"] * tech.om_cost_per_kwh + + new_kw * tech.om_cost_per_kw + + new_kw * tech.om_cost_per_hr_per_kw_rated * hours_operating) + elseif third_party annual_om = -1 * new_kw * tech.om_cost_per_kw else annual_om = -1 * total_kw * tech.om_cost_per_kw @@ -297,6 +374,12 @@ function update_metrics(m::Metrics, p::REoptInputs, tech::AbstractTech, tech_nam m.om_series += escalate_om(annual_om) m.om_series_bau += escalate_om(-1 * existing_kw * tech.om_cost_per_kw) + if tech_name == "CHP" + escalate_fuel(val, esc_rate) = [val * (1 + esc_rate)^yr for yr in 1:years] + fuel_cost = results["CHP"]["year_one_fuel_cost_before_tax"] + m.fuel_cost_series += escalate_fuel(-1 * fuel_cost, p.s.financial.chp_fuel_cost_escalation_rate_fraction) + end + # incentive calculations, in the spreadsheet utility incentives are applied first utility_ibi = minimum([capital_cost * tech.utility_ibi_fraction, tech.utility_ibi_max]) utility_cbi = minimum([new_kw * tech.utility_rebate_per_kw, tech.utility_rebate_max]) @@ -311,7 +394,11 @@ function update_metrics(m::Metrics, p::REoptInputs, tech::AbstractTech, tech_nam pbi_series = Float64[] pbi_series_bau = Float64[] existing_energy_bau = third_party ? get(results[tech_name], "year_one_energy_produced_kwh_bau", 0) : 0 - year_one_energy = "year_one_energy_produced_kwh" in keys(results[tech_name]) ? results[tech_name]["year_one_energy_produced_kwh"] : results[tech_name]["annual_energy_produced_kwh"] + if tech_name == "CHP" + year_one_energy = results[tech_name]["annual_electric_production_kwh"] + else + year_one_energy = "year_one_energy_produced_kwh" in keys(results[tech_name]) ? results[tech_name]["year_one_energy_produced_kwh"] : results[tech_name]["annual_energy_produced_kwh"] + end for yr in range(0, stop=years-1) if yr < tech.production_incentive_years degradation_fraction = :degradation_fraction in fieldnames(typeof(tech)) ? (1 - tech.degradation_fraction)^yr : 1.0 @@ -334,31 +421,13 @@ function update_metrics(m::Metrics, p::REoptInputs, tech::AbstractTech, tech_nam m.total_pbi_bau += pbi_series_bau # Federal ITC - # NOTE: bug in v1 has the ITC within the `if tech.macrs_option_years in [5 ,7]` block. - # NOTE: bug in v1 reduces the federal_itc_basis with the federal_cbi, which is incorrect federal_itc_basis = capital_cost - state_ibi - utility_ibi - state_cbi - utility_cbi federal_itc_amount = tech.federal_itc_fraction * federal_itc_basis m.federal_itc += federal_itc_amount # Depreciation if tech.macrs_option_years in [5 ,7] - schedule = [] - if tech.macrs_option_years == 5 - schedule = p.s.financial.macrs_five_year - elseif tech.macrs_option_years == 7 - schedule = p.s.financial.macrs_seven_year - end - - macrs_bonus_basis = federal_itc_basis - federal_itc_basis * tech.federal_itc_fraction * tech.macrs_itc_reduction - macrs_basis = macrs_bonus_basis * (1 - tech.macrs_bonus_fraction) - - depreciation_schedule = zeros(years) - for (i, r) in enumerate(schedule) - if i < length(depreciation_schedule) - depreciation_schedule[i] = macrs_basis * r - end - end - depreciation_schedule[1] += (tech.macrs_bonus_fraction * macrs_bonus_basis) + depreciation_schedule = get_depreciation_schedule(p, tech, federal_itc_basis) m.total_depreciation += depreciation_schedule end nothing @@ -407,31 +476,13 @@ function update_ghp_metrics(m::REopt.Metrics, p::REoptInputs, tech::REopt.Abstra m.total_pbi_bau += pbi_series_bau # Federal ITC - # NOTE: bug in v1 has the ITC within the `if tech.macrs_option_years in [5 ,7]` block. - # NOTE: bug in v1 reduces the federal_itc_basis with the federal_cbi, which is incorrect federal_itc_basis = capital_cost - state_ibi - utility_ibi - state_cbi - utility_cbi federal_itc_amount = tech.federal_itc_fraction * federal_itc_basis m.federal_itc += federal_itc_amount # Depreciation if tech.macrs_option_years in [5 ,7] - schedule = [] - if tech.macrs_option_years == 5 - schedule = p.s.financial.macrs_five_year - elseif tech.macrs_option_years == 7 - schedule = p.s.financial.macrs_seven_year - end - - macrs_bonus_basis = federal_itc_basis - federal_itc_basis * tech.federal_itc_fraction * tech.macrs_itc_reduction - macrs_basis = macrs_bonus_basis * (1 - tech.macrs_bonus_fraction) - - depreciation_schedule = zeros(years) - for (i, r) in enumerate(schedule) - if i < length(depreciation_schedule) - depreciation_schedule[i] = macrs_basis * r - end - end - depreciation_schedule[1] += (tech.macrs_bonus_fraction * macrs_bonus_basis) + depreciation_schedule = get_depreciation_schedule(p, tech, federal_itc_basis) m.total_depreciation += depreciation_schedule end nothing @@ -452,6 +503,6 @@ function irr(cashflows::AbstractArray{<:Real, 1}) try rate = fzero(f, [0.0, 0.99]) finally - return round(rate, digits=2) + return round(rate, digits=3) end end diff --git a/src/results/results.jl b/src/results/results.jl index 61202e03a..9b79d13a8 100644 --- a/src/results/results.jl +++ b/src/results/results.jl @@ -61,7 +61,7 @@ function reopt_results(m::JuMP.AbstractModel, p::REoptInputs; _n="") @debug "Outage results processing took $(round(time_elapsed, digits=3)) seconds." end - if !isempty(union(p.techs.chp, p.techs.heating)) + if !isempty(p.techs.heating) add_heating_load_results(m, p, d) end @@ -100,9 +100,17 @@ function reopt_results(m::JuMP.AbstractModel, p::REoptInputs; _n="") add_steam_turbine_results(m, p, d; _n) end - if !isempty(p.techs.electric_heater) + if "ElectricHeater" in p.techs.electric_heater add_electric_heater_results(m, p, d; _n) end + + if "ASHPSpaceHeater" in p.techs.ashp + add_ashp_results(m, p, d; _n) + end + + if "ASHPWaterHeater" in p.techs.ashp_wh + add_ashp_wh_results(m, p, d; _n) + end return d end diff --git a/test/runtests.jl b/test/runtests.jl index dd7b5905b..3a63a2a38 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -8,6 +8,7 @@ using DotEnv DotEnv.load!() using Random using DelimitedFiles +using Logging Random.seed!(42) if "Xpress" in ARGS @@ -22,8 +23,7 @@ elseif "CPLEX" in ARGS end else # run HiGHS tests - - @testset "Inputs" begin + @testset verbose=true "REopt test set using HiGHS solver" begin @testset "hybrid profile" begin electric_load = REopt.ElectricLoad(; blended_doe_reference_percents = [0.2, 0.2, 0.2, 0.2, 0.2], @@ -54,7 +54,7 @@ else # run HiGHS tests latitude, longitude = 3.8603988398663125, 11.528880303663136 radius = 0 dataset, distance, datasource = REopt.call_solar_dataset_api(latitude, longitude, radius) - @test dataset ≈ "intl" + @test dataset ≈ "nsrdb" # 4. Fairbanks, AK site = "Fairbanks" @@ -63,551 +63,589 @@ else # run HiGHS tests dataset, distance, datasource = REopt.call_solar_dataset_api(latitude, longitude, radius) @test dataset ≈ "tmy3" end - end - @testset "January Export Rates" begin - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - data = JSON.parsefile("./scenarios/monthly_rate.json") - - # create wholesale_rate with compensation in January > retail rate - jan_rate = data["ElectricTariff"]["monthly_energy_rates"][1] - data["ElectricTariff"]["wholesale_rate"] = - append!(repeat([jan_rate + 0.1], 31 * 24), repeat([0.0], 8760 - 31*24)) - data["ElectricTariff"]["monthly_demand_rates"] = repeat([0], 12) + @testset "ASHP min allowable size and COP, CF Profiles" begin + #Heating profiles + heating_reference_temps_degF = [10,20,30] + heating_cop_reference = [1,3,4] + heating_cf_performance = [1.2,1.3,1.5] + back_up_temp_threshold_degF = 10 + test_temps = [5,15,25,35] + test_cops = [1.0,2.0,3.5,4.0] + test_cfs = [1.0,1.25,1.4,1.5] + heating_cop, heating_cf = REopt.get_ashp_performance(heating_cop_reference, + heating_cf_performance, + heating_reference_temps_degF, + test_temps, + back_up_temp_threshold_degF) + @test all(heating_cop .== test_cops) + @test all(heating_cf .== test_cfs) + #Cooling profiles + cooling_reference_temps_degF = [30,20,10] + cooling_cop_reference = [1,3,4] + cooling_cf_performance = [1.2,1.3,1.5] + back_up_temp_threshold_degF = -200 + test_temps = [35,25,15,5] + test_cops = [1.0,2.0,3.5,4.0] + test_cfs = [1.2,1.25,1.4,1.5] + cooling_cop, cooling_cf = REopt.get_ashp_performance(cooling_cop_reference, + cooling_cf_performance, + cooling_reference_temps_degF, + test_temps, + back_up_temp_threshold_degF) + @test all(cooling_cop .== test_cops) + @test all(cooling_cf .== test_cfs) + # min allowable size + heating_load = Array{Real}([10.0,10.0,10.0,10.0]) + cooling_load = Array{Real}([10.0,10.0,10.0,10.0]) + space_heating_min_allowable_size = REopt.get_ashp_default_min_allowable_size(heating_load, heating_cf, cooling_load, cooling_cf) + wh_min_allowable_size = REopt.get_ashp_default_min_allowable_size(heating_load, heating_cf) + @test space_heating_min_allowable_size ≈ 9.166666666666666 atol=1e-8 + @test wh_min_allowable_size ≈ 5.0 atol=1e-8 + end - s = Scenario(data) - inputs = REoptInputs(s) - results = run_reopt(model, inputs) + @testset "January Export Rates" begin + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + data = JSON.parsefile("./scenarios/monthly_rate.json") - @test results["PV"]["size_kw"] ≈ 68.9323 atol=0.01 - @test results["Financial"]["lcc"] ≈ 432681.26 rtol=1e-5 # with levelization_factor hack the LCC is within 5e-5 of REopt API LCC - @test all(x == 0.0 for x in results["PV"]["electric_to_load_series_kw"][1:744]) - end + # create wholesale_rate with compensation in January > retail rate + jan_rate = data["ElectricTariff"]["monthly_energy_rates"][1] + data["ElectricTariff"]["wholesale_rate"] = + append!(repeat([jan_rate + 0.1], 31 * 24), repeat([0.0], 8760 - 31*24)) + data["ElectricTariff"]["monthly_demand_rates"] = repeat([0], 12) - @testset "Blended tariff" begin - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt(model, "./scenarios/no_techs.json") - @test results["ElectricTariff"]["year_one_energy_cost_before_tax"] ≈ 1000.0 - @test results["ElectricTariff"]["year_one_demand_cost_before_tax"] ≈ 136.99 - end + s = Scenario(data) + inputs = REoptInputs(s) + results = run_reopt(model, inputs) - @testset "Solar and Storage" begin - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - r = run_reopt(model, "./scenarios/pv_storage.json") + @test results["PV"]["size_kw"] ≈ 68.9323 atol=0.01 + @test results["Financial"]["lcc"] ≈ 432681.26 rtol=1e-5 # with levelization_factor hack the LCC is within 5e-5 of REopt API LCC + @test all(x == 0.0 for x in results["PV"]["electric_to_load_series_kw"][1:744]) + end - @test r["PV"]["size_kw"] ≈ 216.6667 atol=0.01 - @test r["Financial"]["lcc"] ≈ 1.2391786e7 rtol=1e-5 - @test r["ElectricStorage"]["size_kw"] ≈ 49.0 atol=0.1 - @test r["ElectricStorage"]["size_kwh"] ≈ 83.3 atol=0.1 - end + @testset "Blended tariff" begin + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(model, "./scenarios/no_techs.json") + @test results["ElectricTariff"]["year_one_energy_cost_before_tax"] ≈ 1000.0 + @test results["ElectricTariff"]["year_one_demand_cost_before_tax"] ≈ 136.99 + end - @testset "Outage with Generator" begin - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt(model, "./scenarios/generator.json") - @test results["Generator"]["size_kw"] ≈ 9.55 atol=0.01 - @test (sum(results["Generator"]["electric_to_load_series_kw"][i] for i in 1:9) + - sum(results["Generator"]["electric_to_load_series_kw"][i] for i in 13:8760)) == 0 - p = REoptInputs("./scenarios/generator.json") - simresults = simulate_outages(results, p) - @test simresults["resilience_hours_max"] == 11 - end + @testset "Solar and Storage" begin + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + r = run_reopt(model, "./scenarios/pv_storage.json") - # TODO test MPC with outages - @testset "MPC" begin - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - r = run_mpc(model, "./scenarios/mpc.json") - @test maximum(r["ElectricUtility"]["to_load_series_kw"][1:15]) <= 98.0 - @test maximum(r["ElectricUtility"]["to_load_series_kw"][16:24]) <= 97.0 - @test sum(r["PV"]["to_grid_series_kw"]) ≈ 0 - end + @test r["PV"]["size_kw"] ≈ 216.6667 atol=0.01 + @test r["Financial"]["lcc"] ≈ 1.2391786e7 rtol=1e-5 + @test r["ElectricStorage"]["size_kw"] ≈ 49.0 atol=0.1 + @test r["ElectricStorage"]["size_kwh"] ≈ 83.3 atol=0.1 + end - @testset "MPC Multi-node" begin - # not doing much yet; just testing that two identical sites have the same costs - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - ps = MPCInputs[] - push!(ps, MPCInputs("./scenarios/mpc_multinode1.json")); - push!(ps, MPCInputs("./scenarios/mpc_multinode2.json")); - r = run_mpc(model, ps) - @test r[1]["Costs"] ≈ r[2]["Costs"] - end + @testset "Outage with Generator" begin + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(model, "./scenarios/generator.json") + @test results["Generator"]["size_kw"] ≈ 9.55 atol=0.01 + @test (sum(results["Generator"]["electric_to_load_series_kw"][i] for i in 1:9) + + sum(results["Generator"]["electric_to_load_series_kw"][i] for i in 13:8760)) == 0 + p = REoptInputs("./scenarios/generator.json") + simresults = simulate_outages(results, p) + @test simresults["resilience_hours_max"] == 11 + end - @testset "Complex Incentives" begin - """ - This test was compared against the API test: - reo.tests.test_reopt_url.EntryResourceTest.test_complex_incentives - when using the hardcoded levelization_factor in this package's REoptInputs function. - The two LCC's matched within 0.00005%. (The Julia pkg LCC is 1.0971991e7) - """ - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt(model, "./scenarios/incentives.json") - @test results["Financial"]["lcc"] ≈ 1.096852612e7 atol=1e4 - end + # TODO test MPC with outages + @testset "MPC" begin + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + r = run_mpc(model, "./scenarios/mpc.json") + @test maximum(r["ElectricUtility"]["to_load_series_kw"][1:15]) <= 98.0 + @test maximum(r["ElectricUtility"]["to_load_series_kw"][16:24]) <= 97.0 + @test sum(r["PV"]["to_grid_series_kw"]) ≈ 0 + end - @testset "Fifteen minute load" begin - d = JSON.parsefile("scenarios/no_techs.json") - d["ElectricLoad"] = Dict("loads_kw" => repeat([1.0], 35040)) - d["Settings"] = Dict("time_steps_per_hour" => 4) - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt(model, d) - @test results["ElectricLoad"]["annual_calculated_kwh"] ≈ 8760 - end + @testset "MPC Multi-node" begin + # not doing much yet; just testing that two identical sites have the same costs + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + ps = MPCInputs[] + push!(ps, MPCInputs("./scenarios/mpc_multinode1.json")); + push!(ps, MPCInputs("./scenarios/mpc_multinode2.json")); + r = run_mpc(model, ps) + @test r[1]["Costs"] ≈ r[2]["Costs"] + end - try - rm("Highs.log", force=true) - catch - @warn "Could not delete test/Highs.log" - end + @testset "Complex Incentives" begin + """ + This test was compared against the API test: + reo.tests.test_reopt_url.EntryResourceTest.test_complex_incentives + when using the hardcoded levelization_factor in this package's REoptInputs function. + The two LCC's matched within 0.00005%. (The Julia pkg LCC is 1.0971991e7) + """ + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(model, "./scenarios/incentives.json") + @test results["Financial"]["lcc"] ≈ 1.096852612e7 atol=1e4 + end - @testset "AVERT region abberviations" begin - """ - This test checks 5 scenarios (in order) - 1. Coordinate pair inside an AVERT polygon - 2. Coordinate pair near a US border - 3. Coordinate pair < 5 miles from US border - 4. Coordinate pair > 5 miles from US border - 5. Coordinate pair >> 5 miles from US border - """ - (r, d) = REopt.avert_region_abbreviation(65.27661752129738, -149.59278391820223) - @test r == "AKGD" - (r, d) = REopt.avert_region_abbreviation(21.45440792261567, -157.93648793163402) - @test r == "HIOA" - (r, d) = REopt.avert_region_abbreviation(19.686877556659436, -155.4223641905743) - @test r == "HIMS" - (r, d) = REopt.avert_region_abbreviation(39.86357200140234, -104.67953917092028) - @test r == "RM" - @test d ≈ 0.0 atol=1 - (r, d) = REopt.avert_region_abbreviation(47.49137892652077, -69.3240287592685) - @test r == "NE" - @test d ≈ 7986 atol=1 - (r, d) = REopt.avert_region_abbreviation(47.50448307102053, -69.34882434376593) - @test r === nothing - @test d ≈ 10297 atol=1 - (r, d) = REopt.avert_region_abbreviation(55.860334445251354, -4.286554357755312) - @test r === nothing - end + @testset "Fifteen minute load" begin + d = JSON.parsefile("scenarios/no_techs.json") + d["ElectricLoad"] = Dict("loads_kw" => repeat([1.0], 35040)) + d["Settings"] = Dict("time_steps_per_hour" => 4) + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(model, d) + @test results["ElectricLoad"]["annual_calculated_kwh"] ≈ 8760 + end - @testset "PVspecs" begin - ## Scenario 1: Palmdale, CA; array-type = 0 (Ground-mount) - post_name = "pv.json" - post = JSON.parsefile("./scenarios/$post_name") - scen = Scenario(post) - @test scen.pvs[1].tilt ≈ 20 - @test scen.pvs[1].azimuth ≈ 180 - - ## Scenario 2: Palmdale, CA; array-type = 1 (roof) - post["PV"]["array_type"] = 1 - scen = Scenario(post) - - @test scen.pvs[1].tilt ≈ 20 # Correct tilt value for array_type = 1 - - ## Scenario 3: Palmdale, CA; array-type = 2 (axis-tracking) - post["PV"]["array_type"] = 2 - scen = Scenario(post) - - @test scen.pvs[1].tilt ≈ 0 # Correct tilt value for array_type = 2 - - ## Scenario 4: Cape Town; array-type = 0 (ground) - post["Site"]["latitude"] = -33.974732 - post["Site"]["longitude"] = 19.130050 - post["PV"]["array_type"] = 0 - scen = Scenario(post) - - @test scen.pvs[1].tilt ≈ 20 - @test scen.pvs[1].azimuth ≈ 0 - @test sum(scen.electric_utility.emissions_factor_series_lb_CO2_per_kwh) ≈ 0 - - ## Scenario 4:Cape Town; array-type = 0 (ground); user-provided tilt (should not get overwritten) - post["PV"]["tilt"] = 17 - scen = Scenario(post) - @test scen.pvs[1].tilt ≈ 17 - - - end - - - @testset "AlternativeFlatLoads" begin - input_data = JSON.parsefile("./scenarios/flatloads.json") - s = Scenario(input_data) - inputs = REoptInputs(s) - - # FlatLoad_8_5 => 8 hrs/day, 5 days/week, 52 weeks/year - active_hours_8_5 = 8 * 5 * 52 - @test count(x->x>0, s.space_heating_load.loads_kw, dims=1)[1] == active_hours_8_5 - # FlatLoad_16_7 => only hours 6-22 should be >0, and each day is the same portion of the total year - @test sum(s.electric_load.loads_kw[1:5]) + sum(s.electric_load.loads_kw[23:24]) == 0.0 - @test sum(s.electric_load.loads_kw[6:22]) / sum(s.electric_load.loads_kw) - 1/365 ≈ 0.0 atol=0.000001 - end - - # removed Wind test for two reasons - # 1. reduce WindToolKit calls in tests - # 2. HiGHS does not support SOS or indicator constraints, which are needed for export constraints - - @testset "Simulated load function consistency with REoptInputs.s (Scenario)" begin - """ - - This tests the consistency between getting DOE commercial reference building (CRB) load data - from the simulated_load function and the processing of REoptInputs.s (Scenario struct). - - The simulated_load function is used for the /simulated_load endpoint in the REopt API, - in particular for the webtool/UI to display loads before running REopt, but is also generally - an external way to access CRB load data without running REopt. + try + rm("Highs.log", force=true) + catch + @warn "Could not delete test/Highs.log" + end - One particular test specifically for the webtool/UI is for the heating load because there is just a - single heating load instead of separated space heating and domestic hot water loads. - - """ - input_data = JSON.parsefile("./scenarios/simulated_load.json") + @testset "AVERT region abberviations" begin + """ + This test checks 5 scenarios (in order) + 1. Coordinate pair inside an AVERT polygon + 2. Coordinate pair near a US border + 3. Coordinate pair < 5 miles from US border + 4. Coordinate pair > 5 miles from US border + 5. Coordinate pair >> 5 miles from US border + """ + (r, d) = REopt.avert_region_abbreviation(65.27661752129738, -149.59278391820223) + @test r == "AKGD" + (r, d) = REopt.avert_region_abbreviation(21.45440792261567, -157.93648793163402) + @test r == "HIOA" + (r, d) = REopt.avert_region_abbreviation(19.686877556659436, -155.4223641905743) + @test r == "HIMS" + (r, d) = REopt.avert_region_abbreviation(39.86357200140234, -104.67953917092028) + @test r == "RM" + @test d ≈ 0.0 atol=1 + (r, d) = REopt.avert_region_abbreviation(47.49137892652077, -69.3240287592685) + @test r == "NE" + @test d ≈ 7986 atol=1 + (r, d) = REopt.avert_region_abbreviation(47.50448307102053, -69.34882434376593) + @test r === nothing + @test d ≈ 10297 atol=1 + (r, d) = REopt.avert_region_abbreviation(55.860334445251354, -4.286554357755312) + @test r === nothing + end - input_data["ElectricLoad"] = Dict([("blended_doe_reference_names", ["Hospital", "FlatLoad_16_5"]), - ("blended_doe_reference_percents", [0.2, 0.8]) - ]) - - input_data["CoolingLoad"] = Dict([("blended_doe_reference_names", ["Warehouse", "FlatLoad"]), - ("blended_doe_reference_percents", [0.5, 0.5]) - ]) - - # Heating load from the UI will call the /simulated_load endpoint first to parse single heating mmbtu into separate Space and DHW mmbtu - annual_mmbtu_hvac = 7000.0 - annual_mmbtu_process = 3000.0 - doe_reference_name_heating = ["Warehouse", "FlatLoad"] - percent_share_heating = [0.3, 0.7] + @testset "PVspecs" begin + ## Scenario 1: Palmdale, CA; array-type = 0 (Ground-mount) + post_name = "pv.json" + post = JSON.parsefile("./scenarios/$post_name") + scen = Scenario(post) + @test scen.pvs[1].tilt ≈ 20 + @test scen.pvs[1].azimuth ≈ 180 - d_sim_load_heating = Dict([("latitude", input_data["Site"]["latitude"]), - ("longitude", input_data["Site"]["longitude"]), - ("load_type", "heating"), # since annual_tonhour is not given - ("doe_reference_name", doe_reference_name_heating), - ("percent_share", percent_share_heating), - ("annual_mmbtu", annual_mmbtu_hvac) - ]) + ## Scenario 2: Palmdale, CA; array-type = 1 (roof) + post["PV"]["array_type"] = 1 + scen = Scenario(post) - sim_load_response_heating = simulated_load(d_sim_load_heating) + @test scen.pvs[1].tilt ≈ 20 # Correct tilt value for array_type = 1 - d_sim_load_process = copy(d_sim_load_heating) - d_sim_load_process["load_type"] = "process_heat" - d_sim_load_process["annual_mmbtu"] = annual_mmbtu_process - sim_load_response_process = simulated_load(d_sim_load_process) + ## Scenario 3: Palmdale, CA; array-type = 2 (axis-tracking) + post["PV"]["array_type"] = 2 + scen = Scenario(post) - input_data["SpaceHeatingLoad"] = Dict([("blended_doe_reference_names", doe_reference_name_heating), - ("blended_doe_reference_percents", percent_share_heating), - ("annual_mmbtu", sim_load_response_heating["space_annual_mmbtu"]) - ]) + @test scen.pvs[1].tilt ≈ 0 # Correct tilt value for array_type = 2 - input_data["DomesticHotWaterLoad"] = Dict([("blended_doe_reference_names", doe_reference_name_heating), - ("blended_doe_reference_percents", percent_share_heating), - ("annual_mmbtu", sim_load_response_heating["dhw_annual_mmbtu"]) - ]) + ## Scenario 4: Cape Town; array-type = 0 (ground) + post["Site"]["latitude"] = -33.974732 + post["Site"]["longitude"] = 19.130050 + post["PV"]["array_type"] = 0 + scen = Scenario(post) - input_data["ProcessHeatLoad"] = Dict([("blended_industry_reference_names", doe_reference_name_heating), - ("blended_industry_reference_percents", percent_share_heating), - ("annual_mmbtu", annual_mmbtu_process) - ]) - - s = Scenario(input_data) - inputs = REoptInputs(s) + @test scen.pvs[1].tilt ≈ 20 + @test scen.pvs[1].azimuth ≈ 0 + @test sum(scen.electric_utility.emissions_factor_series_lb_CO2_per_kwh) ≈ 0 + + ## Scenario 4:Cape Town; array-type = 0 (ground); user-provided tilt (should not get overwritten) + post["PV"]["tilt"] = 17 + scen = Scenario(post) + @test scen.pvs[1].tilt ≈ 17 - # Call simulated_load function to check cooling - d_sim_load_elec_and_cooling = Dict([("latitude", input_data["Site"]["latitude"]), - ("longitude", input_data["Site"]["longitude"]), - ("load_type", "electric"), # since annual_tonhour is not given - ("doe_reference_name", input_data["ElectricLoad"]["blended_doe_reference_names"]), - ("percent_share", input_data["ElectricLoad"]["blended_doe_reference_percents"]), - ("cooling_doe_ref_name", input_data["CoolingLoad"]["blended_doe_reference_names"]), - ("cooling_pct_share", input_data["CoolingLoad"]["blended_doe_reference_percents"]), - ]) - sim_load_response_elec_and_cooling = simulated_load(d_sim_load_elec_and_cooling) - sim_electric_kw = sim_load_response_elec_and_cooling["loads_kw"] - sim_cooling_ton = sim_load_response_elec_and_cooling["cooling_defaults"]["loads_ton"] + end - total_heating_thermal_load_reopt_inputs = (s.space_heating_load.loads_kw + s.dhw_load.loads_kw + s.process_heat_load.loads_kw) ./ REopt.KWH_PER_MMBTU ./ REopt.EXISTING_BOILER_EFFICIENCY + + @testset "AlternativeFlatLoads" begin + input_data = JSON.parsefile("./scenarios/flatloads.json") + s = Scenario(input_data) + inputs = REoptInputs(s) + + # FlatLoad_8_5 => 8 hrs/day, 5 days/week, 52 weeks/year + active_hours_8_5 = 8 * 5 * 52 + @test count(x->x>0, s.space_heating_load.loads_kw, dims=1)[1] == active_hours_8_5 + # FlatLoad_16_7 => only hours 6-22 should be >0, and each day is the same portion of the total year + @test sum(s.electric_load.loads_kw[1:5]) + sum(s.electric_load.loads_kw[23:24]) == 0.0 + @test sum(s.electric_load.loads_kw[6:22]) / sum(s.electric_load.loads_kw) - 1/365 ≈ 0.0 atol=0.000001 + end - @test round.(sim_load_response_heating["loads_mmbtu_per_hour"] + - sim_load_response_process["loads_mmbtu_per_hour"], digits=2) ≈ - round.(total_heating_thermal_load_reopt_inputs, digits=2) rtol=0.02 + # removed Wind test for two reasons + # 1. reduce WindToolKit calls in tests + # 2. HiGHS does not support SOS or indicator constraints, which are needed for export constraints - @test sim_electric_kw ≈ s.electric_load.loads_kw atol=0.1 - @test sim_cooling_ton ≈ s.cooling_load.loads_kw_thermal ./ REopt.KWH_THERMAL_PER_TONHOUR atol=0.1 - end + @testset "Simulated load function consistency with REoptInputs.s (Scenario)" begin + """ - @testset "Backup Generator Reliability" begin - - @testset "Compare backup_reliability and simulate_outages" begin - # Tests ensure `backup_reliability()` consistent with `simulate_outages()` - # First, just battery - reopt_inputs = Dict( - "Site" => Dict( - "longitude" => -106.42077256104001, - "latitude" => 31.810468380036337 - ), - "ElectricStorage" => Dict( - "min_kw" => 4000, - "max_kw" => 4000, - "min_kwh" => 400000, - "max_kwh" => 400000, - "soc_min_fraction" => 0.8, - "soc_init_fraction" => 0.9 - ), - "ElectricLoad" => Dict( - "doe_reference_name" => "FlatLoad", - "annual_kwh" => 175200000.0, - "critical_load_fraction" => 0.2 - ), - "ElectricTariff" => Dict( - "urdb_label" => "5ed6c1a15457a3367add15ae" - ), - ) - p = REoptInputs(reopt_inputs) - model = Model(optimizer_with_attributes(HiGHS.Optimizer,"output_flag" => false, "log_to_console" => false)) - results = run_reopt(model, p) - simresults = simulate_outages(results, p) + This tests the consistency between getting DOE commercial reference building (CRB) load data + from the simulated_load function and the processing of REoptInputs.s (Scenario struct). + + The simulated_load function is used for the /simulated_load endpoint in the REopt API, + in particular for the webtool/UI to display loads before running REopt, but is also generally + an external way to access CRB load data without running REopt. + + One particular test specifically for the webtool/UI is for the heating load because there is just a + single heating load instead of separated space heating and domestic hot water loads. + + """ + input_data = JSON.parsefile("./scenarios/simulated_load.json") + + input_data["ElectricLoad"] = Dict([("blended_doe_reference_names", ["Hospital", "FlatLoad_16_5"]), + ("blended_doe_reference_percents", [0.2, 0.8]) + ]) + + input_data["CoolingLoad"] = Dict([("blended_doe_reference_names", ["Warehouse", "FlatLoad"]), + ("blended_doe_reference_percents", [0.5, 0.5]) + ]) + + # Heating load from the UI will call the /simulated_load endpoint first to parse single heating mmbtu into separate Space and DHW mmbtu + annual_mmbtu_hvac = 7000.0 + annual_mmbtu_process = 3000.0 + doe_reference_name_heating = ["Warehouse", "FlatLoad"] + percent_share_heating = [0.3, 0.7] + + d_sim_load_heating = Dict([("latitude", input_data["Site"]["latitude"]), + ("longitude", input_data["Site"]["longitude"]), + ("load_type", "heating"), # since annual_tonhour is not given + ("doe_reference_name", doe_reference_name_heating), + ("percent_share", percent_share_heating), + ("annual_mmbtu", annual_mmbtu_hvac) + ]) + + sim_load_response_heating = simulated_load(d_sim_load_heating) + + d_sim_load_process = copy(d_sim_load_heating) + d_sim_load_process["load_type"] = "process_heat" + d_sim_load_process["annual_mmbtu"] = annual_mmbtu_process + sim_load_response_process = simulated_load(d_sim_load_process) + + input_data["SpaceHeatingLoad"] = Dict([("blended_doe_reference_names", doe_reference_name_heating), + ("blended_doe_reference_percents", percent_share_heating), + ("annual_mmbtu", sim_load_response_heating["space_annual_mmbtu"]) + ]) + + input_data["DomesticHotWaterLoad"] = Dict([("blended_doe_reference_names", doe_reference_name_heating), + ("blended_doe_reference_percents", percent_share_heating), + ("annual_mmbtu", sim_load_response_heating["dhw_annual_mmbtu"]) + ]) + + input_data["ProcessHeatLoad"] = Dict([("blended_industry_reference_names", doe_reference_name_heating), + ("blended_industry_reference_percents", percent_share_heating), + ("annual_mmbtu", annual_mmbtu_process) + ]) + + s = Scenario(input_data) + inputs = REoptInputs(s) + + # Call simulated_load function to check cooling + d_sim_load_elec_and_cooling = Dict([("latitude", input_data["Site"]["latitude"]), + ("longitude", input_data["Site"]["longitude"]), + ("load_type", "electric"), # since annual_tonhour is not given + ("doe_reference_name", input_data["ElectricLoad"]["blended_doe_reference_names"]), + ("percent_share", input_data["ElectricLoad"]["blended_doe_reference_percents"]), + ("cooling_doe_ref_name", input_data["CoolingLoad"]["blended_doe_reference_names"]), + ("cooling_pct_share", input_data["CoolingLoad"]["blended_doe_reference_percents"]), + ]) + + sim_load_response_elec_and_cooling = simulated_load(d_sim_load_elec_and_cooling) + sim_electric_kw = sim_load_response_elec_and_cooling["loads_kw"] + sim_cooling_ton = sim_load_response_elec_and_cooling["cooling_defaults"]["loads_ton"] + + total_heating_thermal_load_reopt_inputs = (s.space_heating_load.loads_kw + s.dhw_load.loads_kw + s.process_heat_load.loads_kw) ./ REopt.KWH_PER_MMBTU ./ REopt.EXISTING_BOILER_EFFICIENCY + + @test round.(sim_load_response_heating["loads_mmbtu_per_hour"] + + sim_load_response_process["loads_mmbtu_per_hour"], digits=2) ≈ + round.(total_heating_thermal_load_reopt_inputs, digits=2) rtol=0.02 + + @test sim_electric_kw ≈ s.electric_load.loads_kw atol=0.1 + @test sim_cooling_ton ≈ s.cooling_load.loads_kw_thermal ./ REopt.KWH_THERMAL_PER_TONHOUR atol=0.1 + end + @testset verbose=true "Backup Generator Reliability" begin + + @testset "Compare backup_reliability and simulate_outages" begin + # Tests ensure `backup_reliability()` consistent with `simulate_outages()` + # First, just battery + reopt_inputs = Dict( + "Site" => Dict( + "longitude" => -106.42077256104001, + "latitude" => 31.810468380036337 + ), + "ElectricStorage" => Dict( + "min_kw" => 4000, + "max_kw" => 4000, + "min_kwh" => 400000, + "max_kwh" => 400000, + "soc_min_fraction" => 0.8, + "soc_init_fraction" => 0.9 + ), + "ElectricLoad" => Dict( + "doe_reference_name" => "FlatLoad", + "annual_kwh" => 175200000.0, + "critical_load_fraction" => 0.2 + ), + "ElectricTariff" => Dict( + "urdb_label" => "5ed6c1a15457a3367add15ae" + ), + ) + p = REoptInputs(reopt_inputs) + model = Model(optimizer_with_attributes(HiGHS.Optimizer,"output_flag" => false, "log_to_console" => false)) + results = run_reopt(model, p) + simresults = simulate_outages(results, p) + + reliability_inputs = Dict( + "generator_size_kw" => 0, + "max_outage_duration" => 100, + "generator_operational_availability" => 1.0, + "generator_failure_to_start" => 0.0, + "generator_mean_time_to_failure" => 10000000000, + "fuel_limit" => 0, + "battery_size_kw" => 4000, + "battery_size_kwh" => 400000, + "battery_charge_efficiency" => 1, + "battery_discharge_efficiency" => 1, + "battery_operational_availability" => 1.0, + "battery_minimum_soc_fraction" => 0.0, + "battery_starting_soc_series_fraction" => results["ElectricStorage"]["soc_series_fraction"], + "critical_loads_kw" => results["ElectricLoad"]["critical_load_series_kw"]#4000*ones(8760)#p.s.electric_load.critical_loads_kw + ) + reliability_results = backup_reliability(reliability_inputs) + + #TODO: resolve bug where unlimted fuel markov portion of results goes to zero 1 timestep early + for i = 1:99#min(length(simresults["probs_of_surviving"]), reliability_inputs["max_outage_duration"]) + @test simresults["probs_of_surviving"][i] ≈ reliability_results["mean_cumulative_survival_by_duration"][i] atol=0.01 + @test simresults["probs_of_surviving"][i] ≈ reliability_results["unlimited_fuel_mean_cumulative_survival_by_duration"][i] atol=0.01 + @test simresults["probs_of_surviving"][i] ≈ reliability_results["mean_fuel_survival_by_duration"][i] atol=0.01 + end + + # Second, gen, PV, Wind, battery + reopt_inputs = JSON.parsefile("./scenarios/backup_reliability_reopt_inputs.json") + reopt_inputs["ElectricLoad"]["annual_kwh"] = 4*reopt_inputs["ElectricLoad"]["annual_kwh"] + p = REoptInputs(reopt_inputs) + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(model, p) + simresults = simulate_outages(results, p) + reliability_inputs = Dict( + "max_outage_duration" => 48, + "generator_operational_availability" => 1.0, + "generator_failure_to_start" => 0.0, + "generator_mean_time_to_failure" => 10000000000, + "fuel_limit" => 1000000000, + "battery_operational_availability" => 1.0, + "battery_minimum_soc_fraction" => 0.0, + "pv_operational_availability" => 1.0, + "wind_operational_availability" => 1.0 + ) + reliability_results = backup_reliability(results, p, reliability_inputs) + for i = 1:min(length(simresults["probs_of_surviving"]), reliability_inputs["max_outage_duration"]) + @test simresults["probs_of_surviving"][i] ≈ reliability_results["mean_cumulative_survival_by_duration"][i] atol=0.001 + end + end + + # Test survival with no generator decreasing and same as with generator but no fuel reliability_inputs = Dict( - "generator_size_kw" => 0, - "max_outage_duration" => 100, - "generator_operational_availability" => 1.0, - "generator_failure_to_start" => 0.0, - "generator_mean_time_to_failure" => 10000000000, - "fuel_limit" => 0, - "battery_size_kw" => 4000, - "battery_size_kwh" => 400000, + "critical_loads_kw" => 200 .* (2 .+ sin.(collect(1:8760)*2*pi/24)), + "num_generators" => 0, + "generator_size_kw" => 312.0, + "fuel_limit" => 0.0, + "max_outage_duration" => 10, + "battery_size_kw" => 428.0, + "battery_size_kwh" => 1585.0, + "num_battery_bins" => 5 + ) + reliability_results1 = backup_reliability(reliability_inputs) + reliability_inputs["generator_size_kw"] = 0 + reliability_inputs["fuel_limit"] = 1e10 + reliability_results2 = backup_reliability(reliability_inputs) + for i in 1:reliability_inputs["max_outage_duration"] + if i != 1 + @test reliability_results1["mean_fuel_survival_by_duration"][i] <= reliability_results1["mean_fuel_survival_by_duration"][i-1] + @test reliability_results1["mean_cumulative_survival_by_duration"][i] <= reliability_results1["mean_cumulative_survival_by_duration"][i-1] + end + @test reliability_results2["mean_fuel_survival_by_duration"][i] == reliability_results1["mean_fuel_survival_by_duration"][i] + end + + #test fuel limit + input_dict = JSON.parsefile("./scenarios/erp_fuel_limit_inputs.json") + results = backup_reliability(input_dict) + @test results["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 1 + @test results["cumulative_survival_final_time_step"][1] ≈ 1 + + input_dict = Dict( + "critical_loads_kw" => [1,2,2,1], + "battery_starting_soc_series_fraction" => [0.75,0.75,0.75,0.75], + "max_outage_duration" => 3, + "num_generators" => 2, "generator_size_kw" => 1, + "generator_operational_availability" => 1, + "generator_failure_to_start" => 0.0, + "generator_mean_time_to_failure" => 5, + "battery_operational_availability" => 1, + "num_battery_bins" => 3, + "battery_size_kwh" => 4, + "battery_size_kw" => 1, "battery_charge_efficiency" => 1, "battery_discharge_efficiency" => 1, + "battery_minimum_soc_fraction" => 0.5) + + + #Given outage starts in time period 1 + #____________________________________ + #Outage hour 1: + #2 generators: Prob = 0.64, Battery = 2, Survived + #1 generator: Prob = 0.32, Battery = 1, Survived + #0 generator: Prob = 0.04, Battery = 0, Survived + #Survival Probability 1.0 + + #Outage hour 2: + #2 generators: Prob = 0.4096, Battery = 2, Survived + #2 gen -> 1 gen: Prob = 0.2048, Battery = 1, Survived + #1 gen -> 1 gen: Prob = 0.256, Battery = 0, Survived + #0 generators: Prob = 0.1296, Battery = -1, Failed + #Survival Probability: 0.8704 + + #Outage hour 3: + #2 generators: Prob = 0.262144, Battery = 0, Survived + #2 gen -> 2 -> 1 Prob = 0.131072, Battery = 1, Survived + #2 gen -> 1 -> 1 Prob = 0.16384, Battery = 0, Survived + #1 gen -> 1 -> 1 Prob = 0.2048, Battery = -1, Failed + #0 generators Prob = 0.238144, Battery = -1, Failed + #Survival Probability: 0.557056 + @test backup_reliability(input_dict)["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 0.557056 + + #Test multiple generator types + input_dict = Dict( + "critical_loads_kw" => [1,2,2,1], + "battery_starting_soc_series_fraction" => [0.5,0.5,0.5,0.5], + "max_outage_duration" => 3, + "num_generators" => [1,1], + "generator_size_kw" => [1,1], + "generator_operational_availability" => [1,1], + "generator_failure_to_start" => [0.0, 0.0], + "generator_mean_time_to_failure" => [5, 5], "battery_operational_availability" => 1.0, - "battery_minimum_soc_fraction" => 0.0, - "battery_starting_soc_series_fraction" => results["ElectricStorage"]["soc_series_fraction"], - "critical_loads_kw" => results["ElectricLoad"]["critical_load_series_kw"]#4000*ones(8760)#p.s.electric_load.critical_loads_kw - ) - reliability_results = backup_reliability(reliability_inputs) + "num_battery_bins" => 3, + "battery_size_kwh" => 2, + "battery_size_kw" => 1, + "battery_charge_efficiency" => 1, + "battery_discharge_efficiency" => 1, + "battery_minimum_soc_fraction" => 0) + + @test backup_reliability(input_dict)["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 0.557056 + + #8760 of flat load. Battery can survive 4 hours. + #Survival after 24 hours should be chance of generator surviving 20 or more hours + input_dict = Dict( + "critical_loads_kw" => 100 .* ones(8760), + "max_outage_duration" => 24, + "num_generators" => 1, + "generator_size_kw" => 100, + "generator_operational_availability" => 0.98, + "generator_failure_to_start" => 0.1, + "generator_mean_time_to_failure" => 100, + "battery_operational_availability" => 1.0, + "num_battery_bins" => 101, + "battery_size_kwh" => 400, + "battery_size_kw" => 100, + "battery_charge_efficiency" => 1, + "battery_discharge_efficiency" => 1, + "battery_minimum_soc_fraction" => 0) - #TODO: resolve bug where unlimted fuel markov portion of results goes to zero 1 timestep early - for i = 1:99#min(length(simresults["probs_of_surviving"]), reliability_inputs["max_outage_duration"]) - @test simresults["probs_of_surviving"][i] ≈ reliability_results["mean_cumulative_survival_by_duration"][i] atol=0.01 - @test simresults["probs_of_surviving"][i] ≈ reliability_results["unlimited_fuel_mean_cumulative_survival_by_duration"][i] atol=0.01 - @test simresults["probs_of_surviving"][i] ≈ reliability_results["mean_fuel_survival_by_duration"][i] atol=0.01 - end + reliability_results = backup_reliability(input_dict) + @test reliability_results["unlimited_fuel_mean_cumulative_survival_by_duration"][24] ≈ (0.99^20)*(0.9*0.98) atol=0.00001 - # Second, gen, PV, Wind, battery - reopt_inputs = JSON.parsefile("./scenarios/backup_reliability_reopt_inputs.json") - reopt_inputs["ElectricLoad"]["annual_kwh"] = 4*reopt_inputs["ElectricLoad"]["annual_kwh"] - p = REoptInputs(reopt_inputs) - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt(model, p) - simresults = simulate_outages(results, p) - reliability_inputs = Dict( - "max_outage_duration" => 48, - "generator_operational_availability" => 1.0, - "generator_failure_to_start" => 0.0, - "generator_mean_time_to_failure" => 10000000000, - "fuel_limit" => 1000000000, - "battery_operational_availability" => 1.0, - "battery_minimum_soc_fraction" => 0.0, - "pv_operational_availability" => 1.0, - "wind_operational_availability" => 1.0 - ) - reliability_results = backup_reliability(results, p, reliability_inputs) - for i = 1:min(length(simresults["probs_of_surviving"]), reliability_inputs["max_outage_duration"]) - @test simresults["probs_of_surviving"][i] ≈ reliability_results["mean_cumulative_survival_by_duration"][i] atol=0.001 + #More complex case of hospital load with 2 generators, PV, wind, and battery + reliability_inputs = JSON.parsefile("./scenarios/backup_reliability_inputs.json") + reliability_results = backup_reliability(reliability_inputs) + @test reliability_results["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 0.858756 atol=0.0001 + @test reliability_results["cumulative_survival_final_time_step"][1] ≈ 0.858756 atol=0.0001 + @test reliability_results["mean_cumulative_survival_final_time_step"] ≈ 0.904242 atol=0.0001#0.833224 + + # Test gens+pv+wind+batt with 3 arg version of backup_reliability + # Attention! REopt optimization results are presaved in erp_gens_batt_pv_wind_reopt_results.json + # If you modify backup_reliability_reopt_inputs.json, you must add this before JSON.parsefile: + # results = run_reopt(model, p) + # open("scenarios/erp_gens_batt_pv_wind_reopt_results.json","w") do f + # JSON.print(f, results, 4) + # end + for input_key in [ + "generator_size_kw", + "battery_size_kw", + "battery_size_kwh", + "pv_size_kw", + "wind_size_kw", + "critical_loads_kw", + "pv_production_factor_series", + "wind_production_factor_series" + ] + delete!(reliability_inputs, input_key) end - end + # note: the wind prod series in backup_reliability_reopt_inputs.json is actually a PV profile (to in order to test a wind scenario that should give same results as an existing PV one) + p = REoptInputs("./scenarios/backup_reliability_reopt_inputs.json") + results = JSON.parsefile("./scenarios/erp_gens_batt_pv_wind_reopt_results.json") + reliability_results = backup_reliability(results, p, reliability_inputs) - # Test survival with no generator decreasing and same as with generator but no fuel - reliability_inputs = Dict( - "critical_loads_kw" => 200 .* (2 .+ sin.(collect(1:8760)*2*pi/24)), - "num_generators" => 0, - "generator_size_kw" => 312.0, - "fuel_limit" => 0.0, - "max_outage_duration" => 10, - "battery_size_kw" => 428.0, - "battery_size_kwh" => 1585.0, - "num_battery_bins" => 5 - ) - reliability_results1 = backup_reliability(reliability_inputs) - reliability_inputs["generator_size_kw"] = 0 - reliability_inputs["fuel_limit"] = 1e10 - reliability_results2 = backup_reliability(reliability_inputs) - for i in 1:reliability_inputs["max_outage_duration"] - if i != 1 - @test reliability_results1["mean_fuel_survival_by_duration"][i] <= reliability_results1["mean_fuel_survival_by_duration"][i-1] - @test reliability_results1["mean_cumulative_survival_by_duration"][i] <= reliability_results1["mean_cumulative_survival_by_duration"][i-1] + @test reliability_results["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 0.802997 atol=0.0001 + @test reliability_results["cumulative_survival_final_time_step"][1] ≈ 0.802997 atol=0.0001 + @test reliability_results["mean_cumulative_survival_final_time_step"] ≈ 0.817586 atol=0.001 + end + + @testset verbose=true "Disaggregated Heating Loads" begin + @testset "Process Heat Load Inputs" begin + d = JSON.parsefile("./scenarios/electric_heater.json") + d["SpaceHeatingLoad"]["annual_mmbtu"] = 0.5 * 8760 + d["DomesticHotWaterLoad"]["annual_mmbtu"] = 0.5 * 8760 + d["ProcessHeatLoad"]["annual_mmbtu"] = 0.5 * 8760 + s = Scenario(d) + inputs = REoptInputs(s) + @test inputs.heating_loads_kw["ProcessHeat"][1] ≈ 117.228428 atol=1.0e-3 + end + @testset "Separate Heat Load Results" begin + d = JSON.parsefile("./scenarios/electric_heater.json") + d["SpaceHeatingLoad"]["annual_mmbtu"] = 0.5 * 8760 + d["DomesticHotWaterLoad"]["annual_mmbtu"] = 0.5 * 8760 + d["ProcessHeatLoad"]["annual_mmbtu"] = 0.5 * 8760 + d["ExistingBoiler"]["fuel_cost_per_mmbtu"] = 100 + d["ElectricHeater"]["installed_cost_per_mmbtu_per_hour"] = 1.0 + d["ElectricTariff"]["monthly_energy_rates"] = [0,0,0,0,0,0,0,0,0,0,0,0] + d["HotThermalStorage"]["max_gal"] = 0.0 + s = Scenario(d) + inputs = REoptInputs(s) + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(m, inputs) + @test sum(results["ExistingBoiler"]["thermal_to_dhw_load_series_mmbtu_per_hour"]) ≈ 0.0 atol=0.01 + @test sum(results["ExistingBoiler"]["thermal_to_space_heating_load_series_mmbtu_per_hour"]) ≈ 0.0 atol=0.01 + @test sum(results["ExistingBoiler"]["thermal_to_process_heat_load_series_mmbtu_per_hour"]) ≈ 0.8*4380.0 atol=0.01 + @test sum(results["ElectricHeater"]["thermal_to_dhw_load_series_mmbtu_per_hour"]) ≈ 0.8*4380.0 atol=0.01 + @test sum(results["ElectricHeater"]["thermal_to_space_heating_load_series_mmbtu_per_hour"]) ≈ 0.8*4380.0 atol=0.01 + @test sum(results["ElectricHeater"]["thermal_to_process_heat_load_series_mmbtu_per_hour"]) ≈ 0.0 atol=0.01 end - @test reliability_results2["mean_fuel_survival_by_duration"][i] == reliability_results1["mean_fuel_survival_by_duration"][i] end - #test fuel limit - input_dict = JSON.parsefile("./scenarios/erp_fuel_limit_inputs.json") - results = backup_reliability(input_dict) - @test results["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 1 - @test results["cumulative_survival_final_time_step"][1] ≈ 1 - - input_dict = Dict( - "critical_loads_kw" => [1,2,2,1], - "battery_starting_soc_series_fraction" => [0.75,0.75,0.75,0.75], - "max_outage_duration" => 3, - "num_generators" => 2, "generator_size_kw" => 1, - "generator_operational_availability" => 1, - "generator_failure_to_start" => 0.0, - "generator_mean_time_to_failure" => 5, - "battery_operational_availability" => 1, - "num_battery_bins" => 3, - "battery_size_kwh" => 4, - "battery_size_kw" => 1, - "battery_charge_efficiency" => 1, - "battery_discharge_efficiency" => 1, - "battery_minimum_soc_fraction" => 0.5) + @testset verbose=true "Net Metering" begin + @testset "Net Metering Limit and Wholesale" begin + #case 1: net metering limit is met by PV + d = JSON.parsefile("./scenarios/net_metering.json") + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(m, d) + @test results["PV"]["size_kw"] ≈ 30.0 atol=1e-3 - - #Given outage starts in time period 1 - #____________________________________ - #Outage hour 1: - #2 generators: Prob = 0.64, Battery = 2, Survived - #1 generator: Prob = 0.32, Battery = 1, Survived - #0 generator: Prob = 0.04, Battery = 0, Survived - #Survival Probability 1.0 - - #Outage hour 2: - #2 generators: Prob = 0.4096, Battery = 2, Survived - #2 gen -> 1 gen: Prob = 0.2048, Battery = 1, Survived - #1 gen -> 1 gen: Prob = 0.256, Battery = 0, Survived - #0 generators: Prob = 0.1296, Battery = -1, Failed - #Survival Probability: 0.8704 - - #Outage hour 3: - #2 generators: Prob = 0.262144, Battery = 0, Survived - #2 gen -> 2 -> 1 Prob = 0.131072, Battery = 1, Survived - #2 gen -> 1 -> 1 Prob = 0.16384, Battery = 0, Survived - #1 gen -> 1 -> 1 Prob = 0.2048, Battery = -1, Failed - #0 generators Prob = 0.238144, Battery = -1, Failed - #Survival Probability: 0.557056 - @test backup_reliability(input_dict)["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 0.557056 - - #Test multiple generator types - input_dict = Dict( - "critical_loads_kw" => [1,2,2,1], - "battery_starting_soc_series_fraction" => [0.5,0.5,0.5,0.5], - "max_outage_duration" => 3, - "num_generators" => [1,1], - "generator_size_kw" => [1,1], - "generator_operational_availability" => [1,1], - "generator_failure_to_start" => [0.0, 0.0], - "generator_mean_time_to_failure" => [5, 5], - "battery_operational_availability" => 1.0, - "num_battery_bins" => 3, - "battery_size_kwh" => 2, - "battery_size_kw" => 1, - "battery_charge_efficiency" => 1, - "battery_discharge_efficiency" => 1, - "battery_minimum_soc_fraction" => 0) - - @test backup_reliability(input_dict)["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 0.557056 - - #8760 of flat load. Battery can survive 4 hours. - #Survival after 24 hours should be chance of generator surviving 20 or more hours - input_dict = Dict( - "critical_loads_kw" => 100 .* ones(8760), - "max_outage_duration" => 24, - "num_generators" => 1, - "generator_size_kw" => 100, - "generator_operational_availability" => 0.98, - "generator_failure_to_start" => 0.1, - "generator_mean_time_to_failure" => 100, - "battery_operational_availability" => 1.0, - "num_battery_bins" => 101, - "battery_size_kwh" => 400, - "battery_size_kw" => 100, - "battery_charge_efficiency" => 1, - "battery_discharge_efficiency" => 1, - "battery_minimum_soc_fraction" => 0) - - reliability_results = backup_reliability(input_dict) - @test reliability_results["unlimited_fuel_mean_cumulative_survival_by_duration"][24] ≈ (0.99^20)*(0.9*0.98) atol=0.00001 - - #More complex case of hospital load with 2 generators, PV, wind, and battery - reliability_inputs = JSON.parsefile("./scenarios/backup_reliability_inputs.json") - reliability_results = backup_reliability(reliability_inputs) - @test reliability_results["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 0.858756 atol=0.0001 - @test reliability_results["cumulative_survival_final_time_step"][1] ≈ 0.858756 atol=0.0001 - @test reliability_results["mean_cumulative_survival_final_time_step"] ≈ 0.904242 atol=0.0001#0.833224 - - # Test gens+pv+wind+batt with 3 arg version of backup_reliability - # Attention! REopt optimization results are presaved in erp_gens_batt_pv_wind_reopt_results.json - # If you modify backup_reliability_reopt_inputs.json, you must add this before JSON.parsefile: - # results = run_reopt(model, p) - # open("scenarios/erp_gens_batt_pv_wind_reopt_results.json","w") do f - # JSON.print(f, results, 4) - # end - for input_key in [ - "generator_size_kw", - "battery_size_kw", - "battery_size_kwh", - "pv_size_kw", - "wind_size_kw", - "critical_loads_kw", - "pv_production_factor_series", - "wind_production_factor_series" - ] - delete!(reliability_inputs, input_key) - end - # note: the wind prod series in backup_reliability_reopt_inputs.json is actually a PV profile (to in order to test a wind scenario that should give same results as an existing PV one) - p = REoptInputs("./scenarios/backup_reliability_reopt_inputs.json") - results = JSON.parsefile("./scenarios/erp_gens_batt_pv_wind_reopt_results.json") - reliability_results = backup_reliability(results, p, reliability_inputs) - - @test reliability_results["unlimited_fuel_cumulative_survival_final_time_step"][1] ≈ 0.802997 atol=0.0001 - @test reliability_results["cumulative_survival_final_time_step"][1] ≈ 0.802997 atol=0.0001 - @test reliability_results["mean_cumulative_survival_final_time_step"] ≈ 0.817586 atol=0.001 - end - - @testset "Disaggregated Heating Loads" begin - @testset "Process Heat Load Inputs" begin - d = JSON.parsefile("./scenarios/electric_heater.json") - d["SpaceHeatingLoad"]["annual_mmbtu"] = 0.5 * 8760 - d["DomesticHotWaterLoad"]["annual_mmbtu"] = 0.5 * 8760 - d["ProcessHeatLoad"]["annual_mmbtu"] = 0.5 * 8760 - s = Scenario(d) - inputs = REoptInputs(s) - @test inputs.heating_loads_kw["ProcessHeat"][1] ≈ 117.228428 atol=1.0e-3 - end - @testset "Separate Heat Load Results" begin - d = JSON.parsefile("./scenarios/electric_heater.json") - d["SpaceHeatingLoad"]["annual_mmbtu"] = 0.5 * 8760 - d["DomesticHotWaterLoad"]["annual_mmbtu"] = 0.5 * 8760 - d["ProcessHeatLoad"]["annual_mmbtu"] = 0.5 * 8760 - d["ExistingBoiler"]["fuel_cost_per_mmbtu"] = 100 - d["ElectricHeater"]["installed_cost_per_mmbtu_per_hour"] = 1.0 - d["ElectricTariff"]["monthly_energy_rates"] = [0,0,0,0,0,0,0,0,0,0,0,0] - d["HotThermalStorage"]["max_gal"] = 0.0 - s = Scenario(d) - inputs = REoptInputs(s) - m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt(m, inputs) - @test sum(results["ExistingBoiler"]["thermal_to_dhw_load_series_mmbtu_per_hour"]) ≈ 0.0 atol=0.01 - @test sum(results["ExistingBoiler"]["thermal_to_space_heating_load_series_mmbtu_per_hour"]) ≈ 0.0 atol=0.01 - @test sum(results["ExistingBoiler"]["thermal_to_process_heat_load_series_mmbtu_per_hour"]) ≈ 0.8*4380.0 atol=0.01 - @test sum(results["ElectricHeater"]["thermal_to_dhw_load_series_mmbtu_per_hour"]) ≈ 0.8*4380.0 atol=0.01 - @test sum(results["ElectricHeater"]["thermal_to_space_heating_load_series_mmbtu_per_hour"]) ≈ 0.8*4380.0 atol=0.01 - @test sum(results["ElectricHeater"]["thermal_to_process_heat_load_series_mmbtu_per_hour"]) ≈ 0.0 atol=0.01 - end - end - - @testset "Net Metering" begin - @testset "Net Metering Limit and Wholesale" begin - #case 1: net metering limit is met by PV - d = JSON.parsefile("./scenarios/net_metering.json") - m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt(m, d) - @test results["PV"]["size_kw"] ≈ 30.0 atol=1e-3 - - #case 2: wholesale rate is high, big-M is met - d["ElectricTariff"]["wholesale_rate"] = 5.0 - d["PV"]["can_wholesale"] = true - m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt(m, d) - @test results["PV"]["size_kw"] ≈ 7440.0 atol=1e-3 #max benefit provides the upper bound - + #case 2: wholesale rate is high, big-M is met + d["ElectricTariff"]["wholesale_rate"] = 5.0 + d["PV"]["can_wholesale"] = true + m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt(m, d) + @test results["PV"]["size_kw"] ≈ 7440.0 atol=1e-3 #max benefit provides the upper bound + + end end - end - @testset "Imported Xpress Test Suite" begin @testset "Heating loads and addressable load fraction" begin # Default LargeOffice CRB with SpaceHeatingLoad and DomesticHotWaterLoad are served by ExistingBoiler m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) @@ -631,6 +669,19 @@ else # run HiGHS tests results = run_reopt(m, inputs) @test round(results["ExistingBoiler"]["annual_fuel_consumption_mmbtu"], digits=0) ≈ 8760 * (0.5 * 0.6 + 0.5 * 0.8 + 0.3 * 0.7) atol = 1.0 + # Test for unaddressable heating load fuel and emissions outputs + unaddressable = results["HeatingLoad"]["annual_total_unaddressable_heating_load_mmbtu"] + addressable = results["HeatingLoad"]["annual_calculated_total_heating_boiler_fuel_load_mmbtu"] + total = unaddressable + addressable + # Find the weighted average addressable_load_fraction from the fractions and loads above + weighted_avg_addressable_fraction = (0.5 * 0.6 + 0.5 * 0.8 + 0.3 * 0.7) / (0.5 + 0.5 + 0.3) + @test round(abs(addressable / total - weighted_avg_addressable_fraction), digits=3) == 0 + + unaddressable_emissions = results["HeatingLoad"]["annual_emissions_from_unaddressable_heating_load_tonnes_CO2"] + addressable_site_fuel_emissions = results["Site"]["annual_emissions_from_fuelburn_tonnes_CO2"] + total_site_emissions = unaddressable_emissions + addressable_site_fuel_emissions + @test round(abs(addressable_site_fuel_emissions / total_site_emissions - weighted_avg_addressable_fraction), digits=3) == 0 + # Monthly fuel load input with addressable_load_fraction is processed to expected thermal load data = JSON.parsefile("./scenarios/thermal_load.json") data["DomesticHotWaterLoad"]["monthly_mmbtu"] = repeat([100], 12) @@ -653,7 +704,7 @@ else # run HiGHS tests @test round(sum(s.process_heat_load.loads_kw) / REopt.KWH_PER_MMBTU) ≈ sum(process_thermal_load_expected) end - @testset "CHP" begin + @testset verbose=true "CHP" begin @testset "CHP Sizing" begin # Sizing CHP with non-constant efficiency, no cost curve, no unavailability_periods data_sizing = JSON.parsefile("./scenarios/chp_sizing.json") @@ -662,8 +713,8 @@ else # run HiGHS tests m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.01, "presolve" => "on")) results = run_reopt(m, inputs) - @test round(results["CHP"]["size_kw"], digits=0) ≈ 400.0 atol=50.0 - @test round(results["Financial"]["lcc"], digits=0) ≈ 1.3476e7 rtol=1.0e-2 + @test round(results["CHP"]["size_kw"], digits=0) ≈ 263.0 atol=50.0 + @test round(results["Financial"]["lcc"], digits=0) ≈ 1.11e7 rtol=0.05 end @testset "CHP Cost Curve and Min Allowable Size" begin @@ -831,9 +882,22 @@ else # run HiGHS tests results = run_reopt(m, d) @test sum(results["CHP"]["thermal_curtailed_series_mmbtu_per_hour"]) ≈ 4174.455 atol=1e-3 end + + @testset "CHP Proforma Metrics" begin + # This test compares the resulting simple payback period (years) for CHP to a proforma spreadsheet model which has been verified + # All financial parameters which influence this calc have been input to avoid breaking with changing defaults + input_data = JSON.parsefile("./scenarios/chp_payback.json") + s = Scenario(input_data) + inputs = REoptInputs(s) + + m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.01, "output_flag" => false, "log_to_console" => false)) + m2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "mip_rel_gap" => 0.01, "output_flag" => false, "log_to_console" => false)) + results = run_reopt([m1,m2], inputs) + @test abs(results["Financial"]["simple_payback_years"] - 8.12) <= 0.02 + end end - @testset "FlexibleHVAC" begin + @testset verbose=true "FlexibleHVAC" begin @testset "Single RC Model heating only" begin #= @@ -980,7 +1044,7 @@ else # run HiGHS tests m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) set_optimizer_attribute(m, "mip_rel_gap", 0.01) r = run_reopt(m, d) - @test occursin("not supported by the solver", string(r["Messages"]["errors"])) + @test occursin("are not supported by the solver", string(r["Messages"]["errors"])) || occursin("Unable to use IndicatorToMILPBridge", string(r["Messages"]["errors"])) # #optimal SOH at end of horizon is 80\% to prevent any replacement # @test sum(value.(m[:bmth_BkWh])) ≈ 0 atol=0.1 # # @test r["ElectricStorage"]["maintenance_cost"] ≈ 2972.66 atol=0.01 @@ -994,7 +1058,7 @@ else # run HiGHS tests m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) set_optimizer_attribute(m, "mip_rel_gap", 0.01) r = run_reopt(m, d) - @test occursin("not supported by the solver", string(r["Messages"]["errors"])) + @test occursin("are not supported by the solver", string(r["Messages"]["errors"])) || occursin("Unable to use IndicatorToMILPBridge", string(r["Messages"]["errors"])) # @test round(sum(r["ElectricStorage"]["soc_series_fraction"]), digits=2) / 8760 >= 0.7199 end @@ -1119,7 +1183,7 @@ else # run HiGHS tests @test results["Financial"]["lcc"] ≈ 1.094596365e7 atol=5e4 end - @testset verbose = true "Rate Structures" begin + @testset verbose=true "Rate Structures" begin @testset "Tiered Energy" begin m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) @@ -1208,12 +1272,16 @@ else # run HiGHS tests end @testset "Custom URDB with Sub-Hourly" begin - # Testing a 15-min post with a urdb_response with multiple n_energy_tiers - model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) - p = REoptInputs("./scenarios/subhourly_with_urdb.json") - results = run_reopt(model, p) - @test length(p.s.electric_tariff.export_rates[:WHL]) ≈ 8760*4 - @test results["PV"]["size_kw"] ≈ p.s.pvs[1].existing_kw + # Avoid excessive JuMP warning messages about += with Expressions + logger = SimpleLogger() + with_logger(logger) do + # Testing a 15-min post with a urdb_response with multiple n_energy_tiers + model = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false, "mip_rel_gap" => 0.1)) + p = REoptInputs("./scenarios/subhourly_with_urdb.json") + results = run_reopt(model, p) + @test length(p.s.electric_tariff.export_rates[:WHL]) ≈ 8760*4 + @test results["PV"]["size_kw"] ≈ p.s.pvs[1].existing_kw + end end @testset "Multi-tier demand and energy rates" begin @@ -1307,24 +1375,27 @@ else # run HiGHS tests end @testset "Multiple PVs" begin - m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - m2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) - results = run_reopt([m1,m2], "./scenarios/multiple_pvs.json") - - ground_pv = results["PV"][findfirst(pv -> pv["name"] == "ground", results["PV"])] - roof_west = results["PV"][findfirst(pv -> pv["name"] == "roof_west", results["PV"])] - roof_east = results["PV"][findfirst(pv -> pv["name"] == "roof_east", results["PV"])] - - @test ground_pv["size_kw"] ≈ 15 atol=0.1 - @test roof_west["size_kw"] ≈ 7 atol=0.1 - @test roof_east["size_kw"] ≈ 4 atol=0.1 - @test ground_pv["lifecycle_om_cost_after_tax_bau"] ≈ 782.0 atol=0.1 - @test roof_west["lifecycle_om_cost_after_tax_bau"] ≈ 782.0 atol=0.1 - @test ground_pv["annual_energy_produced_kwh_bau"] ≈ 8933.09 atol=0.1 - @test roof_west["annual_energy_produced_kwh_bau"] ≈ 7656.11 atol=0.1 - @test ground_pv["annual_energy_produced_kwh"] ≈ 26799.26 atol=0.1 - @test roof_west["annual_energy_produced_kwh"] ≈ 10719.51 atol=0.1 - @test roof_east["annual_energy_produced_kwh"] ≈ 6685.95 atol=0.1 + logger = SimpleLogger() + with_logger(logger) do + m1 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + m2 = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) + results = run_reopt([m1,m2], "./scenarios/multiple_pvs.json") + + ground_pv = results["PV"][findfirst(pv -> pv["name"] == "ground", results["PV"])] + roof_west = results["PV"][findfirst(pv -> pv["name"] == "roof_west", results["PV"])] + roof_east = results["PV"][findfirst(pv -> pv["name"] == "roof_east", results["PV"])] + + @test ground_pv["size_kw"] ≈ 15 atol=0.1 + @test roof_west["size_kw"] ≈ 7 atol=0.1 + @test roof_east["size_kw"] ≈ 4 atol=0.1 + @test ground_pv["lifecycle_om_cost_after_tax_bau"] ≈ 782.0 atol=0.1 + @test roof_west["lifecycle_om_cost_after_tax_bau"] ≈ 782.0 atol=0.1 + @test ground_pv["annual_energy_produced_kwh_bau"] ≈ 8933.09 atol=0.1 + @test roof_west["annual_energy_produced_kwh_bau"] ≈ 7656.11 atol=0.1 + @test ground_pv["annual_energy_produced_kwh"] ≈ 26799.26 atol=0.1 + @test roof_west["annual_energy_produced_kwh"] ≈ 10719.51 atol=0.1 + @test roof_east["annual_energy_produced_kwh"] ≈ 6685.95 atol=0.1 + end end @testset "Thermal Energy Storage + Absorption Chiller" begin @@ -1929,8 +2000,8 @@ else # run HiGHS tests @test abs(results["Financial"]["lifecycle_capital_costs"] - 0.7*results["Financial"]["initial_capital_costs"]) < 150.0 @test abs(results["Financial"]["npv"] - 840621) < 1.0 - @test results["Financial"]["simple_payback_years"] - 5.09 < 0.1 - @test results["Financial"]["internal_rate_of_return"] - 0.18 < 0.01 + @test abs(results["Financial"]["simple_payback_years"] - 3.59) < 0.1 + @test abs(results["Financial"]["internal_rate_of_return"] - 0.258) < 0.01 @test haskey(results["ExistingBoiler"], "year_one_fuel_cost_before_tax_bau") @@ -2417,6 +2488,147 @@ else # run HiGHS tests end + @testset "ASHP" begin + @testset "ASHP Space Heater" begin + #Case 1: Boiler and existing chiller produce the required heat and cooling - ASHP is not purchased + d = JSON.parsefile("./scenarios/ashp.json") + d["SpaceHeatingLoad"]["annual_mmbtu"] = 1.0 * 8760 + 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["ASHPSpaceHeater"]["size_ton"] ≈ 0.0 atol=0.1 + @test results["ASHPSpaceHeater"]["annual_thermal_production_mmbtu"] ≈ 0.0 atol=0.1 + @test results["ASHPSpaceHeater"]["annual_electric_consumption_kwh"] ≈ 0.0 atol=0.1 + @test results["ElectricUtility"]["annual_energy_supplied_kwh"] ≈ 87600.0 atol=0.1 + + #Case 2: ASHP has temperature-dependent output and serves all heating load + d["ExistingChiller"] = Dict("retire_in_optimal" => false) + d["ExistingBoiler"]["retire_in_optimal"] = false + d["ExistingBoiler"]["fuel_cost_per_mmbtu"] = 100 + d["ASHPSpaceHeater"]["installed_cost_per_ton"] = 300 + d["ASHPSpaceHeater"]["min_allowable_ton"] = 80.0 + + 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) + annual_thermal_prod = 0.8 * 8760 #80% efficient boiler --> 0.8 MMBTU of heat load per hour + annual_ashp_consumption = sum(0.8 * REopt.KWH_PER_MMBTU / p.heating_cop["ASHPSpaceHeater"][ts] for ts in p.time_steps) + annual_energy_supplied = 87600 + annual_ashp_consumption + @test results["ASHPSpaceHeater"]["size_ton"] ≈ 80.0 atol=0.01 + @test results["ASHPSpaceHeater"]["annual_thermal_production_mmbtu"] ≈ annual_thermal_prod rtol=1e-4 + @test results["ASHPSpaceHeater"]["annual_electric_consumption_kwh"] ≈ annual_ashp_consumption rtol=1e-4 + @test results["ElectricUtility"]["annual_energy_supplied_kwh"] ≈ annual_energy_supplied rtol=1e-4 + @test results["ASHPSpaceHeater"]["annual_thermal_production_tonhour"] ≈ 0.0 atol=1e-4 + + #Case 3: ASHP can serve cooling, add cooling load + d["CoolingLoad"] = Dict("thermal_loads_ton" => ones(8760)*0.1) + d["ExistingChiller"] = Dict("cop" => 0.5) + d["ASHPSpaceHeater"]["can_serve_cooling"] = true + + 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) + + annual_ashp_consumption += 0.1 * sum(REopt.KWH_THERMAL_PER_TONHOUR / p.cooling_cop["ASHPSpaceHeater"][ts] for ts in p.time_steps) + annual_energy_supplied = annual_ashp_consumption + 87600 - 2*876.0*REopt.KWH_THERMAL_PER_TONHOUR + @test results["ASHPSpaceHeater"]["size_ton"] ≈ 80.0 atol=0.01 #size increases when cooling load also served + @test results["ASHPSpaceHeater"]["annual_electric_consumption_kwh"] ≈ annual_ashp_consumption rtol=1e-4 + @test results["ASHPSpaceHeater"]["annual_thermal_production_tonhour"] ≈ 876.0 rtol=1e-4 + + #Case 4: ASHP used for everything because the existing boiler and chiller are retired even if efficient or free to operate + d["ExistingChiller"] = Dict("retire_in_optimal" => true, "cop" => 100) + d["ExistingBoiler"]["retire_in_optimal"] = true + d["ExistingBoiler"]["fuel_cost_per_mmbtu"] = 0 + 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["ASHPSpaceHeater"]["annual_electric_consumption_kwh"] ≈ annual_ashp_consumption rtol=1e-4 + @test results["ASHPSpaceHeater"]["annual_thermal_production_tonhour"] ≈ 876.0 atol=1e-4 + + end + + @testset "ASHP Water Heater" begin + #Case 1: Boiler and existing chiller produce the required heat and cooling - ASHP_WH is not purchased + d = JSON.parsefile("./scenarios/ashp_wh.json") + d["SpaceHeatingLoad"]["annual_mmbtu"] = 0.5 * 8760 + d["DomesticHotWaterLoad"]["annual_mmbtu"] = 0.5 * 8760 + 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"]["size_ton"] ≈ 0.0 atol=0.1 + @test results["ASHPWaterHeater"]["annual_thermal_production_mmbtu"] ≈ 0.0 atol=0.1 + @test results["ASHPWaterHeater"]["annual_electric_consumption_kwh"] ≈ 0.0 atol=0.1 + @test results["ElectricUtility"]["annual_energy_supplied_kwh"] ≈ 87600.0 atol=0.1 + + #Case 2: ASHP_WH has temperature-dependent output and serves all DHW load + d["ExistingChiller"] = Dict("retire_in_optimal" => false) + d["ExistingBoiler"]["retire_in_optimal"] = false + d["ExistingBoiler"]["fuel_cost_per_mmbtu"] = 100 + d["ASHPWaterHeater"]["installed_cost_per_ton"] = 300 + + 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) + annual_thermal_prod = 0.4 * 8760 #80% efficient boiler --> 0.8 MMBTU of heat load per hour + annual_ashp_consumption = sum(0.4 * REopt.KWH_PER_MMBTU / p.heating_cop["ASHPWaterHeater"][ts] for ts in p.time_steps) + annual_energy_supplied = 87600 + annual_ashp_consumption + @test results["ASHPWaterHeater"]["size_ton"] ≈ 37.673 atol=0.1 + @test results["ASHPWaterHeater"]["annual_thermal_production_mmbtu"] ≈ annual_thermal_prod rtol=1e-4 + @test results["ASHPWaterHeater"]["annual_electric_consumption_kwh"] ≈ annual_ashp_consumption rtol=1e-4 + @test results["ElectricUtility"]["annual_energy_supplied_kwh"] ≈ annual_energy_supplied rtol=1e-4 + end + + @testset "Force in ASHP systems" 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"] = true + d["ASHPWaterHeater"] = Dict{String,Any}("force_into_system" => true, "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) + + @test results["ASHPWaterHeater"]["annual_electric_consumption_kwh"] ≈ sum(0.4 * 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.4 * 8760 rtol=1e-4 + @test results["ASHPSpaceHeater"]["annual_thermal_production_tonhour"] ≈ 876.0 rtol=1e-4 + + d["ASHPSpaceHeater"]["force_into_system"] = false + 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.4 * REopt.KWH_PER_MMBTU / p.heating_cop["ASHPWaterHeater"][ts] for ts in p.time_steps) rtol=1e-4 + @test results["ExistingBoiler"]["annual_thermal_production_mmbtu"] ≈ 0.4 * 8760 rtol=1e-4 + @test results["ExistingChiller"]["annual_thermal_production_tonhour"] ≈ 876.0 rtol=1e-4 + + d["ASHPSpaceHeater"]["force_into_system"] = true + d["ASHPWaterHeater"]["force_into_system"] = false + 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["ASHPSpaceHeater"]["annual_thermal_production_mmbtu"] ≈ 0.4 * 8760 rtol=1e-4 + @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 + + end + @testset "Process Heat Load" begin d = JSON.parsefile("./scenarios/process_heat.json") @@ -2426,6 +2638,7 @@ else # run HiGHS tests p = REoptInputs(s) m = Model(optimizer_with_attributes(HiGHS.Optimizer, "output_flag" => false, "log_to_console" => false)) results = run_reopt(m, p) + @test results["Boiler"]["size_mmbtu_per_hour"] ≈ 24.0 atol=0.1 @test results["Boiler"]["annual_thermal_production_mmbtu"] ≈ 210240.0 atol=0.1 @test sum(results["Boiler"]["thermal_to_dhw_load_series_mmbtu_per_hour"]) ≈ 70080.0 atol=0.1 diff --git a/test/scenarios/ashp.json b/test/scenarios/ashp.json new file mode 100644 index 000000000..d28d3b5fc --- /dev/null +++ b/test/scenarios/ashp.json @@ -0,0 +1,44 @@ +{ + "Site": { + "latitude": 37.78, + "longitude": -122.45 + }, + "ExistingBoiler": { + "production_type": "steam", + "efficiency": 0.8, + "fuel_type": "natural_gas", + "fuel_cost_per_mmbtu": 5 + }, + "ASHPSpaceHeater": { + "min_ton": 0.0, + "max_ton": 100000, + "installed_cost_per_ton": 4050, + "om_cost_per_ton": 0.0, + "macrs_option_years": 0, + "macrs_bonus_fraction": 0.0, + "can_serve_cooling": false, + "back_up_temp_threshold_degF": 10.0 + }, + "Financial": { + "om_cost_escalation_rate_fraction": 0.025, + "elec_cost_escalation_rate_fraction": 0.023, + "existing_boiler_fuel_cost_escalation_rate_fraction": 0.034, + "boiler_fuel_cost_escalation_rate_fraction": 0.034, + "offtaker_tax_rate_fraction": 0.26, + "offtaker_discount_rate_fraction": 0.083, + "third_party_ownership": false, + "owner_tax_rate_fraction": 0.26, + "owner_discount_rate_fraction": 0.083, + "analysis_years": 25 + }, + "ElectricLoad": { + "doe_reference_name": "FlatLoad", + "annual_kwh": 87600.0 + }, + "SpaceHeatingLoad": { + "doe_reference_name": "FlatLoad" + }, + "ElectricTariff": { + "monthly_energy_rates": [0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1] + } + } \ No newline at end of file diff --git a/test/scenarios/ashp_wh.json b/test/scenarios/ashp_wh.json new file mode 100644 index 000000000..7be52fc6c --- /dev/null +++ b/test/scenarios/ashp_wh.json @@ -0,0 +1,45 @@ +{ + "Site": { + "latitude": 37.78, + "longitude": -122.45 + }, + "ExistingBoiler": { + "production_type": "steam", + "efficiency": 0.8, + "fuel_type": "natural_gas", + "fuel_cost_per_mmbtu": 5 + }, + "ASHPWaterHeater": { + "min_ton": 0.0, + "max_ton": 100000, + "installed_cost_per_ton": 4050, + "om_cost_per_ton": 0.0, + "macrs_option_years": 0, + "macrs_bonus_fraction": 0.0 + }, + "Financial": { + "om_cost_escalation_rate_fraction": 0.025, + "elec_cost_escalation_rate_fraction": 0.023, + "existing_boiler_fuel_cost_escalation_rate_fraction": 0.034, + "boiler_fuel_cost_escalation_rate_fraction": 0.034, + "offtaker_tax_rate_fraction": 0.26, + "offtaker_discount_rate_fraction": 0.083, + "third_party_ownership": false, + "owner_tax_rate_fraction": 0.26, + "owner_discount_rate_fraction": 0.083, + "analysis_years": 25 + }, + "ElectricLoad": { + "doe_reference_name": "FlatLoad", + "annual_kwh": 87600.0 + }, + "SpaceHeatingLoad": { + "doe_reference_name": "FlatLoad" + }, + "DomesticHotWaterLoad": { + "doe_reference_name": "FlatLoad" + }, + "ElectricTariff": { + "monthly_energy_rates": [0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1] + } + } \ No newline at end of file diff --git a/test/scenarios/chp_payback.json b/test/scenarios/chp_payback.json new file mode 100644 index 000000000..ae8e05353 --- /dev/null +++ b/test/scenarios/chp_payback.json @@ -0,0 +1,86 @@ +{ + "Financial": { + "offtaker_tax_rate_fraction": 0.26, + "elec_cost_escalation_rate_fraction": 0.017, + "chp_fuel_cost_escalation_rate_fraction": 0.015, + "existing_boiler_fuel_cost_escalation_rate_fraction": 0.015, + "om_cost_escalation_rate_fraction": 0.025 + }, + "Settings": { + "solver_name": "HiGHS", + "off_grid_flag": false, + "include_climate_in_objective": false, + "include_health_in_objective": false + }, + "Site": { + "latitude": 41.8809434, + "longitude": -72.2600655, + "include_exported_renewable_electricity_in_total": true, + "include_exported_elec_emissions_in_total": true, + "land_acres": 1000000.0, + "roof_squarefeet": 0 + }, + "ElectricLoad": { + "monthly_totals_kwh": [ + 921984.0, + 884352.0, + 912576.0, + 921984.0, + 950208.0, + 903168.0, + 1025472.0, + 978432.0, + 1072512.0, + 1044288.0, + 987840.0, + 950208.0 + ], + "critical_load_fraction": 0.8, + "doe_reference_name": "FlatLoad" + }, + "ElectricTariff": { + "blended_annual_demand_rate": 21.487, + "blended_annual_energy_rate": 0.0788 + }, + "ElectricUtility": { + "net_metering_limit_kw": 0.0, + "avert_emissions_region": "New England", + "cambium_location_type": "GEA Regions", + "cambium_levelization_years": 1, + "cambium_metric_col": "lrmer_co2e", + "cambium_scenario": "Mid-case", + "cambium_grid_level": "enduse" + }, + "Generator": { + "max_kw": 0, + "existing_kw": 0 + }, + "CHP": { + "can_net_meter": false, + "prime_mover": "recip_engine", + "size_class": 5, + "min_kw": 1104.0, + "min_allowable_kw": 1104.0, + "max_kw": 1104.0, + "fuel_type": "natural_gas", + "can_wholesale": false, + "fuel_cost_per_mmbtu": 10.55, + "federal_itc_fraction": 0.3, + "macrs_option_years": 5, + "macrs_bonus_fraction": 0.8, + "macrs_itc_reduction": 0.5 + }, + "DomesticHotWaterLoad": { + "annual_mmbtu": 6795.0, + "doe_reference_name": "FlatLoad" + }, + "SpaceHeatingLoad": { + "annual_mmbtu": 36205.0, + "doe_reference_name": "FlatLoad" + }, + "ExistingBoiler": { + "fuel_type": "natural_gas", + "production_type": "hot_water", + "fuel_cost_per_mmbtu": 10.55 + } +} \ No newline at end of file diff --git a/test/scenarios/chp_sizing.json b/test/scenarios/chp_sizing.json index eefb65416..657a9a8c4 100644 --- a/test/scenarios/chp_sizing.json +++ b/test/scenarios/chp_sizing.json @@ -17,7 +17,8 @@ "doe_reference_name": "Hospital" }, "ElectricTariff": { - "urdb_label": "5e1676e95457a3f87673e3b0" + "blended_annual_energy_rate": 0.12, + "blended_annual_demand_rate": 10.0 }, "SpaceHeatingLoad": { "doe_reference_name": "Hospital" @@ -40,11 +41,11 @@ "om_cost_per_kw": 149.8, "om_cost_per_kwh": 0.0, "om_cost_per_hr_per_kw_rated": 0.0, - "electric_efficiency_full_load": 0.3573, - "electric_efficiency_half_load": 0.3216, + "electric_efficiency_full_load": 0.34, + "electric_efficiency_half_load": 0.34, "min_turn_down_fraction": 0.5, - "thermal_efficiency_full_load": 0.4418, - "thermal_efficiency_half_load": 0.4664, + "thermal_efficiency_full_load": 0.45, + "thermal_efficiency_half_load": 0.45, "macrs_option_years": 0, "macrs_bonus_fraction": 0.0, "macrs_itc_reduction": 0.0, diff --git a/test/scenarios/logger.json b/test/scenarios/logger.json index ba240aab6..2e540281b 100644 --- a/test/scenarios/logger.json +++ b/test/scenarios/logger.json @@ -8,7 +8,7 @@ }, "ElectricLoad": { "annual_kwh": 100000.0, - "doe_reference_name": "MidriceApartment" + "doe_reference_name": "MidriseApartment" }, "ElectricUtility" : { "co2_from_avert" : true diff --git a/test/test_with_cplex.jl b/test/test_with_cplex.jl index b869e25f9..b32ac7756 100644 --- a/test/test_with_cplex.jl +++ b/test/test_with_cplex.jl @@ -1,6 +1,6 @@ # REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE. using CPLEX - +# using GAMS #= add a time-of-export rate that is greater than retail rate for the month of January,