diff --git a/src/REopt.jl b/src/REopt.jl index 5ff756e2f..7598c1f5f 100644 --- a/src/REopt.jl +++ b/src/REopt.jl @@ -162,6 +162,7 @@ include("constraints/ghp_constraints.jl") include("constraints/steam_turbine_constraints.jl") include("constraints/renewable_energy_constraints.jl") include("constraints/emissions_constraints.jl") +include("constraints/cef_constraints.jl") include("mpc/structs.jl") include("mpc/scenario.jl") diff --git a/src/constraints/cef_constraints.jl b/src/constraints/cef_constraints.jl new file mode 100644 index 000000000..8551f409b --- /dev/null +++ b/src/constraints/cef_constraints.jl @@ -0,0 +1,59 @@ +# REopt®, Copyright (c) Alliance for Sustainable Energy, LLC. See also https://github.com/NREL/REopt.jl/blob/master/LICENSE. + +""" +calc_clean_grid_kWh(m, p) + +This function calculates the clean energy fraction of the electricity from the electric utility +by multiplying the electricity from the grid used to charge the batteries and the electricity from the grid directly serving the load +by the clean energy fraction series. + +Returns: +- clean_energy_fraction: The clean energy fraction of the grid electricity. +""" +function calc_clean_grid_kWh(m, p) + # Calculate the grid electricity used to charge the batteries and directly serve the load + m[:CleanGridToLoad], m[:CleanGridToBatt] = calc_grid_to_load(m, p) + + # Calculate the clean energy fraction from the electric utility + m[:grid_clean_energy_series_kw] = @expression(m, [ + ts in p.time_steps], (m[:CleanGridToLoad][ts] + m[:CleanGridToBatt][ts]) * p.s.electric_utility.clean_energy_fraction_series[ts] + ) +end + + +""" +calc_grid_to_load(m, p) + +This function calculates, for each timestep: +1. The electricity from the grid used to charge the batteries, accounting for battery losses. +2. The electricity from the grid directly serving the load. + +Returns: +- CleanGridToLoad: The electricity from the grid directly serving the load. +- CleanGridToBatt: The electricity from the grid used to charge the batteries, accounting for losses. +""" + +function calc_grid_to_load(m, p) + if !isempty(p.s.storage.types.elec) + # Calculate the grid to load through the battery, accounting for the battery losses + m[:CleanGridToBatt] = @expression(m, [ + ts in p.time_steps], sum( + m[:dvGridToStorage][b, ts] * p.s.storage.attr[b].charge_efficiency * p.s.storage.attr[b].discharge_efficiency + for b in p.s.storage.types.elec) + ) + else + m[:CleanGridToBatt] = zeros(length(p.time_steps)) + end + + # Calculate the grid serving load not through the battery + m[:CleanGridToLoad] = @expression(m, [ + ts in p.time_steps], ( + sum(m[:dvGridPurchase][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) - + sum(m[:dvGridToStorage][b, ts] for b in p.s.storage.types.elec) + )) + + return m[:CleanGridToLoad], m[:CleanGridToBatt] +end + + + diff --git a/src/constraints/renewable_energy_constraints.jl b/src/constraints/renewable_energy_constraints.jl index 3a6211756..5df9d56e7 100644 --- a/src/constraints/renewable_energy_constraints.jl +++ b/src/constraints/renewable_energy_constraints.jl @@ -3,6 +3,7 @@ add_re_elec_constraints(m,p) Function to add minimum and/or maximum renewable electricity (as percentage of load) constraints, if specified by user. +Function modified to include cef fraction from the grid in accounting for renewable energy percentage of load. !!! note When a single outage is modeled (using outage_start_time_step), renewable electricity calculations account for operations during this outage (e.g., the critical load is used during time_steps_without_grid) @@ -50,7 +51,14 @@ function add_re_elec_calcs(m,p) # )) # end - m[:AnnualREEleckWh] = @expression(m,p.hours_per_time_step * ( + # Function modified to add clean energy fraction to in annual renewable energy calculations + + calc_clean_grid_kWh(m, p) + + m[:AnnualREEleckWh] = @expression(m, p.hours_per_time_step * ( + (p.s.electric_utility.include_grid_clean_energy_in_re ? + sum(m[:grid_clean_energy_series_kw][ts] for ts in p.time_steps) : 0) + # Include grid clean energy fraction conditionally + + # Clean energy fraction of grid electricity to load and battery sum(p.production_factor[t,ts] * p.levelization_factor[t] * m[:dvRatedProduction][t,ts] * p.tech_renewable_energy_fraction[t] for t in p.techs.elec, ts in p.time_steps ) - #total RE elec generation, excl steam turbine diff --git a/src/core/electric_utility.jl b/src/core/electric_utility.jl index 1fa2c8404..54d444f01 100644 --- a/src/core/electric_utility.jl +++ b/src/core/electric_utility.jl @@ -19,13 +19,17 @@ ### Grid Climate Emissions Inputs ### # Climate Option 1 (Default): Use levelized emissions data from NREL's Cambium database by specifying the following fields: cambium_scenario::String = "Mid-case", # Cambium Scenario for evolution of electricity sector (see Cambium documentation for descriptions). - ## Options: ["Mid-case", "Mid-case with tax credit expiration", "Low renewable energy cost", "Low renewable energy cost with tax credit expiration", "High renewable energy cost", "High electrification", "Low natrual gas prices", "High natrual gas prices", "Mid-case with 95% decarbonization by 2050", "Mid-case with 100% decarbonization by 2035"] + ## Options: ["Mid-case", "Mid-case with tax credit expiration", "Low renewable energy cost", "Low renewable energy cost with tax credit expiration", "High renewable energy cost", "High electrification", "Low natural gas prices", "High natural gas prices", "Mid-case with 95% decarbonization by 2050", "Mid-case with 100% decarbonization by 2035"] cambium_location_type::String = "GEA Regions", # Geographic boundary at which emissions are calculated. Options: ["Nations", "GEA Regions", "States"] cambium_metric_col::String = "lrmer_co2e", # Emissions metric used. Default: "lrmer_co2e" - Long-run marginal emissions rate for CO2-equivalant, combined combustion and pre-combustion emissions rates. Options: See metric definitions and names in the Cambium documentation cambium_start_year::Int = 2024, # First year of operation of system. Emissions will be levelized starting in this year for the duration of cambium_levelization_years. # Options: any year 2023 through 2050. cambium_levelization_years::Int = analysis_years, # Expected lifetime or analysis period of the intervention being studied. Emissions will be averaged over this period. cambium_grid_level::String = "enduse", # Options: ["enduse", "busbar"]. Busbar refers to point where bulk generating stations connect to grid; enduse refers to point of consumption (includes distribution loss rate). + ### Grid Clean Energy Fraction Inputs ### + cambium_cef_col::String = "cef_load", Options = ["cef_load", "cef_gen"] # Cef_load refers to the proportion of electricity consumed (load) that in a region that comes from clean energy sources; cef_gen refers to the proportion of electricity generated in a region that comes from clean energy sources. + clean_energy_fraction_series::Union{Real,Array{<:Real,1}} = Float64[], # Utilities renewable energy fraction. Can be scalar or timeseries (aligned with time_steps_per_hour). + # Climate Option 2: Use CO2 emissions data from the EPA's AVERT based on the AVERT emissions region and specify annual percent decrease co2_from_avert::Bool = false, # Default is to use Cambium data for CO2 grid emissions. Set to `true` to instead use data from the EPA's AVERT database. @@ -82,6 +86,11 @@ !!! note "Climate and Health Emissions Modeling" Climate and health-related emissions from grid electricity come from two different data sources and have different REopt inputs as described below. + **Grid Clean Energy Fraction** + - For sites in the contiguous United States: + - Default clean energy fraction data comes from NREL's Cambium database (Current version: 2022) + - By default, REopt uses *clean energy fraction* for the region in which the site is located. + **Climate Emissions** - For sites in the contiguous United States: - Default climate-related emissions factors come from NREL's Cambium database (Current version: 2022) @@ -125,8 +134,9 @@ struct ElectricUtility outage_time_steps::Union{Nothing, UnitRange} scenarios::Union{Nothing, UnitRange} net_metering_limit_kw::Real - interconnection_limit_kw::Real - + interconnection_limit_kw::Real + clean_energy_fraction_series::Array{<:Real,1} # Utilities renewable energy fraction. + include_grid_clean_energy_in_re::Bool function ElectricUtility(; @@ -149,6 +159,7 @@ struct ElectricUtility outage_start_time_step::Int=0, # for modeling a single outage, with critical load spliced into the baseline load ... outage_end_time_step::Int=0, # ... utility production_factor = 0 during the outage allow_simultaneous_export_import::Bool=true, # if true the site has two meters (in effect) + include_grid_clean_energy_in_re::Bool=true, # if true, the clean energy fraction of the grid electricity is included in the renewable electricity calculations # next 5 variables below used for minimax the expected outage cost, # with max taken over outage start time, expectation taken over outage duration outage_start_time_steps::Array{Int,1}=Int[], # we include in the minimization the maximum outage cost over outage start times @@ -156,6 +167,11 @@ struct ElectricUtility outage_probabilities::Array{<:Real,1} = isempty(outage_durations) ? Float64[] : [1/length(outage_durations) for p_i in 1:length(outage_durations)], outage_time_steps::Union{Nothing, UnitRange} = isempty(outage_durations) ? nothing : 1:maximum(outage_durations), scenarios::Union{Nothing, UnitRange} = isempty(outage_durations) ? nothing : 1:length(outage_durations), + + ### Grid Renewable Energy Fraction Inputs ### + # Utilities renewable energy fraction. Can be scalar or timeseries (aligned with time_steps_per_hour) + clean_energy_fraction_series::Union{Real, Array{<:Real, 1}} = Float64[], + cambium_cef_col::String = "cef_load", # Column name for clean energy fraction in Cambium database ### Grid Climate Emissions Inputs ### # Climate Option 1 (Default): Use levelized emissions data from NREL's Cambium database by specifying the following fields: @@ -195,6 +211,67 @@ struct ElectricUtility cambium_emissions_region = "NA - Cambium data not used for climate emissions" # will be overwritten if Cambium is used if !is_MPC + # Initialize clean energy fraction series + clean_energy_series_dict = Dict{String, Union{Nothing, Array{<:Real, 1}}}() + if typeof(clean_energy_fraction_series) <: Real # user provided scalar value + if clean_energy_fraction_series < 0 || clean_energy_fraction_series > 1 + throw(@error("The provided ElectricUtility clean energy fraction value must be between 0 and 1.")) + end + clean_energy_series_dict["cef"] = repeat([clean_energy_fraction_series], 8760*time_steps_per_hour) + elseif length(clean_energy_fraction_series) == 1 # user provided array of one value + if clean_energy_fraction_series[1] < 0 || clean_energy_fraction_series[1] > 1 + throw(@error("The provided ElectricUtility clean energy fraction value must be between 0 and 1.")) + end + clean_energy_series_dict["cef"] = repeat(clean_energy_fraction_series, 8760*time_steps_per_hour) + elseif length(clean_energy_fraction_series) / time_steps_per_hour ≈ 8760 # user provided array with correct length + if any(x -> x < 0 || x > 1, clean_energy_fraction_series) + throw(@error("All values in the provided ElectricUtility clean energy fraction series must be between 0 and 1.")) + end + clean_energy_series_dict["cef"] = clean_energy_fraction_series + elseif length(clean_energy_fraction_series) > 1 && !(length(clean_energy_fraction_series) / time_steps_per_hour ≈ 8760) # user provided array with incorrect length + if length(clean_energy_fraction_series) == 8760 + if any(x -> x < 0 || x > 1, clean_energy_fraction_series) + throw(@error("All values in the provided ElectricUtility clean energy fraction series must be between 0 and 1.")) + end + clean_energy_series_dict["cef"] = repeat(clean_energy_fraction_series, inner=time_steps_per_hour) + @warn("Clean energy fraction series has been adjusted to align with time_steps_per_hour of $(time_steps_per_hour).") + else + throw(@error("The provided ElectricUtility clean energy fraction series does not match the time_steps_per_hour.")) + end + else + # Retrieve clean energy fraction data if not user-provided + if cambium_start_year < 2023 || cambium_start_year > 2050 + @warn("The cambium_start_year must be between 2023 and 2050. Setting cambium_start_year to 2024.") + cambium_start_year = 2024 # Must update annually + end + try + clean_energy_response_dict = cambium_clean_energy_fraction_profile( + scenario = cambium_scenario, + location_type = cambium_location_type, + latitude = latitude, + longitude = longitude, + start_year = cambium_start_year, + lifetime = cambium_levelization_years, + metric_col = cambium_cef_col, + grid_level = cambium_grid_level, + time_steps_per_hour = time_steps_per_hour, + load_year = load_year, + emissions_year = 2017 # Cambium data starts on a Sunday + ) + clean_energy_series_dict["cef"] = clean_energy_response_dict["clean_energy_fraction_series"] + cambium_emissions_region = clean_energy_response_dict["location"] + catch + @warn("Could not look up Cambium renewable energy fraction profile from point ($(latitude), $(longitude)). + Location is likely outside contiguous US or something went wrong with the Cambium API request. Setting clean energy fraction to zero.") + clean_energy_series_dict["cef"] = zeros(Float64, 8760*time_steps_per_hour) + end + end + + # save clean_energy_series_dict["cef"] as csv + # clean_energy_df = DataFrame(cef = clean_energy_series_dict["cef"]) + # CSV.write("clean_energy_fraction_series.csv", clean_energy_df) + + # Get AVERT emissions region if avert_emissions_region == "" region_abbr, meters_to_region = avert_region_abbreviation(latitude, longitude) @@ -350,13 +427,13 @@ struct ElectricUtility outage_time_steps, scenarios, net_metering_limit_kw, - interconnection_limit_kw + interconnection_limit_kw, + is_MPC ? Float64[] : clean_energy_series_dict["cef"], + include_grid_clean_energy_in_re ) end end - - """ Determine the AVERT region abberviation for a given lat/lon pair. 1. Checks to see if given point is in an AVERT region @@ -540,23 +617,25 @@ end emissions_year::Int=2017, grid_level::String) +This function constructs an API request to the Cambium database to retrieve either emissions data or clean energy fraction data depending on the `metric_col` provided. This function gets levelized grid CO2 or CO2e emission rate profiles (1-year time series) from the Cambium dataset. The returned profiles are adjusted for day of week alignment with the provided "load_year" (Cambium profiles always start on a Sunday.) This function is also used for the /cambium_emissions_profile endpoint in the REopt API, in particular for the webtool to display grid emissions defaults before running REopt. + """ -function cambium_emissions_profile(; scenario::String, - location_type::String, - latitude::Real, - longitude::Real, - start_year::Int, - lifetime::Int, - metric_col::String, - grid_level::String, - time_steps_per_hour::Int=1, - load_year::Int=2017, - emissions_year::Int=2017 - ) +function cambium_profile(; scenario::String, + location_type::String, + latitude::Real, + longitude::Real, + start_year::Int, + lifetime::Int, + metric_col::String, + grid_level::String, + time_steps_per_hour::Int=1, + load_year::Int=2017, + emissions_year::Int=2017 + ) url = "https://scenarioviewer.nrel.gov/api/get-levelized/" # Production project_uuid = "82460f06-548c-4954-b2d9-b84ba92d63e2" # Cambium 2022 @@ -568,7 +647,7 @@ function cambium_emissions_profile(; scenario::String, # "location" => "Colorado", # e.g., Contiguous United States, Colorado, Kansas, p33, p34 "latitude" => string(round(latitude, digits=3)), "longitude" => string(round(longitude, digits=3)), - "start_year" => string(start_year), # biennial from 2022-2050 (data year covers nominal year and years proceeding; e.g., 2040 values cover time range starting in 2036) + "start_year" => string(start_year), # biennial from 2022-2050 (data year covers nominal year and years proceeding; e.g., 2040 values cover time range starting in 2036) # The 2023 release has five-year time steps from 2025 through 2050 "lifetime" => string(lifetime), # Integer 1 or greater (Default 25 yrs) "discount_rate" => "0.0", # Zero = simple average (a pwf with discount rate gets applied to projected CO2 costs, but not quantity.) "time_type" => "hourly", # hourly or annual @@ -583,29 +662,40 @@ function cambium_emissions_profile(; scenario::String, r = HTTP.get(url; query=payload) response = JSON.parse(String(r.body)) # contains response["status"] output = response["message"] - co2_emissions = output["values"] ./ 1000 # [lb / MWh] --> [lb / kWh] - + data_series = output["values"] + # co2_emissions = output["values"] ./ 1000 # [lb / MWh] --> [lb / kWh] + + # Convert from [lb/MWh] to [lb/kWh] if the metric is emissions-related + if metric_col == "lrmer_co2e" + data_series = data_series ./ 1000 + end + # Align day of week of emissions and load profiles (Cambium data starts on Sundays so assuming emissions_year=2017) - co2_emissions = align_emission_with_load_year(load_year=load_year,emissions_year=emissions_year,emissions_profile=co2_emissions) + data_series = align_emission_with_load_year(load_year=load_year, emissions_year=emissions_year, emissions_profile=data_series) if time_steps_per_hour > 1 - co2_emissions = repeat(co2_emissions, inner=time_steps_per_hour) + data_series = repeat(data_series, inner=time_steps_per_hour) end - + + description, units = if metric_col == "lrmer_co2e" + ("Hourly CO2 (or CO2e) grid emissions factors for applicable Cambium location and location_type, adjusted to align with load year $(load_year).", "Pounds emissions per kWh") + else + ("Hourly clean energy fraction for applicable Cambium location and location_type, adjusted to align with load year $(load_year).", "Fraction of clean energy") + end + response_dict = Dict{String, Any}( - "description" => "Hourly CO2 (or CO2e) grid emissions factors for applicable Cambium location and location_type, adjusted to align with load year $(load_year).", - "units" => "Pounds emissions per kWh", + "description" => description, + "units" => units, "location" => output["location"], "metric_col" => output["metric_col"], - "emissions_factor_series_lb_CO2_per_kwh" => co2_emissions + "data_series" => data_series ) return response_dict catch return Dict{String, Any}( - "error"=> - "Could not look up Cambium emissions profile from point ($(latitude), $(longitude)). - Location is likely outside contiguous US or something went wrong with the Cambium API request." - ) + "error" => "Could not look up Cambium profile from point ($(latitude), $(longitude)). + Location is likely outside contiguous US or something went wrong with the Cambium API request." + ) end end diff --git a/src/core/reopt.jl b/src/core/reopt.jl index 662d249cd..41879dce2 100644 --- a/src/core/reopt.jl +++ b/src/core/reopt.jl @@ -580,6 +580,7 @@ function run_reopt(m::JuMP.AbstractModel, p::REoptInputs; organize_pvs=true) results = reopt_results(m, p) time_elapsed = time() - tstart @info "Results processing took $(round(time_elapsed, digits=3)) seconds." + @info "REopt results have been processed." results["status"] = status results["solver_seconds"] = opt_time diff --git a/src/core/scenario.jl b/src/core/scenario.jl index 621781f53..d588a0320 100644 --- a/src/core/scenario.jl +++ b/src/core/scenario.jl @@ -137,7 +137,7 @@ function Scenario(d::Dict; flex_hvac_from_json=false) off_grid_flag=settings.off_grid_flag, time_steps_per_hour=settings.time_steps_per_hour, analysis_years=financial.analysis_years, - load_year=electric_load.year + load_year=electric_load.year, ) elseif !(settings.off_grid_flag) electric_utility = ElectricUtility(; latitude=site.latitude, longitude=site.longitude, @@ -148,7 +148,7 @@ function Scenario(d::Dict; flex_hvac_from_json=false) off_grid_flag=settings.off_grid_flag, time_steps_per_hour=settings.time_steps_per_hour, analysis_years=financial.analysis_years, - load_year=electric_load.year + load_year=electric_load.year, ) elseif settings.off_grid_flag if haskey(d, "ElectricUtility") @@ -162,7 +162,8 @@ function Scenario(d::Dict; flex_hvac_from_json=false) emissions_factor_series_lb_CO2_per_kwh = 0, emissions_factor_series_lb_NOx_per_kwh = 0, emissions_factor_series_lb_SO2_per_kwh = 0, - emissions_factor_series_lb_PM25_per_kwh = 0 + emissions_factor_series_lb_PM25_per_kwh = 0, + clean_energy_fraction_series = 0 ) end diff --git a/src/results/electric_utility.jl b/src/results/electric_utility.jl index 88819ab41..373f122bd 100644 --- a/src/results/electric_utility.jl +++ b/src/results/electric_utility.jl @@ -44,7 +44,13 @@ function add_electric_utility_results(m::JuMP.AbstractModel, p::AbstractInputs, Year1UtilityEnergy = p.hours_per_time_step * sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for ts in p.time_steps, tier in 1:p.s.electric_tariff.n_energy_tiers) r["annual_energy_supplied_kwh"] = round(value(Year1UtilityEnergy), digits=2) - + + calc_clean_grid_kWh(m, p) + + r["clean_grid_to_load_series_kw"] = round.(value.([m[:grid_clean_energy_series_kw][ts] for ts in p.time_steps]), digits=3) + #sum to find the annual clean grid to load kWh + r["annual_clean_grid_to_load_kwh"] = round(sum(r["clean_grid_to_load_series_kw"]), digits=2) + if !isempty(p.s.storage.types.elec) GridToLoad = (sum(m[Symbol("dvGridPurchase"*_n)][ts, tier] for tier in 1:p.s.electric_tariff.n_energy_tiers) - sum(m[Symbol("dvGridToStorage"*_n)][b, ts] for b in p.s.storage.types.elec)