Skip to content

Commit 62769ac

Browse files
authored
Merge pull request #700 from johnjasa/per_year_grid_feedstock
Add per-year pricing support for Grid and Feedstock cost models
2 parents d7821ce + 2823c1a commit 62769ac

7 files changed

Lines changed: 347 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- Added electricity and water consumption profiles as outputs to the `ECOElectrolyzerPerformanceModel` [PR 690](https://github.com/NatLabRockies/H2Integrate/pull/690)
1313
- Add `PeakLoadManagementHeuristicOpenLoopStorageController` as a storage control strategy. [PR 641](https://github.com/NatLabRockies/H2Integrate/pull/641)
1414
- Minor cleanup to `pose_optimization` [PR 695](https://github.com/NatLabRockies/H2Integrate/pull/695)
15+
- Add per-year pricing support for Grid and Feedstock cost models, allowing price arrays of length `plant_life` in addition to scalar and per-timestep arrays. [PR 700](https://github.com/NatLabRockies/H2Integrate/pull/700)
1516
- Added ability to have a custom/user-specified resource model [PR 698](https://github.com/NatLabRockies/H2Integrate/pull/698)
1617
- Add `{commodity}_set_point` as an input to hydrogen fuel cell model [PR 709](https://github.com/NatLabRockies/H2Integrate/pull/709)
1718
- Rename `n_control_window` to `n_control_window_hours` for unit clarity [PR 712](https://github.com/NatLabRockies/H2Integrate/pull/712)

docs/technology_models/feedstocks.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,15 @@ ng_feedstock:
7373
- `commodity_rate_units` (str): Must match the performance model commodity_rate_units
7474
- `price` (float, int, or list): Cost per unit in `USD/commodity_amount_units`. Can be:
7575
- Scalar: Constant price for all timesteps and years
76-
- List: Price per timestep
76+
- List of length `n_timesteps`: Price per timestep, applied uniformly across all years
77+
- List of length `plant_life`: Price per year of plant operation
7778
- `annual_cost` (float, optional): Fixed cost per year in USD/year. Defaults to 0.0
7879
- `start_up_cost` (float, optional): One-time capital cost in USD. Defaults to 0.0
7980
- `cost_year` (int): Dollar year for cost inputs
8081
- `commodity_amount_units` (str | None, optional): the amount units of the commodity (i.e., "MMBtu", "kg", "galUS" or "kW*h"). If None, will be set as `commodity_rate_units*h`
8182

8283
```{tip}
83-
The `price` parameter is flexible - you can specify constant pricing with a single value or time-varying pricing with an array of values matching the number of simulation timesteps.
84+
The `price` parameter is flexible - you can specify constant pricing with a single value, time-varying pricing with an array of length `n_timesteps`, or per-year pricing with an array of length `plant_life`. When `n_timesteps == plant_life`, the per-year interpretation will be used and a warning is issued for clarity.
8485
```
8586

8687
### Consumed Feedstock Outputs

docs/technology_models/grid.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,14 @@ Multiple grid instances may be used within the same plant to represent different
4848
| `interconnection_capex_per_kw` | scalar | $/kW | Capital cost per kW of interconnection. |
4949
| `interconnection_opex_per_kw` | scalar | $/kW/year | Annual O&M cost per kW of interconnection. |
5050
| `fixed_interconnection_cost` | scalar | $ | One-time fixed cost regardless of size. |
51-
| `electricity_out` | array[n_timesteps] | kW | Electricity flowing out of grid (buying from grid). |
52-
| `electricity_buy_price` | scalar/array[n_timesteps] | $/kWh | Price to buy electricity from grid (optional, time-varying supported). |
53-
| `electricity_sold` | array[n_timesteps] | kW | Electricity flowing into grid (selling to grid). |
54-
| `electricity_sell_price` | scalar/array[n_timesteps] | $/kWh | Price to sell electricity to grid (optional, time-varying supported). |
51+
| `electricity_buy_price` | scalar or array | $/kWh | Price to buy electricity from grid (optional). Shape is `n_timesteps` when `buy_price_mode` is `per_timestep`, or `plant_life` when `per_year`. |
52+
| `buy_price_mode` | string || `"per_timestep"` or `"per_year"`. Controls the buy price input shape and cost calculation. |
53+
| `electricity_out` | array[n_timesteps] | kW | Electricity flowing out of grid (buying). Present when `buy_price_mode` is `per_timestep` (or buy price is not set). |
54+
| `annual_electricity_out` | array[plant_life] | kWh/yr | Annual electricity bought from grid. Present when `buy_price_mode` is `per_year`. |
55+
| `electricity_sell_price` | scalar or array | $/kWh | Price to sell electricity to grid (optional). Shape is `n_timesteps` when `sell_price_mode` is `per_timestep`, or `plant_life` when `per_year`. |
56+
| `sell_price_mode` | string || `"per_timestep"` or `"per_year"`. Controls the sell price input shape and cost calculation. |
57+
| `electricity_sold` | array[n_timesteps] | kW | Electricity flowing into grid (selling). Present when `sell_price_mode` is `per_timestep` (or sell price is not set). |
58+
| `annual_electricity_sold` | array[plant_life] | kWh/yr | Annual electricity sold to grid. Present when `sell_price_mode` is `per_year`. |
5559

5660
**Outputs**
5761
| Name | Description |
@@ -71,3 +75,20 @@ If you're using a price-maker financial model (e.g., calculating the LCOE) and s
7175
```{note}
7276
The grid components are currently compatible with 5-minute (300-second) to 1-hour (3600-second) time steps.
7377
```
78+
79+
### Price Input Modes
80+
81+
The pricing mode is controlled explicitly via `buy_price_mode` and `sell_price_mode` in the grid cost configuration. Each can be set to:
82+
83+
- **`per_timestep`** (default): The price is a scalar or an array of length `n_timesteps`. The cost model uses the timestep-level `electricity_out` / `electricity_sold` inputs (in kW) and converts to energy using `dt`. The resulting `VarOpEx` is a single value applied uniformly across all years.
84+
- **`per_year`**: The price is an array of length `plant_life`. The cost model uses `annual_electricity_out` / `annual_electricity_sold` inputs (in kWh/yr, shape `plant_life`) directly, producing a per-year `VarOpEx` array with no `dt` conversion needed in the cost model.
85+
86+
Example YAML configuration for per-year pricing:
87+
88+
```yaml
89+
cost_parameters:
90+
electricity_buy_price: [0.05, 0.06, 0.07, ...] # length = plant_life
91+
buy_price_mode: per_year
92+
electricity_sell_price: [0.03, 0.04, 0.05, ...] # length = plant_life
93+
sell_price_mode: per_year
94+
```

h2integrate/converters/grid/grid.py

Lines changed: 89 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ def setup(self):
119119
desc="Electricity that was not sold due to interconnection limits",
120120
)
121121

122+
self.add_output(
123+
"annual_electricity_sold",
124+
val=0.0,
125+
shape=self.plant_life,
126+
units=f"({self.commodity_amount_units})/year",
127+
desc="Annual electricity sold to the grid",
128+
)
129+
122130
def compute(self, inputs, outputs):
123131
interconnection_size = inputs["interconnection_size"]
124132

@@ -148,6 +156,11 @@ def compute(self, inputs, outputs):
148156
1 / self.fraction_of_year_simulated
149157
)
150158

159+
total_electricity_sold = np.sum(electricity_sold) * (self.dt / 3600)
160+
outputs["annual_electricity_sold"] = total_electricity_sold * (
161+
1 / self.fraction_of_year_simulated
162+
)
163+
151164

152165
@define(kw_only=True)
153166
class GridCostModelConfig(CostModelBaseConfig):
@@ -168,6 +181,8 @@ class GridCostModelConfig(CostModelBaseConfig):
168181
fixed_interconnection_cost: float = field() # $
169182
electricity_buy_price: float | list[float] | np.ndarray | None = field(default=None) # $/kWh
170183
electricity_sell_price: float | list[float] | np.ndarray | None = field(default=None) # $/kWh
184+
buy_price_mode: str | None = field(default=None) # 'per_timestep' or 'per_year'
185+
sell_price_mode: str | None = field(default=None) # 'per_timestep' or 'per_year'
171186

172187

173188
class GridCostModel(CostModelBaseClass):
@@ -198,6 +213,7 @@ def setup(self):
198213
super().setup()
199214

200215
n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]
216+
plant_life = int(self.options["plant_config"]["plant"]["plant_life"])
201217

202218
# Common input for sizing costs
203219
self.add_input(
@@ -207,28 +223,20 @@ def setup(self):
207223
desc="Interconnection capacity for cost calculation",
208224
)
209225

210-
# Electricity flowing OUT of grid (buying from grid)
211-
self.add_input(
212-
"electricity_out",
213-
val=0.0,
214-
shape=n_timesteps,
215-
units="kW",
216-
desc="Electricity flowing out of grid (buying from grid)",
217-
)
218-
219226
# Add buy price input if configured
220227
if self.config.electricity_buy_price is not None:
221-
buy_price = self.config.electricity_buy_price
222-
if isinstance(buy_price, list | np.ndarray):
223-
if len(buy_price) != n_timesteps:
224-
raise ValueError(
225-
f"electricity_buy_price length ({len(buy_price)}) "
226-
f"must match n_timesteps ({n_timesteps})"
227-
)
228-
buy_price_shape = n_timesteps
229-
228+
self._buy_price_mode = self.config.buy_price_mode
229+
if self._buy_price_mode is None:
230+
# Default: scalar if single value, per_timestep if array
231+
if isinstance(self.config.electricity_buy_price, list | np.ndarray):
232+
self._buy_price_mode = "per_timestep"
233+
else:
234+
self._buy_price_mode = "per_timestep"
235+
236+
if self._buy_price_mode == "per_year":
237+
buy_price_shape = plant_life
230238
else:
231-
buy_price_shape = 1
239+
buy_price_shape = n_timesteps
232240

233241
self.add_input(
234242
"electricity_buy_price",
@@ -237,28 +245,40 @@ def setup(self):
237245
units="USD/(kW*h)",
238246
desc="Price to buy electricity from grid",
239247
)
248+
else:
249+
self._buy_price_mode = None
240250

241-
# Electricity flowing INTO grid (selling to grid)
242-
self.add_input(
243-
"electricity_sold",
244-
val=0.0,
245-
shape=n_timesteps,
246-
units="kW",
247-
desc="Electricity flowing into grid (selling to grid)",
248-
)
251+
# Electricity bought: use annual input for per-year pricing, timestep input otherwise
252+
if self._buy_price_mode == "per_year":
253+
self.add_input(
254+
"annual_electricity_out",
255+
val=0.0,
256+
shape=plant_life,
257+
units="kW*h/yr",
258+
desc="Annual electricity flowing out of grid (buying from grid)",
259+
)
260+
else:
261+
self.add_input(
262+
"electricity_out",
263+
val=0.0,
264+
shape=n_timesteps,
265+
units="kW",
266+
desc="Electricity flowing out of grid (buying from grid)",
267+
)
249268

250269
# Add sell price input if configured
251270
if self.config.electricity_sell_price is not None:
252-
sell_price = self.config.electricity_sell_price
253-
if isinstance(sell_price, list | np.ndarray):
254-
if len(sell_price) != n_timesteps:
255-
raise ValueError(
256-
f"electricity_sell_price length ({len(sell_price)}) "
257-
f"must match n_timesteps ({n_timesteps})"
258-
)
259-
sell_price_shape = n_timesteps
271+
self._sell_price_mode = self.config.sell_price_mode
272+
if self._sell_price_mode is None:
273+
if isinstance(self.config.electricity_sell_price, list | np.ndarray):
274+
self._sell_price_mode = "per_timestep"
275+
else:
276+
self._sell_price_mode = "per_timestep"
277+
278+
if self._sell_price_mode == "per_year":
279+
sell_price_shape = plant_life
260280
else:
261-
sell_price_shape = 1
281+
sell_price_shape = n_timesteps
262282

263283
self.add_input(
264284
"electricity_sell_price",
@@ -267,6 +287,26 @@ def setup(self):
267287
units="USD/(kW*h)",
268288
desc="Price to sell electricity to grid",
269289
)
290+
else:
291+
self._sell_price_mode = None
292+
293+
# Electricity sold: use annual input for per-year pricing, timestep input otherwise
294+
if self._sell_price_mode == "per_year":
295+
self.add_input(
296+
"annual_electricity_sold",
297+
val=0.0,
298+
shape=plant_life,
299+
units="kW*h/yr",
300+
desc="Annual electricity sold to grid",
301+
)
302+
else:
303+
self.add_input(
304+
"electricity_sold",
305+
val=0.0,
306+
shape=n_timesteps,
307+
units="kW",
308+
desc="Electricity flowing into grid (selling to grid)",
309+
)
270310

271311
def compute(self, inputs, outputs, discrete_inputs, discrete_outputs):
272312
interconnection_size = inputs["interconnection_size"]
@@ -281,21 +321,26 @@ def compute(self, inputs, outputs, discrete_inputs, discrete_outputs):
281321
outputs["OpEx"] = interconnection_size * opex_per_kw
282322

283323
# Variable operating costs (positive cost for buying, negative for selling)
284-
varopex = 0.0
324+
plant_life = int(self.options["plant_config"]["plant"]["plant_life"])
325+
varopex = np.zeros(plant_life)
285326

286327
# Add buying costs if buy price is configured
287-
# electricity_out represents power flowing OUT of grid (buying)
288328
if self.config.electricity_buy_price is not None:
289-
electricity_out = inputs["electricity_out"]
290329
buy_price = inputs["electricity_buy_price"]
291-
# Buying costs money (positive VarOpEx)
292-
varopex += np.sum((self.dt / 3600) * electricity_out * buy_price)
330+
if self._buy_price_mode == "per_year":
331+
# annual_electricity_out is already in kW*h/yr (shape=plant_life)
332+
varopex += inputs["annual_electricity_out"] * buy_price
333+
else:
334+
# Scalar or per-timestep: same cost each year
335+
varopex += np.sum((self.dt / 3600) * inputs["electricity_out"] * buy_price)
293336

294337
# Add selling revenue if sell price is configured
295-
# electricity_sold represents power flowing INTO grid (selling)
296338
if self.config.electricity_sell_price is not None:
297339
sell_price = inputs["electricity_sell_price"]
298-
# Selling generates revenue (negative VarOpEx)
299-
varopex -= np.sum((self.dt / 3600) * inputs["electricity_sold"] * sell_price)
340+
if self._sell_price_mode == "per_year":
341+
# annual_electricity_sold is already in kW*h/yr (shape=plant_life)
342+
varopex -= inputs["annual_electricity_sold"] * sell_price
343+
else:
344+
varopex -= np.sum((self.dt / 3600) * inputs["electricity_sold"] * sell_price)
300345

301346
outputs["VarOpEx"] = varopex

0 commit comments

Comments
 (0)