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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Added electricity and water consumption profiles as outputs to the `ECOElectrolyzerPerformanceModel` [PR 690](https://github.com/NatLabRockies/H2Integrate/pull/690)
- Add `PeakLoadManagementHeuristicOpenLoopStorageController` as a storage control strategy. [PR 641](https://github.com/NatLabRockies/H2Integrate/pull/641)
- Minor cleanup to `pose_optimization` [PR 695](https://github.com/NatLabRockies/H2Integrate/pull/695)
- Add charge and discharge efficiency support to `StoragePerformanceBaseConfig` and apply efficiency scaling internal to `PySAMBatteryPerformanceModel` so dispatch efficiencies are reflected in battery performance output [PR 699](https://github.com/NatLabRockies/H2Integrate/pull/699)
- Added ability to have a custom/user-specified resource model [PR 698](https://github.com/NatLabRockies/H2Integrate/pull/698)

## 0.8 [April 15, 2026]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,23 @@
)
ax[1].plot(
range(start_hour, end_hour),
model.prob.get_val("battery.unused_electricity_out", units="MW")[start_hour:end_hour],
model.prob.get_val("electrical_load_demand.unused_electricity_out", units="MW")[
start_hour:end_hour
],
linestyle=":",
label="Unused Electricity (MW)",
)
ax[1].plot(
range(start_hour, end_hour),
model.prob.get_val("battery.unmet_electricity_demand_out", units="MW")[start_hour:end_hour],
model.prob.get_val("electrical_load_demand.unmet_electricity_demand_out", units="MW")[
start_hour:end_hour
],
linestyle=":",
label="Unmet Electrical Demand (MW)",
)
ax[1].plot(
range(start_hour, end_hour),
model.prob.get_val("battery.electricity_out", units="MW")[start_hour:end_hour],
linestyle="-",
label="Electricity Out (MW)",
)
ax[1].plot(
range(start_hour, end_hour),
model.prob.get_val("battery.battery_electricity", units="MW")[start_hour:end_hour],
linestyle="-.",
label="Battery Electricity Out (MW)",
)
Expand Down
4 changes: 2 additions & 2 deletions examples/30_pyomo_optimized_dispatch/tech_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ technologies:
max_soc_fraction: 0.9 # Maximum SOC allowable for the storage technology
min_soc_fraction: 0.1 # Minimum SOC allowable for the storage technology
commodity_rate_units: kW
charge_efficiency: 0.95 # Charge efficiency of the storage technology
discharge_efficiency: 0.95 # Discharge efficiency of the storage technology
performance_parameters:
chemistry: LFPGraphite
demand_profile: 100000 # 100 MW
Expand All @@ -59,8 +61,6 @@ technologies:
opex_fraction: 0.25 # 0.25% of capex per year from 2024 ATB
control_parameters:
commodity: electricity
charge_efficiency: 0.95 # Charge efficiency of the storage technology
discharge_efficiency: 0.95 # Discharge efficiency of the storage technology
cost_per_charge: 0.03 # in $/kW, cost to charge the storage (note that charging is incentivized)
cost_per_discharge: 0.05 # in $/kW, cost to discharge the storage
commodity_met_value: 0.1 # in $/kW, penalty for not meeting the desired load demand
Expand Down
4 changes: 2 additions & 2 deletions examples/test/test_all_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -2622,13 +2622,13 @@ def test_pyomo_optimized_dispatch_example(subtests, temp_copy_of_example):
battery_total = model.prob.get_val(
"electrical_load_demand.total_electricity_produced", units="kW*h"
)[0]
assert battery_total == pytest.approx(645_787_407.02, rel=1e-3)
assert battery_total == pytest.approx(639_232_586.47, rel=1e-3)

with subtests.test("Check battery capacity factor"):
battery_cf = model.prob.get_val("electrical_load_demand.capacity_factor", units="unitless")[
0
]
assert battery_cf == pytest.approx(0.7372, rel=1e-3)
assert battery_cf == pytest.approx(0.7297, rel=1e-3)

with subtests.test("Check battery CapEx"):
battery_capex = model.prob.get_val("battery.CapEx", units="USD")[0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,15 +313,19 @@ def test_min_operating_cost_load_following_battery_dispatch(
assert np.cumsum(charge_plus_discharge).min() >= -1 * capacity

with subtests.test("Expected discharge from hour 10-30"):
expected_discharge = np.concat([np.zeros(8), np.ones(8) * 5000, np.zeros(4)])
discharge_eff = 0.95
expected_discharge = np.concat(
[np.zeros(8), np.ones(8) * 5000 * discharge_eff, np.zeros(4)]
)
np.testing.assert_allclose(
prob.get_val("battery.storage_electricity_discharge", units="kW")[0:20],
expected_discharge,
rtol=1e-2,
)

with subtests.test("Expected charge hour 0-24"):
expected_charge = -1 * np.concat([np.zeros(16), np.ones(8) * 4000])
charge_eff = 0.95
expected_charge = -1 * np.concat([np.zeros(16), np.ones(8) * 4000 / charge_eff])
np.testing.assert_allclose(
prob.get_val("battery.storage_electricity_charge", units="kW")[0:24],
expected_charge,
Expand Down
18 changes: 15 additions & 3 deletions h2integrate/storage/battery/pysam_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ def simulate(
# slightly exceeds soc_max.
actual_charge = max(0.0, min(headroom, max_charge_input, -cmd))

# Update the charge command for the PySAM batttery
# Update the charge command for the PySAM battery
cmd = -actual_charge

else:
Expand All @@ -266,7 +266,7 @@ def simulate(
# Clip and apply discharge efficiency.
actual_discharge = max(0.0, min(headroom, max_discharge_input, cmd))

# Update the discharge command for the PySAM batttery
# Update the discharge command for the PySAM battery
cmd = actual_discharge

# Set the input variable to the desired value
Expand All @@ -276,7 +276,19 @@ def simulate(
self.system_model.execute(0)

# Save outputs at time t based on the simulation
storage_power_out_timesteps[t] = self.system_model.value("P")
# Apply external charge/discharge efficiency on top of PySAM's
# internal chemistry-based losses so the performance model output
# is consistent with the Pyomo dispatch optimizer's SOC constraint.
P = self.system_model.value("P")
if P > 0:
# Discharging: less commodity reaches the grid
storage_power_out_timesteps[t] = P * self.config.discharge_efficiency
else:
# Charging (P <= 0): more commodity drawn from the input stream
if self.config.charge_efficiency > 0:
storage_power_out_timesteps[t] = P / self.config.charge_efficiency
else:
storage_power_out_timesteps[t] = P
soc_timesteps[t] = self.system_model.value("SOC")

return storage_power_out_timesteps, soc_timesteps
Expand Down
194 changes: 128 additions & 66 deletions h2integrate/storage/battery/test/test_pysam_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,32 +38,6 @@ def test_pysam_battery_performance_model_without_controller(plant_config, subtes

electricity_demand = np.ones(int(n_control_window)) * 1000.0

prob.model.add_subsystem(
name="IVC1",
subsys=om.IndepVarComp(name="electricity_in", val=electricity_in, units="kW"),
promotes=["*"],
)

prob.model.add_subsystem(
name="IVC2",
subsys=om.IndepVarComp(name="time_step_duration", val=np.ones(n_control_window), units="h"),
promotes=["*"],
)

prob.model.add_subsystem(
name="IVC3",
subsys=om.IndepVarComp(name="electricity_demand", val=electricity_demand, units="kW"),
promotes=["*"],
)

prob.model.add_subsystem(
name="IVC4",
subsys=om.IndepVarComp(
name="electricity_set_point", val=electricity_demand - electricity_in, units="kW"
),
promotes=["*"],
)

prob.model.add_subsystem(
"pysam_battery",
PySAMBatteryPerformanceModel(
Expand All @@ -75,6 +49,9 @@ def test_pysam_battery_performance_model_without_controller(plant_config, subtes

prob.setup()

prob.set_val("electricity_in", electricity_in, units="kW")
prob.set_val("electricity_set_point", electricity_demand - electricity_in, units="kW")

prob.run_model()

expected_battery_power = np.array(
Expand Down Expand Up @@ -259,6 +236,126 @@ def test_battery_config(subtests):
PySAMBatteryPerformanceModelConfig.from_dict(data)


@pytest.mark.unit
@pytest.mark.parametrize("n_timesteps", [24])
def test_pysam_battery_charge_discharge_efficiency(plant_config, subtests):
"""Test that charge and discharge efficiencies are applied to the PySAM battery output.

Runs the battery twice with the same set-point commands:
1. With default efficiencies (1.0) — baseline
2. With charge_efficiency=0.9 and discharge_efficiency=0.9

Verifies that:
- During discharge timesteps, output power is scaled by discharge_efficiency
- During charge timesteps, commodity consumed is scaled by 1/charge_efficiency
"""
charge_eff = 0.9
discharge_eff = 0.9

n_control_window = 24
init_charge_rate = 50000.0
init_capacity = 200000.0

# First 12 hours: excess input → charges battery (negative set_point)
# Last 12 hours: no input → discharges battery (positive set_point)
electricity_in = np.concatenate(
(np.ones(int(n_control_window / 2)) * 2000.0, np.zeros(int(n_control_window / 2)))
)
electricity_demand = np.ones(n_control_window) * 1000.0
set_point = electricity_demand - electricity_in

results = {}
for label, ce, de in [("baseline", 1.0, 1.0), ("with_eff", charge_eff, discharge_eff)]:
tech_config = {
"model_inputs": {
"shared_parameters": {
"max_charge_rate": init_charge_rate,
"max_capacity": init_capacity,
"n_control_window": n_control_window,
"init_soc_fraction": 0.5,
"max_soc_fraction": 0.9,
"min_soc_fraction": 0.1,
},
"performance_parameters": {
"chemistry": "LFPGraphite",
"demand_profile": 0.0,
"charge_efficiency": ce,
"discharge_efficiency": de,
},
}
}

prob = om.Problem()
prob.model.add_subsystem(
"pysam_battery",
PySAMBatteryPerformanceModel(
plant_config=plant_config,
tech_config=tech_config,
),
promotes=["*"],
)
prob.setup()

prob.set_val("electricity_in", electricity_in, units="kW")
prob.set_val("electricity_set_point", set_point, units="kW")

prob.run_model()

results[label] = prob.get_val("electricity_out", units="kW").copy()

baseline = results["baseline"]
with_eff = results["with_eff"]

with subtests.test("discharge timesteps scaled by discharge_efficiency"):
# Discharge timesteps are where baseline > 0
discharge_mask = baseline > 0
assert discharge_mask.any(), "Expected some discharge timesteps"
np.testing.assert_allclose(
with_eff[discharge_mask],
baseline[discharge_mask] * discharge_eff,
rtol=1e-6,
)

with subtests.test("charge timesteps scaled by 1/charge_efficiency"):
# Charge timesteps are where baseline < 0
charge_mask = baseline < 0
assert charge_mask.any(), "Expected some charge timesteps"
np.testing.assert_allclose(
with_eff[charge_mask],
baseline[charge_mask] / charge_eff,
rtol=1e-6,
)

with subtests.test("config round_trip_efficiency"):
config_data = {
"max_capacity": 20000,
"max_charge_rate": 5000,
"chemistry": "LFPGraphite",
"init_soc_fraction": 0.5,
"max_soc_fraction": 0.9,
"min_soc_fraction": 0.1,
"demand_profile": 0.0,
"round_trip_efficiency": 0.81,
}
config = PySAMBatteryPerformanceModelConfig.from_dict(config_data)
assert config.charge_efficiency == pytest.approx(0.9, rel=1e-6)
assert config.discharge_efficiency == pytest.approx(0.9, rel=1e-6)

with subtests.test("config defaults to 1.0 when no efficiency set"):
config_data = {
"max_capacity": 20000,
"max_charge_rate": 5000,
"chemistry": "LFPGraphite",
"init_soc_fraction": 0.5,
"max_soc_fraction": 0.9,
"min_soc_fraction": 0.1,
"demand_profile": 0.0,
}
config = PySAMBatteryPerformanceModelConfig.from_dict(config_data)
assert config.charge_efficiency == 1.0
assert config.discharge_efficiency == 1.0


@pytest.mark.unit
@pytest.mark.parametrize("n_timesteps", [24])
def test_battery_initialization(plant_config, subtests):
Expand Down Expand Up @@ -327,26 +424,6 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests):
}
# Set up the OpenMDAO problem
prob_init = om.Problem()
prob_init.model.add_subsystem(
name="IVC1",
subsys=om.IndepVarComp(name="electricity_demand", val=electricity_demand, units="kW"),
promotes=["*"],
)

prob_init.model.add_subsystem(
name="IVC2",
subsys=om.IndepVarComp(name="electricity_in", val=electricity_in, units="MW"),
promotes=["*"],
)

prob_init.model.add_subsystem(
name="IVC3",
subsys=om.IndepVarComp(
name="electricity_set_point", val=electricity_demand - electricity_in, units="kW"
),
promotes=["*"],
)

prob_init.model.add_subsystem(
"pysam_battery",
PySAMBatteryPerformanceModel(
Expand All @@ -358,6 +435,9 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests):

prob_init.setup()

prob_init.set_val("electricity_in", electricity_in, units="MW")
prob_init.set_val("electricity_set_point", electricity_demand - electricity_in, units="kW")

prob_init.run_model()

with subtests.test("5 MW battery discharge profile within charge rate bounds"):
Expand Down Expand Up @@ -390,26 +470,6 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests):

# Re-run and set the charge rate as half of what it was before
prob = om.Problem()
prob.model.add_subsystem(
name="IVC1",
subsys=om.IndepVarComp(name="electricity_demand", val=electricity_demand, units="kW"),
promotes=["*"],
)

prob.model.add_subsystem(
name="IVC2",
subsys=om.IndepVarComp(name="electricity_in", val=electricity_in, units="MW"),
promotes=["*"],
)

prob.model.add_subsystem(
name="IVC3",
subsys=om.IndepVarComp(
name="electricity_set_point", val=electricity_demand - electricity_in, units="kW"
),
promotes=["*"],
)

prob.model.add_subsystem(
"pysam_battery",
PySAMBatteryPerformanceModel(
Expand All @@ -421,6 +481,8 @@ def test_pysam_battery_no_controller_change_capacity(plant_config, subtests):

prob.setup()

prob.set_val("electricity_in", electricity_in, units="MW")
prob.set_val("electricity_set_point", electricity_demand - electricity_in, units="kW")
prob.set_val("pysam_battery.max_charge_rate", init_charge_rate / 2, units="kW")

prob.run_model()
Expand Down
Loading
Loading