Skip to content

Commit 950f51d

Browse files
authored
Merge pull request #7 from switchbox-data/capex_project_ticket
implement functions in capex_project
2 parents ded4c95 + d0b6ab9 commit 950f51d

File tree

8 files changed

+304
-33
lines changed

8 files changed

+304
-33
lines changed

.github/workflows/main.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ jobs:
2323
uses: ./.github/actions/setup-python-env
2424

2525
- name: Run checks
26-
run: make check
26+
run: |
27+
uv lock --locked
28+
uv run pre-commit run -a
29+
uv run mypy
30+
uv run deptry .
2731
2832
tests-and-type-check:
2933
runs-on: ubuntu-latest

mkdocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ plugins:
1515
- mkdocstrings:
1616
handlers:
1717
python:
18-
paths: ["default"]
18+
paths: ["src/npa_howtopay"]
1919
theme:
2020
name: material
2121
feature:

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ classifiers = [
1919
]
2020
dependencies = [
2121
"numpy>=2.0.2",
22+
"polars>=1.32.3",
2223
]
2324

2425
[project.urls]
@@ -70,7 +71,7 @@ line-length = 120
7071
fix = true
7172

7273
[tool.ruff.lint]
73-
exclude = ["**/capex_project.py", "**/model.py", "**/npa_project.py", "**/params.py", "**/utils.py", "**/web_params.py", "skeleton.pyi"] # TEMPORARY!!!!
74+
exclude = ["**/model.py", "**/npa_project.py", "**/params.py", "**/utils.py", "**/web_params.py", "skeleton.pyi"] # TEMPORARY!!!!
7475
select = [
7576
# flake8-2020
7677
"YTT",

src/npa_howtopay/capex_project.py

Lines changed: 131 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
from attrs import define
2-
from params import InputParams
3-
1+
import numpy as np
42
import polars as pl
53

64
## All dataframes used by functions in this class will have the following columns:
@@ -17,36 +15,145 @@ def get_synthetic_initial_capex_projects(
1715
total_weight = (depreciation_lifetime * (depreciation_lifetime + 1) / 2) / depreciation_lifetime
1816
est_original_cost_per_year = initial_ratebase / total_weight
1917
return pl.DataFrame({
20-
"project_year": range(start_year, start_year + depreciation_lifetime),
18+
"project_year": range(start_year - depreciation_lifetime + 1, start_year + 1),
2119
"original_cost": est_original_cost_per_year,
2220
"depreciation_lifetime": depreciation_lifetime,
2321
})
2422

2523

26-
def get_non_lpp_gas_capex_projects(year: int, input_params: InputParams) -> pl.DataFrame:
27-
pass
28-
29-
30-
def get_lpp_gas_capex_projects(year: int, input_params: InputParams, npas_this_year: NpaProject) -> pl.DataFrame:
31-
pass
32-
33-
34-
def get_non_npa_electric_capex_projects(year: int, input_params: InputParams) -> pl.DataFrame:
35-
pass
24+
def get_non_lpp_gas_capex_projects(
25+
year: int,
26+
current_ratebase: float,
27+
baseline_non_lpp_gas_ratebase_growth: float,
28+
depreciation_lifetime: int,
29+
) -> pl.DataFrame:
30+
return pl.DataFrame({
31+
"project_year": year,
32+
"original_cost": current_ratebase * baseline_non_lpp_gas_ratebase_growth,
33+
"depreciation_lifetime": depreciation_lifetime,
34+
})
3635

3736

38-
def get_grid_upgrade_capex_projects(year: int, input_params: InputParams, npas_projects: pl.DataFrame) -> pl.DataFrame:
39-
pass
37+
def get_lpp_gas_capex_projects(
38+
year: int,
39+
gas_bau_lpp_costs_per_year: pl.DataFrame,
40+
npas_this_year: pl.DataFrame,
41+
depreciation_lifetime: int,
42+
) -> pl.DataFrame:
43+
"""
44+
Inputs:
45+
- year: int
46+
- gas_bau_lpp_costs_per_year: pl.DataFrame
47+
- columns: year, cost
48+
- year is not required to be unique
49+
- npas_this_year: pl.DataFrame
50+
- npa columns
51+
- depreciation_lifetime: int
52+
53+
Outputs:
54+
- pl.DataFrame
55+
- capex project columns
56+
"""
57+
assert all(npas_this_year["year"] == year) # noqa: S101
58+
npa_pipe_costs_avoided = npas_this_year.select(pl.col("pipe_value_per_user") * pl.col("num_converts")).sum().item()
59+
bau_pipe_replacement_costs = (
60+
gas_bau_lpp_costs_per_year.filter(pl.col("year") == year).select(pl.col("cost")).sum().item()
61+
)
62+
remaining_pipe_replacement_cost = np.maximum(0, bau_pipe_replacement_costs - npa_pipe_costs_avoided)
63+
if remaining_pipe_replacement_cost > 0:
64+
return pl.DataFrame({
65+
"project_year": year,
66+
"original_cost": remaining_pipe_replacement_cost,
67+
"depreciation_lifetime": depreciation_lifetime,
68+
})
69+
else:
70+
return pl.DataFrame()
71+
72+
73+
def get_non_npa_electric_capex_projects(
74+
year: int,
75+
current_ratebase: float,
76+
baseline_electric_ratebase_growth: float,
77+
depreciation_lifetime: int,
78+
) -> pl.DataFrame:
79+
return pl.DataFrame({
80+
"project_year": year,
81+
"original_cost": current_ratebase * baseline_electric_ratebase_growth,
82+
"depreciation_lifetime": depreciation_lifetime,
83+
})
4084

4185

42-
def get_npa_capex_projects(year: int, input_params: InputParams, npas_projects: pl.DataFrame) -> pl.DataFrame:
43-
pass
86+
def get_grid_upgrade_capex_projects(
87+
year: int,
88+
npas_this_year: pl.DataFrame,
89+
peak_hp_kw: float,
90+
peak_aircon_kw: float,
91+
distribution_cost_per_peak_kw_increase: float,
92+
grid_upgrade_depreciation_lifetime: int,
93+
) -> pl.DataFrame:
94+
assert all(npas_this_year["year"] == year) # noqa: S101
95+
peak_kw_increase = (
96+
npas_this_year.select(
97+
pl.max_horizontal(
98+
pl.max_horizontal(
99+
pl.col("num_converts") * pl.lit(peak_hp_kw) - pl.col("peak_kw_winter_headroom"), pl.lit(0)
100+
),
101+
pl.max_horizontal(
102+
pl.col("num_converts") * (1 - pl.col("aircon_percent_adoption_pre_npa")) * pl.lit(peak_aircon_kw)
103+
- pl.col("peak_kw_summer_headroom"),
104+
pl.lit(0),
105+
),
106+
)
107+
)
108+
.sum()
109+
.item()
110+
)
111+
if peak_kw_increase > 0:
112+
return pl.DataFrame({
113+
"project_year": year,
114+
"original_cost": peak_kw_increase * distribution_cost_per_peak_kw_increase,
115+
"depreciation_lifetime": grid_upgrade_depreciation_lifetime,
116+
})
117+
else:
118+
return pl.DataFrame()
119+
120+
121+
def get_npa_capex_projects(
122+
year: int, npas_this_year: pl.DataFrame, npa_install_cost: float, npa_lifetime: int
123+
) -> pl.DataFrame:
124+
assert all(npas_this_year["year"] == year) # noqa: S101
125+
npa_total_cost = npa_install_cost * npas_this_year.select(pl.col("num_converts")).sum().item()
126+
if npa_total_cost > 0:
127+
return pl.DataFrame({
128+
"project_year": year,
129+
"original_cost": npa_total_cost,
130+
"depreciation_lifetime": npa_lifetime,
131+
})
132+
else:
133+
return pl.DataFrame()
44134

45135

46136
# functions for computing things given a dataframe of capex projects
47-
def compute_ratebase_from_capex_projects(df: pl.DataFrame, year: int) -> float:
48-
pass
49-
50-
51-
def compute_depreciation_expense_from_capex_projects(df: pl.DataFrame, year: int) -> float:
52-
pass
137+
def compute_ratebase_from_capex_projects(year: int, df: pl.DataFrame) -> float:
138+
df = df.with_columns(
139+
pl.when(pl.lit(year) < pl.col("project_year"))
140+
.then(pl.lit(0))
141+
.otherwise((1 - (pl.lit(year) - pl.col("project_year")) / pl.col("depreciation_lifetime")).clip(lower_bound=0))
142+
.alias("depreciation_fraction")
143+
)
144+
return float(df.select(pl.col("depreciation_fraction") * pl.col("original_cost")).sum().item())
145+
146+
147+
def compute_depreciation_expense_from_capex_projects(year: int, df: pl.DataFrame) -> float:
148+
return float(
149+
df.select(
150+
pl.when(
151+
(pl.lit(year) > pl.col("project_year"))
152+
& (pl.lit(year) <= pl.col("project_year") + pl.col("depreciation_lifetime"))
153+
)
154+
.then(pl.col("original_cost") / pl.col("depreciation_lifetime"))
155+
.otherwise(pl.lit(0))
156+
)
157+
.sum()
158+
.item()
159+
)

src/npa_howtopay/npa_project.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
# num_converts: int
99
# pipe_value_per_user: float
1010
# pipe_decomm_cost_per_user: float
11+
# peak_kw_winter_headroom: float
12+
# peak_kw_summer_headroom: float
13+
# aircon_percent_adoption_pre_npa: float
1114

1215
# note: could represent scattershot electrification as a row with pipe_value_per_user and pipe_decomm_cost_per_user set to 0
1316

tests/test_capex_project.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
## Switchbox
2+
## 2025-08-25
3+
4+
import numpy as np
5+
import polars as pl
6+
from polars.testing import assert_frame_equal
7+
8+
from src.npa_howtopay.capex_project import (
9+
compute_depreciation_expense_from_capex_projects,
10+
compute_ratebase_from_capex_projects,
11+
get_grid_upgrade_capex_projects,
12+
get_lpp_gas_capex_projects,
13+
get_non_lpp_gas_capex_projects,
14+
get_non_npa_electric_capex_projects,
15+
get_npa_capex_projects,
16+
get_synthetic_initial_capex_projects,
17+
)
18+
19+
20+
def test_get_synthetic_initial_capex_projects():
21+
# 3000 * (1 + 2/3 + 1/3) = 6000
22+
df = get_synthetic_initial_capex_projects(start_year=2025, initial_ratebase=6000, depreciation_lifetime=3)
23+
ref_df = pl.DataFrame({
24+
"project_year": [2023, 2024, 2025],
25+
"original_cost": [3000, 3000, 3000],
26+
"depreciation_lifetime": [3, 3, 3],
27+
})
28+
print(df)
29+
print(ref_df)
30+
assert_frame_equal(ref_df, df, check_dtypes=False)
31+
32+
33+
def test_get_non_lpp_gas_capex_projects():
34+
df = get_non_lpp_gas_capex_projects(
35+
year=2025, current_ratebase=1000, baseline_non_lpp_gas_ratebase_growth=0.015, depreciation_lifetime=60
36+
)
37+
ref_df = pl.DataFrame({
38+
"project_year": [2025],
39+
"original_cost": [15],
40+
"depreciation_lifetime": [60],
41+
})
42+
assert_frame_equal(ref_df, df, check_dtypes=False)
43+
44+
45+
def test_get_non_npa_electric_capex_projects():
46+
df = get_non_npa_electric_capex_projects(
47+
year=2025, current_ratebase=1000, baseline_electric_ratebase_growth=0.03, depreciation_lifetime=60
48+
)
49+
ref_df = pl.DataFrame({
50+
"project_year": [2025],
51+
"original_cost": [30],
52+
"depreciation_lifetime": [60],
53+
})
54+
assert_frame_equal(ref_df, df, check_dtypes=False)
55+
56+
57+
## NPA TESTS
58+
npas_this_year = pl.DataFrame({
59+
"year": [2025, 2025, 2025],
60+
"num_converts": [10, 20, 5], # 35 total
61+
"pipe_value_per_user": [1000, 100, 3000], # $27_000 total
62+
"pipe_decomm_cost_per_user": [100, 200, 100], # 5_500; does not affect capex?
63+
"peak_kw_winter_headroom": [10, 100, 1],
64+
"peak_kw_summer_headroom": [10, 1, 10],
65+
"aircon_percent_adoption_pre_npa": [0.2, 0.8, 0.8],
66+
})
67+
68+
69+
def test_get_lpp_gas_capex_projects():
70+
lpp_costs_standard = pl.DataFrame({
71+
"year": [2025, 2025, 2026],
72+
"cost": [30000, 20000, 30000], # 50k in 2025
73+
})
74+
df = get_lpp_gas_capex_projects(
75+
year=2025,
76+
gas_bau_lpp_costs_per_year=lpp_costs_standard,
77+
npas_this_year=npas_this_year,
78+
depreciation_lifetime=60,
79+
)
80+
ref_df = pl.DataFrame({
81+
"project_year": [2025],
82+
"original_cost": [23000],
83+
"depreciation_lifetime": [60],
84+
})
85+
assert_frame_equal(ref_df, df, check_dtypes=False)
86+
87+
# test zero bound
88+
lpp_costs_small = pl.DataFrame({
89+
"year": [2025, 2025, 2026],
90+
"cost": [100, 200, 300], # 300 in 2025
91+
})
92+
df_zero = get_lpp_gas_capex_projects(
93+
year=2025, gas_bau_lpp_costs_per_year=lpp_costs_small, npas_this_year=npas_this_year, depreciation_lifetime=60
94+
)
95+
assert df_zero.is_empty()
96+
97+
98+
def test_get_grid_upgrade_capex_projects():
99+
df = get_grid_upgrade_capex_projects(
100+
year=2025,
101+
npas_this_year=npas_this_year,
102+
peak_hp_kw=2,
103+
peak_aircon_kw=3,
104+
distribution_cost_per_peak_kw_increase=1000,
105+
grid_upgrade_depreciation_lifetime=30,
106+
)
107+
ref_df = pl.DataFrame({
108+
"project_year": [2025],
109+
"original_cost": [34000], # three projects increase peak_kw by 14, 11, 9
110+
"depreciation_lifetime": [30],
111+
})
112+
assert_frame_equal(ref_df, df, check_dtypes=False)
113+
114+
115+
def test_get_npa_capex_projects():
116+
df = get_npa_capex_projects(year=2025, npas_this_year=npas_this_year, npa_install_cost=1000, npa_lifetime=10)
117+
ref_df = pl.DataFrame({
118+
"project_year": [2025],
119+
"original_cost": [35000],
120+
"depreciation_lifetime": [10],
121+
})
122+
assert_frame_equal(ref_df, df, check_dtypes=False)
123+
124+
125+
## COMPUTE TESTS
126+
capex_df = pl.DataFrame({
127+
"project_year": [2025, 2026, 2027],
128+
"original_cost": [1000, 1000, 1000],
129+
"depreciation_lifetime": [10, 20, 10],
130+
})
131+
132+
133+
def test_compute_ratebase_from_capex_projects():
134+
ratebases = [compute_ratebase_from_capex_projects(year, capex_df) for year in [2025, 2026, 2027, 2028, 2045, 2046]]
135+
assert np.isclose(ratebases, [1000, 1900, 2750, 2500, 50, 0]).all()
136+
137+
138+
def test_compute_depreciation_expense_from_capex_projects():
139+
depreciations = [
140+
compute_depreciation_expense_from_capex_projects(year, capex_df)
141+
for year in [2025, 2026, 2027, 2028, 2045, 2046, 2047]
142+
]
143+
assert np.isclose(depreciations, [0, 100, 150, 250, 50, 50, 0]).all()

tests/test_foo.py

Lines changed: 0 additions & 5 deletions
This file was deleted.

0 commit comments

Comments
 (0)