Skip to content

Commit 28b6674

Browse files
authored
Merge pull request #39 from switchbox-data/30-modify-ratebase-growth-calc
30 modify ratebase growth calc
2 parents 04613a5 + 7726577 commit 28b6674

File tree

3 files changed

+181
-25
lines changed

3 files changed

+181
-25
lines changed

src/npa_howtopay/capex_project.py

Lines changed: 161 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,27 @@ def to_df(self) -> pl.DataFrame:
3535
})
3636

3737

38-
# functions for generating dataframe rows for capex projects
3938
def get_synthetic_initial_capex_projects(
4039
start_year: int, initial_ratebase: float, depreciation_lifetime: int
4140
) -> pl.DataFrame:
41+
"""
42+
Generate synthetic capex projects to represent the projects that make up the initial ratebase.
43+
44+
Creates a series of historical capex projects that would result in the given initial ratebase value, assuming straight-line depreciation. Uses the triangular number formula to create a uniform distribution of projects over the depreciation lifetime, where each project has the same original cost. Projects are distributed evenly over depreciation_lifetime years leading up to start_year.
45+
46+
Args:
47+
start_year: The first year of the model
48+
initial_ratebase: The target ratebase value at start_year
49+
depreciation_lifetime: The blended depreciation lifetime for the synthetic projects
50+
51+
Returns:
52+
pl.DataFrame with columns:
53+
- project_year: Year the project was initiated
54+
- project_type: "synthetic_initial"
55+
- original_cost: Cost of the project
56+
- depreciation_lifetime: Depreciation lifetime in years
57+
- retirement_year: Year the project is fully depreciated
58+
"""
4259
total_weight = (depreciation_lifetime * (depreciation_lifetime + 1) / 2) / depreciation_lifetime
4360
est_original_cost_per_year = initial_ratebase / total_weight
4461
project_years = range(start_year - depreciation_lifetime + 1, start_year + 1)
@@ -56,11 +73,34 @@ def get_non_lpp_gas_capex_projects(
5673
current_ratebase: float,
5774
baseline_non_lpp_gas_ratebase_growth: float,
5875
depreciation_lifetime: int,
76+
construction_inflation_rate: float,
5977
) -> pl.DataFrame:
78+
"""
79+
Generate capex projects for non-LPP (non-leak prone pipe) gas infrastructure.
80+
81+
These represent routine gas infrastructure investments not related to pipe replacement or npas,
82+
such as meter replacements, regulator stations, etc. The cost is calculated as a
83+
percentage of current ratebase, adjusted for construction cost inflation.
84+
85+
Args:
86+
year: The year to generate projects for
87+
current_ratebase: Current value of the gas utility's ratebase
88+
baseline_non_lpp_gas_ratebase_growth: Annual growth rate for non-LPP capex as fraction of ratebase
89+
depreciation_lifetime: Blended depreciation lifetime in years for these projects
90+
construction_inflation_rate: Annual inflation rate for construction costs
91+
92+
Returns:
93+
pl.DataFrame with columns:
94+
- project_year: Year the project was initiated
95+
- project_type: "misc" for miscellaneous gas infrastructure
96+
- original_cost: Cost of the project
97+
- depreciation_lifetime: Depreciation lifetime in years
98+
- retirement_year: Year the project is fully depreciated
99+
"""
60100
return CapexProject(
61101
project_year=year,
62102
project_type="misc",
63-
original_cost=current_ratebase * baseline_non_lpp_gas_ratebase_growth,
103+
original_cost=current_ratebase * baseline_non_lpp_gas_ratebase_growth * (1 + construction_inflation_rate),
64104
depreciation_lifetime=depreciation_lifetime,
65105
).to_df()
66106

@@ -72,18 +112,29 @@ def get_lpp_gas_capex_projects(
72112
depreciation_lifetime: int,
73113
) -> pl.DataFrame:
74114
"""
75-
Inputs:
76-
- year: int
77-
- gas_bau_lpp_costs_per_year: pl.DataFrame
78-
- columns: year, cost
79-
- year is not required to be unique
80-
- npa_projects: pl.DataFrame
81-
- npa columns
82-
- depreciation_lifetime: int
83-
84-
Outputs:
85-
- pl.DataFrame
86-
- capex project columns
115+
Generate capex projects for leak-prone pipe (LPP) replacement in the gas system.
116+
117+
This function calculates the remaining pipe replacement costs after accounting for pipe
118+
replacements avoided by NPA projects. If NPAs avoid all planned pipe replacements in a given
119+
year, returns an empty dataframe.
120+
121+
Args:
122+
year: The year to generate projects for
123+
gas_bau_lpp_costs_per_year: DataFrame containing business-as-usual pipe replacement costs
124+
with columns:
125+
- year: Year of planned replacement
126+
- cost: Cost of planned replacement
127+
Note: Multiple entries may exist per year
128+
npa_projects: DataFrame containing NPA project details, used to calculate avoided pipe costs
129+
depreciation_lifetime: Depreciation lifetime in years for pipe replacement projects
130+
131+
Returns:
132+
pl.DataFrame with columns:
133+
- project_year: Year the project was initiated
134+
- project_type: "pipeline" for pipe replacement projects
135+
- original_cost: Cost of the project after subtracting avoided costs
136+
- depreciation_lifetime: Depreciation lifetime in years
137+
- retirement_year: Year the project is fully depreciated
87138
"""
88139
npas_this_year = npa_projects.filter(pl.col("project_year") == year)
89140
npa_pipe_costs_avoided = compute_npa_pipe_cost_avoided_from_df(year, npas_this_year)
@@ -107,11 +158,33 @@ def get_non_npa_electric_capex_projects(
107158
current_ratebase: float,
108159
baseline_electric_ratebase_growth: float,
109160
depreciation_lifetime: int,
161+
construction_inflation_rate: float,
110162
) -> pl.DataFrame:
163+
"""
164+
Generate capex projects for non-NPA non-grid upgrade electric system upgrades.
165+
166+
This function calculates the baseline capital expenditures for the electric system,
167+
excluding NPA-related projects. The expenditure grows with both the baseline growth rate
168+
and construction inflation.
169+
170+
Args:
171+
year: The year to generate projects for
172+
current_ratebase: Current value of the electric utility's ratebase
173+
baseline_electric_ratebase_growth: Annual growth rate of non-NPA electric capex as fraction of ratebase
174+
depreciation_lifetime: Blended depreciation lifetime in years for electric system projects
175+
construction_inflation_rate: Annual inflation rate for construction costs
176+
177+
Returns:
178+
pl.DataFrame with columns:
179+
- project_year: Year the project was initiated
180+
- project_type: "misc" for miscellaneous electric system upgrades
181+
- original_cost: Cost of the project including construction inflation
182+
- depreciation_lifetime: Depreciation lifetime in years
183+
"""
111184
return CapexProject(
112185
project_year=year,
113186
project_type="misc",
114-
original_cost=current_ratebase * baseline_electric_ratebase_growth,
187+
original_cost=current_ratebase * baseline_electric_ratebase_growth * (1 + construction_inflation_rate),
115188
depreciation_lifetime=depreciation_lifetime,
116189
).to_df()
117190

@@ -124,6 +197,28 @@ def get_grid_upgrade_capex_projects(
124197
distribution_cost_per_peak_kw_increase: float,
125198
grid_upgrade_depreciation_lifetime: int,
126199
) -> pl.DataFrame:
200+
"""
201+
Generate capex projects for grid upgrades needed to support NPA installations.
202+
203+
This function calculates the required grid upgrades based on the peak power increase
204+
from heat pumps and air conditioners installed as part of NPA projects. The cost
205+
scales linearly with the total peak power increase.
206+
207+
Args:
208+
year: The year to generate projects for
209+
npa_projects: DataFrame containing NPA project details
210+
peak_hp_kw: Peak power draw in kW for a heat pump
211+
peak_aircon_kw: Peak power draw in kW for an air conditioner
212+
distribution_cost_per_peak_kw_increase: Cost per kW of increasing grid capacity in year of project
213+
grid_upgrade_depreciation_lifetime: Depreciation lifetime in years for grid upgrades
214+
215+
Returns:
216+
pl.DataFrame with columns:
217+
- project_year: Year the project was initiated
218+
- project_type: "grid_upgrade" for grid capacity upgrades
219+
- original_cost: Total cost of grid upgrades
220+
- depreciation_lifetime: Depreciation lifetime in years
221+
"""
127222
npas_this_year = npa_projects.filter(pl.col("project_year") == year)
128223
peak_kw_increase = compute_peak_kw_increase_from_df(year, npas_this_year, peak_hp_kw, peak_aircon_kw)
129224
if peak_kw_increase > 0:
@@ -140,6 +235,25 @@ def get_grid_upgrade_capex_projects(
140235
def get_npa_capex_projects(
141236
year: int, npa_projects: pl.DataFrame, npa_install_cost: float, npa_lifetime: int
142237
) -> pl.DataFrame:
238+
"""
239+
Generate capex projects for NPA (non-pipe alternative) installations.
240+
241+
This function calculates the capital costs associated with installing NPAs in a given year.
242+
The total cost is based on the number of heat pump conversions and the per-unit installation cost.
243+
244+
Args:
245+
year: The year to generate projects for
246+
npa_projects: DataFrame containing NPA project details
247+
npa_install_cost: Cost per household of installing an NPA
248+
npa_lifetime: Expected lifetime in years of an NPA installation
249+
250+
Returns:
251+
pl.DataFrame with columns:
252+
- project_year: Year the project was initiated
253+
- project_type: "npa" for NPA installations
254+
- original_cost: Total cost of NPA installations
255+
- depreciation_lifetime: Depreciation lifetime in years
256+
"""
143257
npas_this_year = npa_projects.filter(pl.col("project_year") == year)
144258
npa_total_cost = npa_install_cost * compute_hp_converts_from_df(
145259
year, npas_this_year, cumulative=False, npa_only=True
@@ -152,8 +266,24 @@ def get_npa_capex_projects(
152266
return return_empty_capex_df()
153267

154268

155-
# functions for computing things given a dataframe of capex projects
156269
def compute_ratebase_from_capex_projects(year: int, df: pl.DataFrame) -> float:
270+
"""Compute the ratebase value for a given year from capital projects.
271+
272+
For each project, the ratebase value declines linearly from the original cost to zero over the depreciation lifetime.
273+
Projects that haven't started yet (year < project_year) have zero ratebase value.
274+
Projects that are fully depreciated have zero ratebase value.
275+
Projects that are in the year of the project have the full original cost added to the ratebase.
276+
277+
Args:
278+
year: The year to compute ratebase for
279+
df: DataFrame containing capital projects with columns:
280+
- project_year: int - Year project was initiated
281+
- original_cost: float - Original cost of the project
282+
- depreciation_lifetime: int - Number of years to depreciate over
283+
284+
Returns:
285+
float: Total ratebase value for the year across all projects
286+
"""
157287
df = df.with_columns(
158288
pl.when(pl.lit(year) < pl.col("project_year"))
159289
.then(pl.lit(0))
@@ -164,6 +294,21 @@ def compute_ratebase_from_capex_projects(year: int, df: pl.DataFrame) -> float:
164294

165295

166296
def compute_depreciation_expense_from_capex_projects(year: int, df: pl.DataFrame) -> float:
297+
"""Compute annual depreciation expense for capital projects.
298+
299+
For each project, depreciation expense is the original cost divided evenly over the depreciation lifetime.
300+
Depreciation starts the year after the project year and continues for the depreciation lifetime.
301+
302+
Args:
303+
year: The year to compute depreciation expense for
304+
df: DataFrame containing capital projects with columns:
305+
- project_year: int - Year project was initiated
306+
- original_cost: float - Original cost of the project
307+
- depreciation_lifetime: int - Number of years to depreciate over
308+
309+
Returns:
310+
float: Total depreciation expense for the year across all projects
311+
"""
167312
return float(
168313
df.select(
169314
pl.when(

src/npa_howtopay/model.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,7 @@ def run_model(scenario_params: ScenarioParams, input_params: InputParams, ts_par
548548
current_ratebase=gas_ratebase,
549549
baseline_non_lpp_gas_ratebase_growth=input_params.gas.baseline_non_lpp_ratebase_growth,
550550
depreciation_lifetime=input_params.gas.non_lpp_depreciation_lifetime,
551+
construction_inflation_rate=input_params.shared.construction_inflation_rate,
551552
),
552553
cp.get_lpp_gas_capex_projects(
553554
year=year,
@@ -568,6 +569,7 @@ def run_model(scenario_params: ScenarioParams, input_params: InputParams, ts_par
568569
current_ratebase=electric_ratebase,
569570
baseline_electric_ratebase_growth=input_params.electric.baseline_non_npa_ratebase_growth,
570571
depreciation_lifetime=input_params.electric.default_depreciation_lifetime,
572+
construction_inflation_rate=input_params.shared.construction_inflation_rate,
571573
),
572574
cp.get_grid_upgrade_capex_projects(
573575
year=year,

tests/test_capex_project.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
## Switchbox
2-
## 2025-08-25
1+
# Switchbox
2+
# 2025-08-25
33

44
import numpy as np
55
import polars as pl
@@ -39,12 +39,16 @@ def test_get_synthetic_initial_capex_projects():
3939

4040
def test_get_non_lpp_gas_capex_projects():
4141
df = get_non_lpp_gas_capex_projects(
42-
year=2025, current_ratebase=1000, baseline_non_lpp_gas_ratebase_growth=0.015, depreciation_lifetime=60
42+
year=2025,
43+
current_ratebase=1000,
44+
baseline_non_lpp_gas_ratebase_growth=0.015,
45+
depreciation_lifetime=60,
46+
construction_inflation_rate=0.02,
4347
)
4448
ref_df = pl.DataFrame({
4549
"project_year": [2025],
4650
"project_type": ["misc"],
47-
"original_cost": [15],
51+
"original_cost": [15.3],
4852
"depreciation_lifetime": [60],
4953
"retirement_year": [2085],
5054
})
@@ -53,19 +57,23 @@ def test_get_non_lpp_gas_capex_projects():
5357

5458
def test_get_non_npa_electric_capex_projects():
5559
df = get_non_npa_electric_capex_projects(
56-
year=2025, current_ratebase=1000, baseline_electric_ratebase_growth=0.03, depreciation_lifetime=60
60+
year=2025,
61+
current_ratebase=1000,
62+
baseline_electric_ratebase_growth=0.03,
63+
depreciation_lifetime=60,
64+
construction_inflation_rate=0.02,
5765
)
5866
ref_df = pl.DataFrame({
5967
"project_year": [2025],
6068
"project_type": ["misc"],
61-
"original_cost": [30],
69+
"original_cost": [30.6],
6270
"depreciation_lifetime": [60],
6371
"retirement_year": [2085],
6472
})
6573
assert_frame_equal(ref_df, df, check_dtypes=False)
6674

6775

68-
## NPA TESTS
76+
# NPA TESTS
6977
@pytest.fixture
7078
def npa_projects():
7179
return pl.concat(
@@ -148,7 +156,8 @@ def test_get_grid_upgrade_capex_projects(npa_projects):
148156
ref_df = pl.DataFrame({
149157
"project_year": [2025],
150158
"project_type": ["grid_upgrade"],
151-
"original_cost": [34000], # three projects increase peak_kw by 14, 11, 9
159+
# three projects increase peak_kw by 14, 11, 9
160+
"original_cost": [34000],
152161
"depreciation_lifetime": [30],
153162
"retirement_year": [2055],
154163
})
@@ -167,7 +176,7 @@ def test_get_npa_capex_projects(npa_projects):
167176
assert_frame_equal(ref_df, df, check_dtypes=False)
168177

169178

170-
## COMPUTE TESTS
179+
# COMPUTE TESTS
171180
capex_df = pl.DataFrame({
172181
"project_year": [2025, 2026, 2027],
173182
"original_cost": [1000, 1000, 1000],

0 commit comments

Comments
 (0)