Skip to content

Commit f7cebbc

Browse files
committed
updated storage autosizing model
1 parent 27c1bdb commit f7cebbc

File tree

2 files changed

+39
-314
lines changed

2 files changed

+39
-314
lines changed
Lines changed: 26 additions & 314 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import numpy as np
22
from attrs import field, define
33

4-
from h2integrate.core.utilities import BaseConfig, merge_shared_inputs
5-
from h2integrate.core.validators import range_val, range_val_or_none
6-
from h2integrate.core.model_baseclasses import PerformanceModelBaseClass
4+
from h2integrate.core.utilities import merge_shared_inputs
5+
from h2integrate.core.validators import range_val_or_none
6+
from h2integrate.storage.storage_baseclass import (
7+
StoragePerformanceBase,
8+
StoragePerformanceBaseConfig,
9+
)
710

811

912
@define(kw_only=True)
10-
class StorageSizingModelConfig(BaseConfig):
13+
class StorageSizingModelConfig(StoragePerformanceBaseConfig):
1114
"""Configuration class for the StorageAutoSizingModel.
1215
1316
Attributes:
@@ -38,8 +41,8 @@ class StorageSizingModelConfig(BaseConfig):
3841
commodity: str = field(converter=(str.strip, str.lower))
3942
commodity_rate_units: str = field(converter=str.strip)
4043

41-
min_soc_fraction: float = field(validator=range_val(0, 1))
42-
max_soc_fraction: float = field(validator=range_val(0, 1))
44+
# min_soc_fraction: float = field(validator=range_val(0, 1))
45+
# max_soc_fraction: float = field(validator=range_val(0, 1))
4346

4447
# TODO: add in logic for having different discharge rate
4548
# charge_equals_discharge: bool = field(default=True)
@@ -80,7 +83,7 @@ def __attrs_post_init__(self):
8083
self.commodity_amount_units = f"({self.commodity_rate_units})*h"
8184

8285

83-
class StorageAutoSizingModel(PerformanceModelBaseClass):
86+
class StorageAutoSizingModel(StoragePerformanceBase):
8487
"""Performance model that calculates the storage charge rate and capacity needed
8588
to either:
8689
@@ -136,23 +139,6 @@ def setup(self):
136139

137140
super().setup()
138141

139-
# Inputs
140-
self.add_input(
141-
f"{self.commodity}_demand",
142-
val=self.config.demand_profile,
143-
shape=self.n_timesteps,
144-
units=self.commodity_rate_units,
145-
desc=f"{self.commodity} demand profile timeseries",
146-
)
147-
148-
self.add_input(
149-
f"{self.commodity}_in",
150-
val=0.0,
151-
shape=self.n_timesteps,
152-
units=self.commodity_rate_units,
153-
desc=f"{self.commodity} input timeseries from production to storage",
154-
)
155-
156142
# Capacity outputs
157143
self.add_output(
158144
"storage_capacity",
@@ -175,87 +161,6 @@ def setup(self):
175161
units=self.commodity_rate_units,
176162
)
177163

178-
self.add_output(
179-
"storage_duration",
180-
units=f"({self.commodity_amount_units})/({self.commodity_rate_units})",
181-
desc="Estimated storage duration based on max capacity and discharge rate",
182-
)
183-
184-
# Storage performance outputs
185-
self.add_output(
186-
"SOC",
187-
val=0.0,
188-
shape=self.n_timesteps,
189-
units="percent",
190-
desc="State of charge of storage",
191-
)
192-
193-
self.add_output(
194-
f"storage_{self.commodity}_discharge",
195-
val=0.0,
196-
shape=self.n_timesteps,
197-
units=self.commodity_rate_units,
198-
desc=f"{self.commodity} output from storage only",
199-
)
200-
201-
self.add_output(
202-
f"storage_{self.commodity}_charge",
203-
val=0.0,
204-
shape=self.n_timesteps,
205-
units=self.commodity_rate_units,
206-
desc=f"{self.commodity} input to storage only",
207-
)
208-
209-
self.add_output(
210-
f"storage_{self.commodity}_out",
211-
val=0.0,
212-
shape=self.n_timesteps,
213-
units=self.commodity_rate_units,
214-
desc=f"{self.commodity} input and output from storage",
215-
)
216-
217-
# Combined storage and input outputs
218-
self.add_output(
219-
f"unmet_{self.commodity}_demand_out",
220-
val=0.0,
221-
shape=self.n_timesteps,
222-
units=self.commodity_rate_units,
223-
desc=f"Unmet {self.commodity} demand",
224-
)
225-
226-
self.add_output(
227-
f"unused_{self.commodity}_out",
228-
val=0.0,
229-
shape=self.n_timesteps,
230-
units=self.commodity_rate_units,
231-
desc="Unused generated commodity",
232-
)
233-
234-
self.dt_hr = self.dt / 3600 # convert from seconds to hours
235-
236-
# create a variable to determine whether we are using feedback control
237-
# for this technology
238-
using_feedback_control = False
239-
# create inputs for pyomo control model
240-
if "tech_to_dispatch_connections" in self.options["plant_config"]:
241-
self.tech_group_name = self.pathname.split(".")
242-
for _source_tech, intended_dispatch_tech in self.options["plant_config"][
243-
"tech_to_dispatch_connections"
244-
]:
245-
if any(intended_dispatch_tech in name for name in self.tech_group_name):
246-
self.add_discrete_input("pyomo_dispatch_solver", val=dummy_function)
247-
using_feedback_control = True
248-
break
249-
250-
if not using_feedback_control:
251-
# using an open-loop storage controller
252-
self.add_input(
253-
f"{self.commodity}_set_point",
254-
val=0.0,
255-
shape=self.n_timesteps,
256-
units=self.commodity_rate_units,
257-
)
258-
259164
def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]):
260165
"""
261166
Part 0: get demand profile based on user input parameters:
@@ -357,221 +262,28 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]):
357262
[self.config.min_soc_fraction, commodity_storage_soc[0] / rated_storage_capacity]
358263
)
359264

360-
# 2. Simulate the storage performance using the `simulate()`
361-
if "pyomo_dispatch_solver" in discrete_inputs:
362-
# Simulate the storage with provided dispatch inputs
363-
dispatch = discrete_inputs["pyomo_dispatch_solver"]
364-
# kwargs are tech-specific inputs to the simulate() method
365-
kwargs = {
366-
"charge_rate": storage_max_fill_rate,
367-
"discharge_rate": storage_max_empty_rate,
368-
"storage_capacity": rated_storage_capacity,
369-
}
370-
371-
# Make dictionary of inputs containing information to pass to the controller
372-
# (such as demand profile, charge rate, and storage capacity)
373-
inputs_adjusted = dict(inputs.items())
374-
inputs_adjusted["storage_capacity"] = np.array([rated_storage_capacity])
375-
inputs_adjusted["max_charge_rate"] = np.array([storage_max_fill_rate])
376-
377-
if self.config.set_demand_as_avg_commodity_in:
378-
inputs_adjusted[f"{self.commodity}_demand"] = commodity_demand
379-
storage_commodity_out, soc = dispatch(self.simulate, kwargs, inputs_adjusted)
380-
381-
else:
382-
# Simulate the storage with provided inputs using dispatch commands from
383-
# an open-loop controller. The commodity_set_point should come from an
384-
# open-loop controller. commodity_set_point is negative when commanding
385-
# storage to charge and positive when commanding storage to discharge
386-
storage_commodity_out, soc = self.simulate(
387-
inputs[f"{self.commodity}_set_point"],
388-
storage_max_fill_rate,
389-
storage_max_empty_rate,
390-
rated_storage_capacity,
391-
)
392-
393-
# soc output from simulating storage is represented as a percentage
394-
storage_commodity_out = np.array(storage_commodity_out)
395-
396-
# 3. Calculate outputs
397-
398-
# calculate combined commodity out from inflow source and storage
399-
# (note: storage_commodity_out is negative when charging)
400-
combined_commodity_out = inputs[f"{self.commodity}_in"] + storage_commodity_out
401-
402-
# find the total commodity out to meet demand
403-
total_commodity_out = np.minimum(commodity_demand, combined_commodity_out)
404-
405-
# determine how much of the inflow commodity was unused
406-
unused_commodity = np.maximum(0, combined_commodity_out - commodity_demand)
407-
408-
# determine how much demand was not met
409-
unmet_demand = np.maximum(0, commodity_demand - combined_commodity_out)
410-
411-
# Output the storage performance outputs
412-
outputs[f"storage_{self.commodity}_charge"] = np.where(
413-
storage_commodity_out < 0, storage_commodity_out, 0
414-
)
415-
outputs[f"storage_{self.commodity}_discharge"] = np.where(
416-
storage_commodity_out > 0, storage_commodity_out, 0
417-
)
418-
outputs[f"unmet_{self.commodity}_demand_out"] = unmet_demand
419-
outputs[f"unused_{self.commodity}_out"] = unused_commodity
420-
outputs[f"storage_{self.commodity}_out"] = storage_commodity_out
421-
outputs["SOC"] = soc
422-
423-
# commodity_out is the commodity_in - charge_storage + discharge_storage
424-
outputs[f"{self.commodity}_out"] = total_commodity_out
425-
426265
# Output the calculated storage sizes (charge rate and capacity)
427266
outputs["max_charge_rate"] = storage_max_fill_rate
428267
outputs["max_discharge_rate"] = storage_max_empty_rate
429268
outputs["storage_capacity"] = rated_storage_capacity
430-
outputs["storage_duration"] = outputs["storage_capacity"] / outputs["max_discharge_rate"]
431269

432-
# The rated_commodity_production is based on the discharge rate
433-
# (which is assumed equal to the charge rate)
434-
outputs[f"rated_{self.commodity}_production"] = storage_max_fill_rate
270+
# 2. Simulate the storage performance using the `simulate()`
435271

436-
# The total_commodity_produced is the sum of the commodity discharged from storage
437-
outputs[f"total_{self.commodity}_produced"] = np.sum(total_commodity_out)
272+
# Make dictionary of inputs containing information to pass to the controller
273+
# (such as demand profile, charge rate, and storage capacity)
274+
inputs_adjusted = dict(inputs.items())
275+
if self.config.set_demand_as_avg_commodity_in:
276+
inputs_adjusted[f"{self.commodity}_demand"] = commodity_demand
438277

439-
# Adjust the total_commodity_produced to a year-long simulation
440-
outputs[f"annual_{self.commodity}_produced"] = outputs[
441-
f"total_{self.commodity}_produced"
442-
] * (1 / self.fraction_of_year_simulated)
278+
if "pyomo_dispatch_solver" in discrete_inputs:
279+
inputs_adjusted["storage_capacity"] = np.array([rated_storage_capacity])
280+
inputs_adjusted["max_charge_rate"] = np.array([storage_max_fill_rate])
443281

444-
# Capacity factor is total discharged commodity / maximum discharged commodity possible
445-
outputs["capacity_factor"] = outputs[f"total_{self.commodity}_produced"] / (
446-
outputs[f"rated_{self.commodity}_production"] * self.n_timesteps
282+
outputs = self.run_storage(
283+
storage_max_fill_rate,
284+
storage_max_empty_rate,
285+
rated_storage_capacity,
286+
inputs_adjusted,
287+
outputs,
288+
discrete_inputs,
447289
)
448-
449-
def simulate(
450-
self,
451-
storage_dispatch_commands: list,
452-
charge_rate: float,
453-
discharge_rate: float,
454-
storage_capacity: float,
455-
sim_start_index: int = 0,
456-
):
457-
"""Run the storage model over a control window of ``n_control_window`` timesteps.
458-
459-
Iterates through ``storage_dispatch_commands`` one timestep at a time.
460-
A negative command requests charging; a positive command requests
461-
discharging. Each command is clipped to the most restrictive of three
462-
limits before it is applied:
463-
464-
1. **SOC headroom** - the remaining capacity (charge) or remaining
465-
stored commodity (discharge), converted to a rate via
466-
``storage_capacity / dt_hr``.
467-
2. **Hardware rate limit** - ``charge_rate`` or ``discharge_rate``,
468-
divided by the corresponding efficiency so the limit is expressed
469-
in pre-efficiency rate units.
470-
3. **Commanded magnitude** - the absolute value of the dispatch command
471-
itself (we never exceed what was asked for).
472-
473-
After clipping, the result is scaled by the charge or discharge
474-
efficiency to obtain the actual commodity flow into or out of the
475-
storage, and the SOC is updated accordingly.
476-
477-
This method is separated from ``compute()`` so the Pyomo dispatch
478-
controller can call it directly to evaluate candidate schedules.
479-
480-
Args:
481-
storage_dispatch_commands (array_like[float]):
482-
Dispatch set-points for each timestep in ``commodity_rate_units``.
483-
Negative values command charging; positive values command
484-
discharging. Length must equal ``config.n_control_window``.
485-
charge_rate (float):
486-
Maximum commodity input rate to storage in
487-
``commodity_rate_units`` (before charge efficiency is applied).
488-
discharge_rate (float):
489-
Maximum commodity output rate from storage in
490-
``commodity_rate_units`` (before discharge efficiency is applied).
491-
storage_capacity (float):
492-
Rated storage capacity in ``commodity_amount_units``.
493-
sim_start_index (int, optional):
494-
Starting index for writing into persistent output arrays.
495-
Defaults to 0.
496-
497-
Returns:
498-
tuple[np.ndarray, np.ndarray]
499-
storage_commodity_out_timesteps :
500-
Commodity flow per timestep in ``commodity_rate_units``.
501-
Positive = discharge (commodity leaving storage),
502-
negative = charge (commodity entering storage).
503-
soc_timesteps :
504-
State of charge at the end of each timestep, in percent
505-
(0-100).
506-
"""
507-
508-
n = len(storage_dispatch_commands)
509-
storage_commodity_out_timesteps = np.zeros(n)
510-
soc_timesteps = np.zeros(n)
511-
512-
# Early return when storage cannot operate: zero capacity or both
513-
# charge and discharge rates are zero.
514-
if storage_capacity <= 0 or (charge_rate <= 0 and discharge_rate <= 0):
515-
soc_timesteps[:] = self.current_soc * 100.0
516-
return storage_commodity_out_timesteps, soc_timesteps
517-
518-
# Pre-compute scalar constants to avoid repeated attribute lookups
519-
# and redundant divisions inside the per-timestep loop.
520-
charge_eff = self.config.charge_efficiency
521-
discharge_eff = self.config.discharge_efficiency
522-
soc_max = self.config.max_soc_fraction
523-
soc_min = self.config.min_soc_fraction
524-
525-
commands = np.asarray(storage_dispatch_commands, dtype=float)
526-
soc = float(self.current_soc)
527-
528-
for t, cmd in enumerate(commands):
529-
if cmd < 0.0:
530-
# --- Charging ---
531-
# headroom: how much more commodity the storage can accept,
532-
# expressed as a rate (commodity_rate_units).
533-
headroom = (soc_max - soc) * storage_capacity / self.dt_hr
534-
535-
# Clip to the most restrictive limit, then apply efficiency.
536-
# max(0, ...) guards against negative headroom when SOC
537-
# slightly exceeds soc_max.
538-
# correct headroom to not include charge_eff.
539-
actual_charge = max(0.0, min(headroom / charge_eff, charge_rate, -cmd)) * charge_eff
540-
541-
# Update SOC (actual_charge is in post-efficiency units)
542-
soc += actual_charge / storage_capacity
543-
544-
# Update the amount of commodity used to charge from the input stream
545-
# If charge_eff<1, more commodity is pulled from the input stream than
546-
# the commodity that goes into the storage.
547-
storage_commodity_out_timesteps[t] = -actual_charge / charge_eff
548-
else:
549-
# --- Discharging ---
550-
# headroom: how much commodity can still be drawn before
551-
# hitting the minimum SOC, expressed as a rate.
552-
headroom = (soc - soc_min) * storage_capacity / self.dt_hr
553-
554-
# Clip to the most restrictive limit without applied efficiency.
555-
# Discharge efficiency losses occur as energy leaves storage.
556-
actual_discharge = max(
557-
0.0, min(headroom, discharge_rate / discharge_eff, cmd / discharge_eff)
558-
)
559-
560-
# Update SOC (actual_discharge is before efficiency losses are applied.)
561-
soc -= actual_discharge / storage_capacity
562-
563-
# If discharge_eff<1, then less commodity is output from the storage
564-
# than the commodity discharged from storage
565-
storage_commodity_out_timesteps[t] = actual_discharge * discharge_eff
566-
567-
soc_timesteps[t] = soc * 100.0
568-
569-
# Persist the final SOC so subsequent simulate() calls (e.g. from the
570-
# Pyomo controller across rolling windows) start where we left off.
571-
self.current_soc = soc
572-
return storage_commodity_out_timesteps, soc_timesteps
573-
574-
575-
def dummy_function():
576-
# this function is required for initializing the pyomo control input and nothing else
577-
pass

0 commit comments

Comments
 (0)