Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions h2integrate/storage/battery/battery_baseclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,23 @@ def setup(self):
self.add_input(
"electricity_in",
val=0.0,
shape_by_conn=True,
shape=self.n_timesteps,
units="kW",
desc="Power input to Battery",
)

self.add_output(
"SOC",
val=0.0,
copy_shape="electricity_in",
shape=self.n_timesteps,
units="percent",
desc="State of charge of Battery",
)

self.add_output(
"battery_electricity_discharge",
val=0.0,
copy_shape="electricity_in",
shape=self.n_timesteps,
units="kW",
desc="Electricity output from Battery only",
)
Expand Down
182 changes: 57 additions & 125 deletions h2integrate/storage/battery/pysam_battery.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
from dataclasses import asdict, dataclass
from collections.abc import Sequence

import numpy as np
import PySAM.BatteryTools as BatteryTools
import PySAM.BatteryStateful as BatteryStateful
Expand All @@ -11,63 +8,6 @@
from h2integrate.storage.battery.battery_baseclass import BatteryPerformanceBaseClass


@dataclass
class BatteryOutputs:
I: Sequence # noqa: E741
P: Sequence
Q: Sequence
SOC: Sequence
T_batt: Sequence
gen: Sequence
n_cycles: Sequence
P_chargeable: Sequence
P_dischargeable: Sequence
unmet_demand: list[float]
unused_commodity: list[float]

"""
Container for simulated outputs from the `BatteryStateful` and H2I dispatch models.

Attributes:
I (Sequence): Battery current [A] per timestep.
P (Sequence): Battery power [kW] per timestep.
Q (Sequence): Battery capacity [Ah] per timestep.
SOC (Sequence): State of charge [%] per timestep.
T_batt (Sequence): Battery temperature [°C] per timestep.
gen (Sequence): Generated power [kW] per timestep.
n_cycles (Sequence): Cumulative rainflow cycles since start of simulation [1].
P_chargeable (Sequence): Maximum estimated chargeable power [kW] per timestep.
P_dischargeable (Sequence): Maximum estimated dischargeable power [kW] per timestep.

unmet_demand (list[float]): Unmet demand [kW] per timestep.
unused_commodity (list[float]): Unused available commodity [kW] per timestep.
"""

def __init__(self, n_timesteps, n_control_window):
"""Class for storing stateful battery and dispatch outputs."""
self.stateful_attributes = [
"I",
"P",
"Q",
"SOC",
"T_batt",
"n_cycles",
"P_chargeable",
"P_dischargeable",
]
for attr in self.stateful_attributes:
setattr(self, attr, [0.0] * n_timesteps)

self.dispatch_lifecycles_per_control_window = [None] * int(n_timesteps / n_control_window)

self.component_attributes = ["unmet_demand", "unused_commodity"]
for attr in self.component_attributes:
setattr(self, attr, [0.0] * n_timesteps)

def export(self):
return asdict(self)


@define(kw_only=True)
class PySAMBatteryPerformanceModelConfig(BaseConfig):
"""Configuration class for battery performance models.
Expand Down Expand Up @@ -115,6 +55,12 @@ class PySAMBatteryPerformanceModelConfig(BaseConfig):
ref_module_surface_area (int | float, optional):
Reference module surface area in square meters (m²).
Defaults to 30.
Cp (int | float, optional): Battery specific heat capacity [J/kg*K].
Defaults to 900.
battery_h (int | float, optional): Heat transfer between battery and
environment [W/m2*K]. Defaults to 20.
resistance (int | float, optional): Battery internal resistance [Ohm].
Defaults to 0.001.
"""

max_capacity: float = field(validator=gt_zero)
Expand All @@ -134,6 +80,9 @@ class PySAMBatteryPerformanceModelConfig(BaseConfig):
)
ref_module_capacity: int | float = field(default=400)
ref_module_surface_area: int | float = field(default=30)
Cp: int | float = field(default=900)
battery_h: int | float = field(default=20)
resistance: int | float = field(default=0.001)


class PySAMBatteryPerformanceModel(BatteryPerformanceBaseClass):
Expand Down Expand Up @@ -249,56 +198,49 @@ def setup(self):
self.add_input(
"electricity_demand",
val=0.0,
copy_shape="electricity_in",
shape=self.n_timesteps,
units="kW",
desc="Power demand",
)

self.add_output(
"P_chargeable",
"unmet_electricity_demand_out",
val=0.0,
copy_shape="electricity_in",
shape=self.n_timesteps,
units="kW",
desc="Estimated max chargeable power",
desc="Unmet power demand",
)

self.add_output(
"P_dischargeable",
"unused_electricity_out",
val=0.0,
copy_shape="electricity_in",
shape=self.n_timesteps,
units="kW",
desc="Estimated max dischargeable power",
desc="Unused generated commodity",
)

self.add_output(
"unmet_electricity_demand_out",
"battery_discharge",
val=0.0,
copy_shape="electricity_in",
shape=self.n_timesteps,
units="kW",
desc="Unmet power demand",
desc="Electricity discharged from battery",
)

self.add_output(
"unused_electricity_out",
"battery_charge",
val=0.0,
copy_shape="electricity_in",
shape=self.n_timesteps,
units="kW",
desc="Unused generated commodity",
desc="Electricity to charge battery",
)

# Initialize the PySAM BatteryStateful model with defaults
self.system_model = BatteryStateful.default(self.config.chemistry)

n_timesteps = int(
self.options["plant_config"]["plant"]["simulation"]["n_timesteps"]
) # self.config.n_timesteps
self.dt_hr = int(self.options["plant_config"]["plant"]["simulation"]["dt"]) / (
60**2
) # convert from seconds to hours
n_control_window = self.config.n_control_window

# Setup outputs for the battery model to be stored during the compute method
self.outputs = BatteryOutputs(n_timesteps=n_timesteps, n_control_window=n_control_window)

# create inputs for pyomo control model
if "tech_to_dispatch_connections" in self.options["plant_config"]:
Expand All @@ -312,9 +254,6 @@ def setup(self):
self.add_discrete_input("pyomo_dispatch_solver", val=dummy_function)
break

self.unmet_demand = 0.0
self.unused_commodity = 0.0

def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]):
"""Run the PySAM Battery model for one simulation step.

Expand All @@ -333,6 +272,7 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]):
discrete_outputs (dict):
Discrete outputs (unused in this component).
"""

# Size the battery based on inputs -> method brought from HOPP
module_specs = {
"capacity": self.config.ref_module_capacity,
Expand All @@ -341,14 +281,14 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]):

BatteryTools.battery_model_sizing(
self.system_model,
self.config.max_charge_rate,
self.config.max_capacity,
inputs["max_charge_rate"][0],
inputs["storage_capacity"][0],
self.system_model.ParamsPack.nominal_voltage,
module_specs=module_specs,
)
self.system_model.ParamsPack.h = 20
self.system_model.ParamsPack.Cp = 900
self.system_model.ParamsCell.resistance = 0.001
self.system_model.ParamsPack.h = self.config.battery_h
self.system_model.ParamsPack.Cp = self.config.Cp
self.system_model.ParamsCell.resistance = self.config.resistance
self.system_model.ParamsCell.C_rate = (
inputs["max_charge_rate"][0] / inputs["storage_capacity"][0]
)
Expand All @@ -375,6 +315,9 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]):
dispatch = discrete_inputs["pyomo_dispatch_solver"]
kwargs = {
"time_step_duration": self.dt_hr,
"charge_rate": inputs["max_charge_rate"][0],
"discharge_rate": inputs["max_charge_rate"][0],
"storage_capacity": inputs["storage_capacity"][0],
"control_variable": self.config.control_variable,
}
(
Expand All @@ -385,6 +328,8 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]):
soc,
) = dispatch(self.simulate, kwargs, inputs)

battery_power_out = np.array(battery_power_out)

else:
# Simulate the battery with provided inputs and no controller.
# This essentially asks for discharge when demand exceeds input
Expand All @@ -396,42 +341,47 @@ def compute(self, inputs, outputs, discrete_inputs=[], discrete_outputs=[]):
battery_power, soc = self.simulate(
storage_dispatch_commands=pseudo_commands,
time_step_duration=self.dt_hr,
charge_rate=inputs["max_charge_rate"][0],
discharge_rate=inputs["max_charge_rate"][0],
storage_capacity=inputs["storage_capacity"][0],
control_variable=self.config.control_variable,
)
n_time_steps = len(inputs["electricity_demand"])

# determine battery discharge
self.outputs.P = battery_power
battery_power_out = [np.max([0, battery_power[i]]) for i in range(n_time_steps)]
battery_power_out = np.max(
[np.array(battery_power), np.zeros(self.n_timesteps)], axis=0
)
# [np.max([0, battery_power[i]]) for i in range(self.n_timesteps)]

# calculate combined power out from inflow source and battery (note: battery_power is
# negative when charging)
combined_power_out = inputs["electricity_in"] + battery_power
combined_power_out = inputs["electricity_in"] + np.array(battery_power)

# find the total power out to meet demand
total_power_out = np.minimum(inputs["electricity_demand"], combined_power_out)

# determine how much of the inflow electricity was unused
self.outputs.unused_commodity = [
unused_commodity = [
np.max([0, combined_power_out[i] - inputs["electricity_demand"][i]])
for i in range(n_time_steps)
for i in range(self.n_timesteps)
]
unused_commodity = self.outputs.unused_commodity

# determine how much demand was not met
self.outputs.unmet_demand = [
unmet_demand = [
np.max([0, inputs["electricity_demand"][i] - combined_power_out[i]])
for i in range(n_time_steps)
for i in range(self.n_timesteps)
]
unmet_demand = self.outputs.unmet_demand

outputs["unmet_electricity_demand_out"] = unmet_demand
outputs["unused_electricity_out"] = unused_commodity
outputs["battery_electricity_discharge"] = battery_power_out

outputs["battery_charge"] = np.where(battery_power_out < 0, battery_power_out, 0)
outputs["battery_discharge"] = np.where(battery_power_out > 0, battery_power_out, 0)

outputs["electricity_out"] = total_power_out
outputs["SOC"] = soc
outputs["P_chargeable"] = self.outputs.P_chargeable
outputs["P_dischargeable"] = self.outputs.P_dischargeable

outputs["rated_electricity_production"] = inputs["max_charge_rate"]

outputs["total_electricity_produced"] = np.sum(total_power_out)
Expand All @@ -446,6 +396,9 @@ def simulate(
self,
storage_dispatch_commands: list,
time_step_duration: list,
charge_rate: float,
discharge_rate: float,
storage_capacity: float,
control_variable: str,
sim_start_index: int = 0,
):
Expand Down Expand Up @@ -506,29 +459,19 @@ def simulate(

# manually adjust the dispatch command based on SOC
## for when battery is withing set bounds
# according to specs
max_chargeable_0 = self.config.max_charge_rate
# according to simulation
max_chargeable_1 = np.maximum(0, -self.system_model.value("P_chargeable"))
# according to soc
max_chargeable_2 = np.maximum(
0, (soc_max - soc) * self.config.max_capacity / self.dt_hr
)
max_chargeable_2 = np.maximum(0, (soc_max - soc) * storage_capacity / self.dt_hr)
# compare all versions of max_chargeable
max_chargeable = np.min([max_chargeable_0, max_chargeable_1, max_chargeable_2])
max_chargeable = np.min([charge_rate, max_chargeable_1, max_chargeable_2])

# according to specs
max_dischargeable_0 = self.config.max_charge_rate
# according to simulation
max_dischargeable_1 = np.maximum(0, self.system_model.value("P_dischargeable"))
# according to soc
max_dischargeable_2 = np.maximum(
0, (soc - soc_min) * self.config.max_capacity / self.dt_hr
)
max_dischargeable_2 = np.maximum(0, (soc - soc_min) * storage_capacity / self.dt_hr)
# compare all versions of max_dischargeable
max_dischargeable = np.min(
[max_dischargeable_0, max_dischargeable_1, max_dischargeable_2]
)
max_dischargeable = np.min([discharge_rate, max_dischargeable_1, max_dischargeable_2])

if dispatch_command_t < -max_chargeable:
dispatch_command_t = -max_chargeable
Expand All @@ -549,17 +492,6 @@ def simulate(
storage_power_out_timesteps[t] = self.system_model.value("P")
soc_timesteps[t] = self.system_model.value("SOC")

# Store outputs based on the outputs defined in `BatteryOutputs` above. The values are
# scraped from the PySAM model modules `StatePack` and `StateCell`.
for attr in self.outputs.stateful_attributes:
if hasattr(self.system_model.StatePack, attr) or hasattr(
self.system_model.StateCell, attr
):
getattr(self.outputs, attr)[sim_start_index + t] = self.system_model.value(attr)

for attr in self.outputs.component_attributes:
getattr(self.outputs, attr)[sim_start_index + t] = getattr(self, attr)

return storage_power_out_timesteps, soc_timesteps

def _set_control_mode(
Expand Down
2 changes: 0 additions & 2 deletions h2integrate/storage/battery/test/test_pysam_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,8 +273,6 @@ def test_battery_initialization(plant_config, subtests):

with subtests.test("battery attribute not None system_model"):
assert battery.system_model is not None
with subtests.test("battery attribute not None outputs"):
assert battery.outputs is not None

with subtests.test("battery mass"):
# this test value does not match the value in test_battery.py in HOPP
Expand Down