Skip to content

Commit 22852cd

Browse files
committed
bug fixes for pv size class determination
1 parent 0bde5d2 commit 22852cd

File tree

5 files changed

+173
-203
lines changed

5 files changed

+173
-203
lines changed

data/pv/pv_defaults.json

+44-24
Original file line numberDiff line numberDiff line change
@@ -3,67 +3,87 @@
33
"size_classes": [
44
{
55
"size_class": 1,
6+
"name": "Residential",
67
"tech_sizes_for_cost_curve": [0, 25],
7-
"installed_cost_per_kw": [2900, 2600],
8-
"om_cost_per_kw": 32
8+
"installed_cost_per_kw": [2680, 2400],
9+
"om_cost_per_kw": 30,
10+
"notes": "Based on 2023 NREL residential benchmark of $2.68/WDC (Ramasamy et al., 2023)"
911
},
1012
{
1113
"size_class": 2,
14+
"name": "Light Commercial",
1215
"tech_sizes_for_cost_curve": [25, 250],
13-
"installed_cost_per_kw": [2600, 2350],
14-
"om_cost_per_kw": 28
16+
"installed_cost_per_kw": [2200, 1900],
17+
"om_cost_per_kw": 22,
18+
"notes": "Based on NREL commercial benchmarks with size-based scaling"
1519
},
1620
{
1721
"size_class": 3,
22+
"name": "Heavy Commercial",
1823
"tech_sizes_for_cost_curve": [250, 750],
19-
"installed_cost_per_kw": [2350, 2100],
20-
"om_cost_per_kw": 25
24+
"installed_cost_per_kw": [1900, 1780],
25+
"om_cost_per_kw": 19,
26+
"notes": "Based on NREL 200kW commercial benchmark of $1.78/WDC"
2127
},
2228
{
2329
"size_class": 4,
24-
"tech_sizes_for_cost_curve": [750, 2000],
25-
"installed_cost_per_kw": [2100, 1850],
26-
"om_cost_per_kw": 22
30+
"name": "Industrial",
31+
"tech_sizes_for_cost_curve": [750, 5000],
32+
"installed_cost_per_kw": [1780, 1600],
33+
"om_cost_per_kw": 19,
34+
"notes": "Transition scale between commercial and utility, upper bound aligned with EIA utility definition of 5MW"
2735
},
2836
{
2937
"size_class": 5,
30-
"tech_sizes_for_cost_curve": [2000, 10000],
31-
"installed_cost_per_kw": [1850, 1600],
32-
"om_cost_per_kw": 18
38+
"name": "Utility",
39+
"tech_sizes_for_cost_curve": [5000, 100000],
40+
"installed_cost_per_kw": [1600, 1560],
41+
"om_cost_per_kw": 22,
42+
"notes": "Based on NREL utility benchmark of $1.56/WAC (adjusted for DC), EIA data shows 86% of utility capacity from systems >50MW"
3343
}
3444
]
3545
},
3646
"roof": {
3747
"size_classes": [
3848
{
3949
"size_class": 1,
50+
"name": "Residential",
4051
"tech_sizes_for_cost_curve": [0, 25],
41-
"installed_cost_per_kw": [2800, 2500],
42-
"om_cost_per_kw": 29
52+
"installed_cost_per_kw": [2680, 2400],
53+
"om_cost_per_kw": 30,
54+
"notes": "Based on 2023 NREL residential benchmark"
4355
},
4456
{
4557
"size_class": 2,
58+
"name": "Light Commercial",
4659
"tech_sizes_for_cost_curve": [25, 250],
47-
"installed_cost_per_kw": [2500, 2250],
48-
"om_cost_per_kw": 26
60+
"installed_cost_per_kw": [2300, 2000],
61+
"om_cost_per_kw": 24,
62+
"notes": "Higher than ground-mount due to roof mounting complexity"
4963
},
5064
{
5165
"size_class": 3,
66+
"name": "Heavy Commercial",
5267
"tech_sizes_for_cost_curve": [250, 750],
53-
"installed_cost_per_kw": [2250, 2050],
54-
"om_cost_per_kw": 24
68+
"installed_cost_per_kw": [2000, 1880],
69+
"om_cost_per_kw": 21,
70+
"notes": "~$0.10/W premium over ground-mount due to rooftop installation requirements"
5571
},
5672
{
5773
"size_class": 4,
58-
"tech_sizes_for_cost_curve": [750, 2000],
59-
"installed_cost_per_kw": [2050, 1850],
60-
"om_cost_per_kw": 22
74+
"name": "Large Commercial/Industrial",
75+
"tech_sizes_for_cost_curve": [750, 5000],
76+
"installed_cost_per_kw": [1880, 1750],
77+
"om_cost_per_kw": 21,
78+
"notes": "Examples include large warehouses and manufacturing facilities"
6179
},
6280
{
6381
"size_class": 5,
64-
"tech_sizes_for_cost_curve": [2000, 10000],
65-
"installed_cost_per_kw": [1850, 1600],
66-
"om_cost_per_kw": 20
82+
"name": "Mega-Rooftop",
83+
"tech_sizes_for_cost_curve": [5000, 35000],
84+
"installed_cost_per_kw": [1750, 1650],
85+
"om_cost_per_kw": 23,
86+
"notes": "Upper limit based on DSV Logistics (35MW) and Tesla Gigafactory (30MW) examples"
6787
}
6888
]
6989
}

src/core/pv.jl

+11-3
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ mutable struct PV <: AbstractTech
9999
operating_reserve_required_fraction
100100
size_class
101101
tech_sizes_for_cost_curve
102+
avg_electric_load_kw
102103

103104
function PV(;
104105
off_grid_flag::Bool = false,
@@ -149,7 +150,8 @@ mutable struct PV <: AbstractTech
149150
size_class::Union{Int, Nothing} = nothing,
150151
tech_sizes_for_cost_curve::AbstractVector = Float64[]
151152
)
152-
153+
@info "PV Constructor - Initial values:" avg_electric_load_kw array_type size_class
154+
153155
# Adjust operating_reserve_required_fraction based on off_grid_flag
154156
if !off_grid_flag && !(operating_reserve_required_fraction == 0.0)
155157
@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."
@@ -196,6 +198,7 @@ mutable struct PV <: AbstractTech
196198
if length(invalid_args) > 0
197199
throw(ErrorException("Invalid PV argument values: $(invalid_args)"))
198200
end
201+
@info "Before getting defaults:" avg_electric_load_kw array_type
199202

200203
# Get defaults structure
201204
pv_defaults_all = get_pv_defaults_size_class(array_type=array_type, avg_electric_load_kw=avg_electric_load_kw)
@@ -225,7 +228,7 @@ mutable struct PV <: AbstractTech
225228
elseif typeof(installed_cost_per_kw) <: Number || (installed_cost_per_kw isa AbstractVector && length(installed_cost_per_kw) == 1)
226229
# Case 2: Single cost value provided - size class not needed
227230
@info "Single cost value provided, size class not needed"
228-
nothing
231+
size_class
229232
elseif !isempty(tech_sizes_for_cost_curve) && isempty(installed_cost_per_kw)
230233
# Case 4: User provided tech curves but no costs, need size class for installed costs
231234
if isnothing(size_class)
@@ -361,7 +364,8 @@ mutable struct PV <: AbstractTech
361364
can_curtail,
362365
operating_reserve_required_fraction,
363366
size_class,
364-
tech_sizes_for_cost_curve
367+
tech_sizes_for_cost_curve,
368+
avg_electric_load_kw
365369
)
366370
end
367371
end
@@ -373,6 +377,8 @@ end
373377
# Helper function
374378

375379
function get_pv_defaults_size_class(; array_type::Int = 1, avg_electric_load_kw::Real = 0.0)
380+
@info "get_pv_defaults_size_class called with:" array_type avg_electric_load_kw
381+
376382
pv_defaults_path = joinpath(@__DIR__, "..", "..", "data", "pv", "pv_defaults.json")
377383
if !isfile(pv_defaults_path)
378384
throw(ErrorException("pv_defaults.json not found at path: $pv_defaults_path"))
@@ -389,6 +395,8 @@ end
389395
function get_pv_size_class(avg_electric_load_kw::Real, tech_sizes_for_cost_curve::AbstractVector;
390396
min_kw::Real=0.0, max_kw::Real=1.0e9, existing_kw::Real=0.0)
391397
# Adjust max_kw to account for existing capacity
398+
@info "get_pv_size_class called with:" avg_electric_load_kw min_kw max_kw existing_kw
399+
392400
adjusted_max_kw = max_kw - existing_kw
393401

394402
effective_size = if max_kw != 1.0e9

src/core/reopt_inputs.jl

+3-10
Original file line numberDiff line numberDiff line change
@@ -540,13 +540,7 @@ and all of the other arguments will be updated as well.
540540
"""
541541
function update_cost_curve!(tech::AbstractTech, tech_name::String, financial::Financial,
542542
cap_cost_slope, segmented_techs, n_segs_by_tech, seg_min_size, seg_max_size, seg_yint
543-
)
544-
if typeof(tech) == PV && (typeof(tech.installed_cost_per_kw) <: Number || length(tech.installed_cost_per_kw) == 1)
545-
# Handle single cost point for PV
546-
cap_cost_slope[tech_name] = first(tech.installed_cost_per_kw)
547-
return nothing
548-
end
549-
543+
)
550544
cost_slope, cost_curve_bp_x, cost_yint, n_segments = cost_curve(tech, financial)
551545
cap_cost_slope[tech_name] = first(cost_slope)
552546

@@ -566,7 +560,6 @@ function update_cost_curve!(tech::AbstractTech, tech_name::String, financial::Fi
566560
seg_yint[tech_name][s] = cost_yint[s]
567561
end
568562
end
569-
nothing
570563

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

598591
if isnothing(pv.size_class)
599592
pv.size_class = get_pv_size_class(
600-
pv.existing_kw,
593+
pv.avg_electric_load_kw,
601594
[c["tech_sizes_for_cost_curve"] for c in defaults],
602595
min_kw=pv.min_kw,
603596
max_kw=pv.max_kw,

src/results/financial.jl

+36-37
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,7 @@ function add_financial_results(m::JuMP.AbstractModel, p::REoptInputs, d::Dict; _
9696

9797
r["initial_capital_costs"] = initial_capex(m, p; _n=_n)
9898
future_replacement_cost, present_replacement_cost = replacement_costs_future_and_present(m, p; _n=_n)
99-
# Debugging: Print initial capital costs and replacement costs
100-
@info "Initial Capital Costs (Pre-Incentives): $(r["initial_capital_costs"])"
101-
@info "Present Replacement Cost: $(present_replacement_cost)"
102-
@info "Future Replacement Cost: $(future_replacement_cost)"
103-
10499
r["initial_capital_costs_after_incentives"] = r["lifecycle_capital_costs"] / p.third_party_factor - present_replacement_cost
105-
@info "Initial Capital Costs After Incentives (Third-Party Ownership): $(r["initial_capital_costs_after_incentives"])"
106100

107101
r["replacements_future_cost_after_tax"] = future_replacement_cost
108102
r["replacements_present_cost_after_tax"] = present_replacement_cost
@@ -136,33 +130,6 @@ end
136130
Calculate and return the up-front capital costs for all technologies, in present value, excluding replacement costs and
137131
incentives.
138132
"""
139-
140-
function get_pv_initial_capex(p::REoptInputs, pv::AbstractTech, size_kw::Float64)
141-
cost_list = pv.installed_cost_per_kw
142-
initial_capex = 0.0
143-
144-
if typeof(cost_list) <: Number
145-
initial_capex = cost_list * size_kw
146-
else
147-
size_list = pv.tech_sizes_for_cost_curve
148-
if size_kw <= size_list[1]
149-
initial_capex = cost_list[1] * size_kw
150-
elseif size_kw > size_list[end]
151-
initial_capex = cost_list[end] * size_kw
152-
else
153-
for s in 2:length(size_list)
154-
if (size_kw > size_list[s-1]) && (size_kw <= size_list[s])
155-
slope = (cost_list[s] * size_list[s] - cost_list[s-1] * size_list[s-1]) /
156-
(size_list[s] - size_list[s-1])
157-
initial_capex = cost_list[s-1] * size_list[s-1] + (size_kw - size_list[s-1]) * slope
158-
break
159-
end
160-
end
161-
end
162-
end
163-
return initial_capex
164-
end
165-
166133
function initial_capex(m::JuMP.AbstractModel, p::REoptInputs; _n="")
167134
initial_capex = 0
168135

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

306-
years = p.s.financial.analysis_years
273+
years = p.s.financial.analysis_years # length of financial life
274+
# TODO is most of this calculated in proforma metrics?
307275
if p.s.financial.third_party_ownership
308276
discount_rate_fraction = p.s.financial.owner_discount_rate_fraction
309277
federal_tax_rate_fraction = p.s.financial.owner_tax_rate_fraction
310278
else
311279
discount_rate_fraction = p.s.financial.offtaker_discount_rate_fraction
312280
federal_tax_rate_fraction = p.s.financial.offtaker_tax_rate_fraction
313281
end
314-
315282
capital_costs = get_pv_initial_capex(p, tech, new_kw)
316-
annual_om = new_kw * tech.om_cost_per_kw
283+
@info "Using initial cap cost: $(capital_costs) for lcoe calculation"
284+
285+
# capital_costs = new_kw * tech.installed_cost_per_kw # pre-incentive capital costs
286+
287+
annual_om = new_kw * tech.om_cost_per_kw
317288

318289
om_series = [annual_om * (1+p.s.financial.om_cost_escalation_rate_fraction)^yr for yr in 1:years]
319290
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
@@ -441,5 +412,33 @@ function get_chp_initial_capex(p::REoptInputs, size_kw::Float64)
441412
# initial_capex += chp_supp_firing_size * chp_supp_firing_cost
442413
end
443414

415+
return initial_capex
416+
end
417+
418+
419+
function get_pv_initial_capex(p::REoptInputs, pv::AbstractTech, size_kw::Float64)
420+
cost_list = pv.installed_cost_per_kw
421+
size_list = pv.tech_sizes_for_cost_curve
422+
pv_size = size_kw
423+
initial_capex = 0.0
424+
425+
if typeof(cost_list) == Vector{Float64}
426+
if pv_size <= size_list[1]
427+
initial_capex = pv_size * cost_list[1]
428+
elseif pv_size > size_list[end]
429+
initial_capex = pv_size * cost_list[end]
430+
else
431+
for s in 2:length(size_list)
432+
if (pv_size > size_list[s-1]) && (pv_size <= size_list[s])
433+
slope = (cost_list[s] * size_list[s] - cost_list[s-1] * size_list[s-1]) /
434+
(size_list[s] - size_list[s-1])
435+
initial_capex = cost_list[s-1] * size_list[s-1] + (pv_size - size_list[s-1]) * slope
436+
end
437+
end
438+
end
439+
else
440+
initial_capex = cost_list * pv_size
441+
end
442+
444443
return initial_capex
445444
end

0 commit comments

Comments
 (0)