Skip to content

Commit 548fb18

Browse files
authored
Merge pull request #40 from switchbox-data/27-add-scattershot-electrification
27 add scattershot electrification
2 parents 70a689a + 50ef4f0 commit 548fb18

File tree

8 files changed

+195
-122
lines changed

8 files changed

+195
-122
lines changed

run_example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
"peak_kw_winter_headroom": 10.0,
6969
"peak_kw_summer_headroom": 10.0,
7070
"aircon_percent_adoption_pre_npa": 0.8,
71-
"non_npa_scattershot_electrifiction_users_per_year": 5,
71+
"scattershot_electrification_users_per_year": 5,
7272
"gas_fixed_overhead_costs": 100.0,
7373
"electric_fixed_overhead_costs": 100.0,
7474
"gas_bau_lpp_costs_per_year": 100.0,

src/npa_howtopay/data/sample.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,16 @@ shared:
4444
time_series:
4545
npa_projects:
4646
project_year: [2025, 2025, 2026, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050]
47-
num_converts: [1.0e3, 0, 0, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3, 1.0e3]
47+
num_converts: [1000, 0, 0, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000]
4848
pipe_value_per_user: [30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3, 30.0e3]
4949
pipe_decomm_cost_per_user: [10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3, 10.0e3]
5050
peak_kw_winter_headroom: [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0]
5151
peak_kw_summer_headroom: [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0]
5252
aircon_percent_adoption_pre_npa: [0.8, 0.9, 1, 0.8, 0.9, 1, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.8]
5353
is_scattershot: [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false]
54+
scattershot_electrification_users_per_year:
55+
project_year: [2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050]
56+
num_converts: [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000]
5457
gas_fixed_overhead_costs:
5558
year: [2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039, 2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050]
5659
cost: [5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6, 5.0e6]

src/npa_howtopay/model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ def compute_bill_costs(
508508

509509

510510
def run_model(scenario_params: ScenarioParams, input_params: InputParams, ts_params: TimeSeriesParams) -> pl.DataFrame:
511+
# in the business-as-usual scenario, we don't have any npa projects. We maintain the scattershot electrification which will still reduce the number of gas customers and total gas usage but will not trigger grid upgrade or capex/opex for either utility.
511512
if scenario_params.bau:
512513
ts_params = evolve(ts_params, npa_projects=npa.return_empty_npa_df())
513514

src/npa_howtopay/npa_project.py

Lines changed: 22 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -44,78 +44,23 @@ def to_df(self) -> pl.DataFrame:
4444
})
4545

4646

47-
def generate_npa_projects(
48-
start_year: int,
49-
end_year: int,
50-
total_num_projects: int,
51-
num_converts_per_project: int,
52-
pipe_value_per_user: float,
53-
pipe_decomm_cost_per_user: float,
54-
peak_kw_winter_headroom_per_project: float,
55-
peak_kw_summer_headroom_per_project: float,
56-
aircon_percent_adoption_pre_npa: float,
57-
pipe_decomm_cost_inflation_rate: float = 0.0,
47+
def append_scattershot_electrification_df(
48+
npa_projects_df: pl.DataFrame,
49+
scattershot_electrification_df: pl.DataFrame,
5850
) -> pl.DataFrame:
5951
"""
60-
Generate a dataframe of NPA projects of length total_num_projects.
61-
The projects are distributed evenly across the years, with remainders added
62-
to earlier years. Note that there can be multiple project rows per year
63-
in the result
52+
Append a dataframe of scattershot electrification projects to the npa projects df.
53+
Scattershot electrification projects match the schema for npa projects, but will only affect the number of users and total electric usage, not anything related to pipe value or grid upgrades. It is meant to capture customers leaving the gas network independent of NPA projects.
6454
"""
65-
years = range(start_year, end_year + 1)
66-
num_years = len(years)
67-
base = total_num_projects // num_years
68-
remainder = total_num_projects % num_years
69-
70-
projects_per_year = np.full(num_years, base, dtype=int)
71-
projects_per_year[:remainder] += 1
72-
73-
project_years = [b for a in [[y] * r for y, r in zip(years, projects_per_year)] for b in a]
74-
pipe_decomm_costs = [
75-
pipe_decomm_cost_per_user * (1.0 + pipe_decomm_cost_inflation_rate) ** (y - start_year) for y in project_years
76-
]
77-
78-
return pl.DataFrame({
79-
"project_year": project_years,
80-
"num_converts": [num_converts_per_project] * total_num_projects,
81-
"pipe_value_per_user": [float(pipe_value_per_user)] * total_num_projects,
82-
"pipe_decomm_cost_per_user": pipe_decomm_costs,
83-
"peak_kw_winter_headroom": [float(peak_kw_winter_headroom_per_project)] * total_num_projects,
84-
"peak_kw_summer_headroom": [float(peak_kw_summer_headroom_per_project)] * total_num_projects,
85-
"aircon_percent_adoption_pre_npa": [float(aircon_percent_adoption_pre_npa)] * total_num_projects,
86-
"is_scattershot": [False] * total_num_projects,
87-
})
88-
89-
90-
def generate_scattershot_electrification_projects(
91-
start_year: int,
92-
end_year: int,
93-
total_num_converts: int,
94-
) -> pl.DataFrame:
95-
"""
96-
Generate a dataframe of scattershot electrification projects, one per year.
97-
The projects are distributed evenly across the years, with remainders added
98-
to earlier years. These match the schema for npa projects, but will only
99-
affect the number of users, not anything related to pipe value or grid upgrades
100-
"""
101-
years = range(start_year, end_year + 1)
102-
num_years = len(years)
103-
base = total_num_converts // num_years
104-
remainder = total_num_converts % num_years
105-
106-
converts_per_year = np.full(num_years, base, dtype=int)
107-
converts_per_year[:remainder] += 1
108-
109-
return pl.DataFrame({
110-
"project_year": years,
111-
"num_converts": converts_per_year,
112-
"pipe_value_per_user": [0.0] * num_years,
113-
"pipe_decomm_cost_per_user": [0.0] * num_years,
114-
"peak_kw_winter_headroom": [np.inf] * num_years,
115-
"peak_kw_summer_headroom": [np.inf] * num_years,
116-
"aircon_percent_adoption_pre_npa": [0.0] * num_years,
117-
"is_scattershot": [True] * num_years,
118-
})
55+
scattershot_with_npa_cols = scattershot_electrification_df.with_columns(
56+
pl.lit(0.0).alias("pipe_value_per_user"),
57+
pl.lit(0.0).alias("pipe_decomm_cost_per_user"),
58+
pl.lit(np.inf).alias("peak_kw_winter_headroom"),
59+
pl.lit(np.inf).alias("peak_kw_summer_headroom"),
60+
pl.lit(0.0).alias("aircon_percent_adoption_pre_npa"),
61+
pl.lit(True).alias("is_scattershot"),
62+
)
63+
return pl.concat([npa_projects_df, scattershot_with_npa_cols])
11964

12065

12166
def compute_hp_converts_from_df(year: int, df: pl.DataFrame, cumulative: bool = False, npa_only: bool = False) -> int:
@@ -182,12 +127,12 @@ def compute_pipe_decomm_cost_from_df(year: int, df: pl.DataFrame) -> float:
182127

183128
def return_empty_npa_df() -> pl.DataFrame:
184129
return pl.DataFrame({
185-
"project_year": [],
186-
"num_converts": [],
187-
"pipe_value_per_user": [],
188-
"pipe_decomm_cost_per_user": [],
189-
"peak_kw_winter_headroom": [],
190-
"peak_kw_summer_headroom": [],
191-
"aircon_percent_adoption_pre_npa": [],
192-
"is_scattershot": [],
130+
"project_year": pl.Series([], dtype=pl.Int64),
131+
"num_converts": pl.Series([], dtype=pl.Int64),
132+
"pipe_value_per_user": pl.Series([], dtype=pl.Float64),
133+
"pipe_decomm_cost_per_user": pl.Series([], dtype=pl.Float64),
134+
"peak_kw_winter_headroom": pl.Series([], dtype=pl.Float64),
135+
"peak_kw_summer_headroom": pl.Series([], dtype=pl.Float64),
136+
"aircon_percent_adoption_pre_npa": pl.Series([], dtype=pl.Float64),
137+
"is_scattershot": pl.Series([], dtype=pl.Boolean),
193138
})

src/npa_howtopay/params.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from ruamel.yaml import YAML
44
import polars as pl
55
from npa_howtopay.web_params import create_time_series_from_web_params, WebParams
6+
from npa_howtopay.npa_project import append_scattershot_electrification_df
67

78
# from npa_project import NpaProject
89
import os
@@ -123,10 +124,16 @@ def __attrs_post_init__(self) -> None:
123124
@define
124125
class TimeSeriesParams:
125126
npa_projects: pl.DataFrame
127+
scattershot_electrification: pl.DataFrame
126128
gas_fixed_overhead_costs: pl.DataFrame
127129
electric_fixed_overhead_costs: pl.DataFrame
128130
gas_bau_lpp_costs_per_year: pl.DataFrame
129131

132+
def __attrs_post_init__(self) -> None:
133+
"""Automatically append scattershot electrification to npa projects. In the BAU scenario, this will only return the scattershot electrification dataframe."""
134+
135+
self.npa_projects = append_scattershot_electrification_df(self.npa_projects, self.scattershot_electrification)
136+
130137

131138
@define
132139
class ScenarioParams:
@@ -183,6 +190,7 @@ def _load_time_series_params_from_yaml(yaml_path: str) -> TimeSeriesParams:
183190

184191
return TimeSeriesParams(
185192
npa_projects=pl.DataFrame(config["time_series"]["npa_projects"]),
193+
scattershot_electrification=pl.DataFrame(config["time_series"]["scattershot_electrification_users_per_year"]),
186194
gas_fixed_overhead_costs=pl.DataFrame(config["time_series"]["gas_fixed_overhead_costs"]),
187195
electric_fixed_overhead_costs=pl.DataFrame(config["time_series"]["electric_fixed_overhead_costs"]),
188196
gas_bau_lpp_costs_per_year=pl.DataFrame(config["time_series"]["gas_bau_lpp_costs_per_year"]),
@@ -230,6 +238,7 @@ def load_time_series_params_from_web_params(
230238

231239
return TimeSeriesParams(
232240
npa_projects=generated_data["npa_projects"],
241+
scattershot_electrification=generated_data["scattershot_electrification_users_per_year"],
233242
gas_fixed_overhead_costs=generated_data["gas_fixed_overhead_costs"],
234243
electric_fixed_overhead_costs=generated_data["electric_fixed_overhead_costs"],
235244
gas_bau_lpp_costs_per_year=generated_data["gas_bau_lpp_costs_per_year"],

src/npa_howtopay/web_params.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class WebParams:
1212
peak_kw_winter_headroom: float
1313
peak_kw_summer_headroom: float
1414
aircon_percent_adoption_pre_npa: float
15-
non_npa_scattershot_electrifiction_users_per_year: int
15+
scattershot_electrification_users_per_year: int
1616
gas_fixed_overhead_costs: float
1717
electric_fixed_overhead_costs: float
1818
gas_bau_lpp_costs_per_year: float
@@ -33,6 +33,25 @@ def create_npa_projects(web_params: WebParams, start_year: int, end_year: int) -
3333
})
3434

3535

36+
def create_scattershot_electrification_df(
37+
web_params: WebParams,
38+
start_year: int,
39+
end_year: int,
40+
) -> pl.DataFrame:
41+
"""
42+
Generate a dataframe of scattershot electrification projects, one per year.
43+
The projects are distributed evenly across the years. These match the schema for npa projects, but will only
44+
affect the number of users, not anything related to pipe value or grid upgrades
45+
"""
46+
years = range(start_year, end_year + 1)
47+
num_years = len(years)
48+
49+
return pl.DataFrame({
50+
"project_year": list(years),
51+
"num_converts": [web_params.scattershot_electrification_users_per_year] * num_years,
52+
})
53+
54+
3655
def create_gas_fixed_overhead_costs(
3756
web_params: WebParams, start_year: int, end_year: int, cost_inflation_rate: float = 0.0
3857
) -> pl.DataFrame:
@@ -84,6 +103,9 @@ def create_time_series_from_web_params(
84103
"""Create all time series DataFrames from web parameters"""
85104
return {
86105
"npa_projects": create_npa_projects(web_params, start_year, end_year),
106+
"scattershot_electrification_users_per_year": create_scattershot_electrification_df(
107+
web_params, start_year, end_year
108+
),
87109
"gas_fixed_overhead_costs": create_gas_fixed_overhead_costs(
88110
web_params, start_year, end_year, cost_inflation_rate
89111
),

tests/test_input_load.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import numpy as np
12
import polars as pl
23
import pytest
34
from polars.testing import assert_frame_equal
@@ -20,7 +21,7 @@ def web_params():
2021
"peak_kw_winter_headroom": 10.0,
2122
"peak_kw_summer_headroom": 10.0,
2223
"aircon_percent_adoption_pre_npa": 0.8,
23-
"non_npa_scattershot_electrifiction_users_per_year": 5,
24+
"scattershot_electrification_users_per_year": 5,
2425
"gas_fixed_overhead_costs": 100.0,
2526
"electric_fixed_overhead_costs": 100.0,
2627
"gas_bau_lpp_costs_per_year": 100.0,
@@ -86,14 +87,14 @@ def expected_electric_fixed_overhead_costs_with_inflation():
8687
def expected_npa_projects():
8788
"""Expected NPA projects for 2025-2030"""
8889
return pl.DataFrame({
89-
"project_year": [2025, 2026, 2027, 2028, 2029, 2030],
90-
"num_converts": [100, 100, 100, 100, 100, 100],
91-
"pipe_value_per_user": [1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0],
92-
"pipe_decomm_cost_per_user": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
93-
"peak_kw_winter_headroom": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
94-
"peak_kw_summer_headroom": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0],
95-
"aircon_percent_adoption_pre_npa": [0.8, 0.8, 0.8, 0.8, 0.8, 0.8],
96-
"is_scattershot": [False, False, False, False, False, False],
90+
"project_year": [2025, 2026, 2027, 2028, 2029, 2030, 2025, 2026, 2027, 2028, 2029, 2030],
91+
"num_converts": [100, 100, 100, 100, 100, 100, 5, 5, 5, 5, 5, 5],
92+
"pipe_value_per_user": [1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
93+
"pipe_decomm_cost_per_user": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
94+
"peak_kw_winter_headroom": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, np.inf, np.inf, np.inf, np.inf, np.inf, np.inf],
95+
"peak_kw_summer_headroom": [10.0, 10.0, 10.0, 10.0, 10.0, 10.0, np.inf, np.inf, np.inf, np.inf, np.inf, np.inf],
96+
"aircon_percent_adoption_pre_npa": [0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
97+
"is_scattershot": [False, False, False, False, False, False, True, True, True, True, True, True],
9798
})
9899

99100

@@ -174,7 +175,8 @@ def test_load_time_series_params_from_yaml():
174175
assert set(params.electric_fixed_overhead_costs.columns) == {"year", "cost"}
175176

176177
# Verify npa_projects df has correct shape and columns
177-
assert params.npa_projects.shape == (28, 8)
178+
# 28 rows from npa_projects + 26 rows from scattershot_electrification = 54 total rows
179+
assert params.npa_projects.shape == (54, 8)
178180
assert set(params.npa_projects.columns) == {
179181
"project_year",
180182
"num_converts",

0 commit comments

Comments
 (0)