Skip to content

Commit

Permalink
bug fixes for pv size class determination
Browse files Browse the repository at this point in the history
  • Loading branch information
bpulluta committed Jan 18, 2025
1 parent 0bde5d2 commit 22852cd
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 203 deletions.
68 changes: 44 additions & 24 deletions data/pv/pv_defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,67 +3,87 @@
"size_classes": [
{
"size_class": 1,
"name": "Residential",
"tech_sizes_for_cost_curve": [0, 25],
"installed_cost_per_kw": [2900, 2600],
"om_cost_per_kw": 32
"installed_cost_per_kw": [2680, 2400],
"om_cost_per_kw": 30,
"notes": "Based on 2023 NREL residential benchmark of $2.68/WDC (Ramasamy et al., 2023)"
},
{
"size_class": 2,
"name": "Light Commercial",
"tech_sizes_for_cost_curve": [25, 250],
"installed_cost_per_kw": [2600, 2350],
"om_cost_per_kw": 28
"installed_cost_per_kw": [2200, 1900],
"om_cost_per_kw": 22,
"notes": "Based on NREL commercial benchmarks with size-based scaling"
},
{
"size_class": 3,
"name": "Heavy Commercial",
"tech_sizes_for_cost_curve": [250, 750],
"installed_cost_per_kw": [2350, 2100],
"om_cost_per_kw": 25
"installed_cost_per_kw": [1900, 1780],
"om_cost_per_kw": 19,
"notes": "Based on NREL 200kW commercial benchmark of $1.78/WDC"
},
{
"size_class": 4,
"tech_sizes_for_cost_curve": [750, 2000],
"installed_cost_per_kw": [2100, 1850],
"om_cost_per_kw": 22
"name": "Industrial",
"tech_sizes_for_cost_curve": [750, 5000],
"installed_cost_per_kw": [1780, 1600],
"om_cost_per_kw": 19,
"notes": "Transition scale between commercial and utility, upper bound aligned with EIA utility definition of 5MW"
},
{
"size_class": 5,
"tech_sizes_for_cost_curve": [2000, 10000],
"installed_cost_per_kw": [1850, 1600],
"om_cost_per_kw": 18
"name": "Utility",
"tech_sizes_for_cost_curve": [5000, 100000],
"installed_cost_per_kw": [1600, 1560],
"om_cost_per_kw": 22,
"notes": "Based on NREL utility benchmark of $1.56/WAC (adjusted for DC), EIA data shows 86% of utility capacity from systems >50MW"
}
]
},
"roof": {
"size_classes": [
{
"size_class": 1,
"name": "Residential",
"tech_sizes_for_cost_curve": [0, 25],
"installed_cost_per_kw": [2800, 2500],
"om_cost_per_kw": 29
"installed_cost_per_kw": [2680, 2400],
"om_cost_per_kw": 30,
"notes": "Based on 2023 NREL residential benchmark"
},
{
"size_class": 2,
"name": "Light Commercial",
"tech_sizes_for_cost_curve": [25, 250],
"installed_cost_per_kw": [2500, 2250],
"om_cost_per_kw": 26
"installed_cost_per_kw": [2300, 2000],
"om_cost_per_kw": 24,
"notes": "Higher than ground-mount due to roof mounting complexity"
},
{
"size_class": 3,
"name": "Heavy Commercial",
"tech_sizes_for_cost_curve": [250, 750],
"installed_cost_per_kw": [2250, 2050],
"om_cost_per_kw": 24
"installed_cost_per_kw": [2000, 1880],
"om_cost_per_kw": 21,
"notes": "~$0.10/W premium over ground-mount due to rooftop installation requirements"
},
{
"size_class": 4,
"tech_sizes_for_cost_curve": [750, 2000],
"installed_cost_per_kw": [2050, 1850],
"om_cost_per_kw": 22
"name": "Large Commercial/Industrial",
"tech_sizes_for_cost_curve": [750, 5000],
"installed_cost_per_kw": [1880, 1750],
"om_cost_per_kw": 21,
"notes": "Examples include large warehouses and manufacturing facilities"
},
{
"size_class": 5,
"tech_sizes_for_cost_curve": [2000, 10000],
"installed_cost_per_kw": [1850, 1600],
"om_cost_per_kw": 20
"name": "Mega-Rooftop",
"tech_sizes_for_cost_curve": [5000, 35000],
"installed_cost_per_kw": [1750, 1650],
"om_cost_per_kw": 23,
"notes": "Upper limit based on DSV Logistics (35MW) and Tesla Gigafactory (30MW) examples"
}
]
}
Expand Down
14 changes: 11 additions & 3 deletions src/core/pv.jl
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ mutable struct PV <: AbstractTech
operating_reserve_required_fraction
size_class
tech_sizes_for_cost_curve
avg_electric_load_kw

function PV(;
off_grid_flag::Bool = false,
Expand Down Expand Up @@ -149,7 +150,8 @@ mutable struct PV <: AbstractTech
size_class::Union{Int, Nothing} = nothing,
tech_sizes_for_cost_curve::AbstractVector = Float64[]
)

@info "PV Constructor - Initial values:" avg_electric_load_kw array_type size_class

# Adjust operating_reserve_required_fraction based on off_grid_flag
if !off_grid_flag && !(operating_reserve_required_fraction == 0.0)
@warn "PV operating_reserve_required_fraction applies only when off_grid_flag is true. Setting operating_reserve_required_fraction to 0.0 for this on-grid analysis."
Expand Down Expand Up @@ -196,6 +198,7 @@ mutable struct PV <: AbstractTech
if length(invalid_args) > 0
throw(ErrorException("Invalid PV argument values: $(invalid_args)"))
end
@info "Before getting defaults:" avg_electric_load_kw array_type

# Get defaults structure
pv_defaults_all = get_pv_defaults_size_class(array_type=array_type, avg_electric_load_kw=avg_electric_load_kw)
Expand Down Expand Up @@ -225,7 +228,7 @@ mutable struct PV <: AbstractTech
elseif typeof(installed_cost_per_kw) <: Number || (installed_cost_per_kw isa AbstractVector && length(installed_cost_per_kw) == 1)
# Case 2: Single cost value provided - size class not needed
@info "Single cost value provided, size class not needed"
nothing
size_class
elseif !isempty(tech_sizes_for_cost_curve) && isempty(installed_cost_per_kw)
# Case 4: User provided tech curves but no costs, need size class for installed costs
if isnothing(size_class)
Expand Down Expand Up @@ -361,7 +364,8 @@ mutable struct PV <: AbstractTech
can_curtail,
operating_reserve_required_fraction,
size_class,
tech_sizes_for_cost_curve
tech_sizes_for_cost_curve,
avg_electric_load_kw
)
end
end
Expand All @@ -373,6 +377,8 @@ end
# Helper function

function get_pv_defaults_size_class(; array_type::Int = 1, avg_electric_load_kw::Real = 0.0)
@info "get_pv_defaults_size_class called with:" array_type avg_electric_load_kw

pv_defaults_path = joinpath(@__DIR__, "..", "..", "data", "pv", "pv_defaults.json")
if !isfile(pv_defaults_path)
throw(ErrorException("pv_defaults.json not found at path: $pv_defaults_path"))
Expand All @@ -389,6 +395,8 @@ end
function get_pv_size_class(avg_electric_load_kw::Real, tech_sizes_for_cost_curve::AbstractVector;
min_kw::Real=0.0, max_kw::Real=1.0e9, existing_kw::Real=0.0)
# Adjust max_kw to account for existing capacity
@info "get_pv_size_class called with:" avg_electric_load_kw min_kw max_kw existing_kw

adjusted_max_kw = max_kw - existing_kw

effective_size = if max_kw != 1.0e9
Expand Down
13 changes: 3 additions & 10 deletions src/core/reopt_inputs.jl
Original file line number Diff line number Diff line change
Expand Up @@ -540,13 +540,7 @@ and all of the other arguments will be updated as well.
"""
function update_cost_curve!(tech::AbstractTech, tech_name::String, financial::Financial,
cap_cost_slope, segmented_techs, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint
)
if typeof(tech) == PV && (typeof(tech.installed_cost_per_kw) <: Number || length(tech.installed_cost_per_kw) == 1)
# Handle single cost point for PV
cap_cost_slope[tech_name] = first(tech.installed_cost_per_kw)
return nothing
end

)
cost_slope, cost_curve_bp_x, cost_yint, n_segments = cost_curve(tech, financial)
cap_cost_slope[tech_name] = first(cost_slope)

Expand All @@ -566,7 +560,6 @@ function update_cost_curve!(tech::AbstractTech, tech_name::String, financial::Fi
seg_yint[tech_name][s] = cost_yint[s]
end
end
nothing

@info "Running update_cost_curve! for $(tech_name)"
@info "Cap Cost Slope Calculated: ", cap_cost_slope[tech_name]
Expand All @@ -592,12 +585,12 @@ function setup_pv_inputs(s::AbstractScenario, max_sizes, min_sizes,
# Get defaults if needed based on size class
if isnothing(pv.size_class) || isempty(pv.installed_cost_per_kw) || isempty(pv.om_cost_per_kw)
array_category = pv.array_type in [0, 2, 3, 4] ? "ground" : "roof"
pv_defaults_all = get_pv_defaults_size_class(array_type=pv.array_type, avg_electric_load_kw=pv.existing_kw)
pv_defaults_all = get_pv_defaults_size_class(array_type=pv.array_type, avg_electric_load_kw=pv.avg_electric_load_kw)
defaults = pv_defaults_all[array_category]["size_classes"]

if isnothing(pv.size_class)
pv.size_class = get_pv_size_class(
pv.existing_kw,
pv.avg_electric_load_kw,
[c["tech_sizes_for_cost_curve"] for c in defaults],
min_kw=pv.min_kw,
max_kw=pv.max_kw,
Expand Down
73 changes: 36 additions & 37 deletions src/results/financial.jl
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,7 @@ function add_financial_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _

r["initial_capital_costs"] = initial_capex(m, p; _n=_n)
future_replacement_cost, present_replacement_cost = replacement_costs_future_and_present(m, p; _n=_n)
# Debugging: Print initial capital costs and replacement costs
@info "Initial Capital Costs (Pre-Incentives): $(r["initial_capital_costs"])"
@info "Present Replacement Cost: $(present_replacement_cost)"
@info "Future Replacement Cost: $(future_replacement_cost)"

r["initial_capital_costs_after_incentives"] = r["lifecycle_capital_costs"] / p.third_party_factor - present_replacement_cost
@info "Initial Capital Costs After Incentives (Third-Party Ownership): $(r["initial_capital_costs_after_incentives"])"

r["replacements_future_cost_after_tax"] = future_replacement_cost
r["replacements_present_cost_after_tax"] = present_replacement_cost
Expand Down Expand Up @@ -136,33 +130,6 @@ end
Calculate and return the up-front capital costs for all technologies, in present value, excluding replacement costs and
incentives.
"""

function get_pv_initial_capex(p::REoptInputs, pv::AbstractTech, size_kw::Float64)
cost_list = pv.installed_cost_per_kw
initial_capex = 0.0

if typeof(cost_list) <: Number
initial_capex = cost_list * size_kw
else
size_list = pv.tech_sizes_for_cost_curve
if size_kw <= size_list[1]
initial_capex = cost_list[1] * size_kw
elseif size_kw > size_list[end]
initial_capex = cost_list[end] * size_kw
else
for s in 2:length(size_list)
if (size_kw > size_list[s-1]) && (size_kw <= 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] + (size_kw - size_list[s-1]) * slope
break
end
end
end
end
return initial_capex
end

function initial_capex(m::JuMP.AbstractModel, p::REoptInputs; _n="")
initial_capex = 0

Expand Down Expand Up @@ -298,22 +265,26 @@ divided by annual energy output. This tech-specific LCOE is distinct from the of
"""
function calculate_lcoe(p::REoptInputs, tech_results::Dict, tech::AbstractTech)
existing_kw = :existing_kw in fieldnames(typeof(tech)) ? tech.existing_kw : 0.0
new_kw = get(tech_results, "size_kw", 0) - existing_kw
new_kw = get(tech_results, "size_kw", 0) - existing_kw # new capacity
if new_kw == 0
return 0.0
end

years = p.s.financial.analysis_years
years = p.s.financial.analysis_years # length of financial life
# TODO is most of this calculated in proforma metrics?
if p.s.financial.third_party_ownership
discount_rate_fraction = p.s.financial.owner_discount_rate_fraction
federal_tax_rate_fraction = p.s.financial.owner_tax_rate_fraction
else
discount_rate_fraction = p.s.financial.offtaker_discount_rate_fraction
federal_tax_rate_fraction = p.s.financial.offtaker_tax_rate_fraction
end

capital_costs = get_pv_initial_capex(p, tech, new_kw)
annual_om = new_kw * tech.om_cost_per_kw
@info "Using initial cap cost: $(capital_costs) for lcoe calculation"

# capital_costs = new_kw * tech.installed_cost_per_kw # pre-incentive capital costs

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 of O&M charges escalated over financial life
Expand Down Expand Up @@ -441,5 +412,33 @@ function get_chp_initial_capex(p::REoptInputs, size_kw::Float64)
# initial_capex += chp_supp_firing_size * chp_supp_firing_cost
end

return initial_capex
end


function get_pv_initial_capex(p::REoptInputs, pv::AbstractTech, size_kw::Float64)
cost_list = pv.installed_cost_per_kw
size_list = pv.tech_sizes_for_cost_curve
pv_size = size_kw
initial_capex = 0.0

if typeof(cost_list) == Vector{Float64}
if pv_size <= size_list[1]
initial_capex = pv_size * cost_list[1]
elseif pv_size > size_list[end]
initial_capex = pv_size * cost_list[end]
else
for s in 2:length(size_list)
if (pv_size > size_list[s-1]) && (pv_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] + (pv_size - size_list[s-1]) * slope
end
end
end
else
initial_capex = cost_list * pv_size
end

return initial_capex
end
Loading

0 comments on commit 22852cd

Please sign in to comment.