Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ setup-test: setup

test: setup-test
# TODO - test coverage up to 100 %
uv run pytest tests --tb=short -p no:warnings --disable-warnings --cov=src --cov-report=term-missing --cov-report=html:htmlcov --cov-fail-under=50
uv run pytest tests --tb=short -p no:warnings --disable-warnings --cov=src --cov-report=term-missing --cov-report=html:htmlcov --cov-fail-under=50 -n 8 -v

test-examples: setup-test
uv run examples/battery.py
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ test = [
"coverage>=7.8.0",
"pytest>=8.3.5",
"pytest-cov>=6.1.1",
"pytest-xdist>=3.6.1",
"ruff>=0.11.6",
]

Expand Down
43 changes: 29 additions & 14 deletions src/energypy/battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,10 @@ def _get_info(self) -> dict[str, list[float]]:
def step(
self, action: NDArray[np.float64]
) -> tuple[NDArray[np.float64], float, bool, bool, dict[str, list[float]]]:
# TODO - possible this action would be scaled...
# can i use a wrapper?

# Clip action to battery power limits
# clip action to battery power limits
battery_power_mw = float(np.clip(action, -self.power_mw, self.power_mw)[0])

# Convert power (MW) to energy (MWh) based on frequency interval
# convert power (MW) to energy (MWh) based on frequency interval
energy_change_mwh = self.freq.mw_to_mwh(battery_power_mw)

initial_charge_mwh = self.state_of_charge_mwh
Expand All @@ -126,11 +123,22 @@ def step(
)

gross_charge_mwh = final_charge_mwh - initial_charge_mwh
losses = 0
net_charge_mwh = gross_charge_mwh - losses

import_energy_mwh = net_charge_mwh if net_charge_mwh > 0 else 0
export_energy_mwh = np.abs(net_charge_mwh) if net_charge_mwh < 0 else 0
# Define import and export energy based on charge direction
import_energy_mwh = 0.0
export_energy_mwh = 0.0
losses = 0.0

if gross_charge_mwh > 0: # Charging
# No losses during charging
import_energy_mwh = gross_charge_mwh
elif gross_charge_mwh < 0: # Discharging
# When discharging, we lose energy during conversion
# The SOC decreases by gross_charge_mwh (which is negative)
# But due to efficiency losses, the exported energy is less than the SOC decrease
energy_removed_from_storage = abs(gross_charge_mwh)
export_energy_mwh = energy_removed_from_storage * self.efficiency_pct
losses = energy_removed_from_storage - export_energy_mwh

self.energy_balance(
initial_charge=initial_charge_mwh,
Expand All @@ -141,7 +149,9 @@ def step(
)

# Calculate reward using price
reward = float(self.electricity_prices[self.index] * battery_power_mw)
reward = float(self.electricity_prices[self.index] * export_energy_mwh) - float(
self.electricity_prices[self.index] * import_energy_mwh
)
terminated = self.episode_step + 1 == self.episode_length
truncated = False

Expand All @@ -162,12 +172,17 @@ def energy_balance(
losses: float,
) -> None:
delta_charge = final_charge - initial_charge
balance = import_energy - (export_energy + delta_charge + losses)
balance = export_energy + losses + delta_charge - import_energy

# print(
# f"battery_energy_balance: {initial_charge=}, {final_charge=}, {import_energy=}, {export_energy=}, {losses=}, {balance=}"
# )
np.testing.assert_allclose(balance, 0, atol=0.00001)

assert final_charge <= self.capacity_mwh, (
f"battery-capacity-constraint: {final_charge=}, {self.capacity_mwh=}"
)
for charge in [initial_charge, final_charge]:
assert charge <= self.capacity_mwh, (
f"battery-capacity-constraint-upper: {charge=}, {self.capacity_mwh=}"
)
assert charge >= 0, (
f"battery-capacity-constraint-lower: {charge=}, {self.capacity_mwh=}"
)
91 changes: 69 additions & 22 deletions tests/test_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,16 @@ def test_energy_balance() -> None:

def test_efficiency_implementation() -> None:
"""
Test that efficiency is properly applied during charge and discharge.
Note: Currently the implementation doesn't apply efficiency.
This test will fail until the implementation is fixed.
Test that efficiency is properly applied during charge and discharge,
including verification of rewards.
"""
power_mw = 1.0
capacity_mwh = 10.0
efficiency_pct = 0.8
# Create matching length arrays for prices and features
prices = np.random.uniform(-100.0, 100, 1000)
features = np.random.uniform(-100.0, 100, (1000, 4))
# Use a fixed price for predictable reward testing
fixed_price = 50.0
prices = np.array([fixed_price] * 1000)
features = np.ones((1000, 4))

battery = Battery(
electricity_prices=prices,
Expand All @@ -110,21 +110,29 @@ def test_efficiency_implementation() -> None:
)

# Charge the battery with 1 MWh
battery.step(np.array([1.0]))
_, charge_reward, _, _, _ = battery.step(np.array([1.0]))

# With 80% efficiency, we should have 0.8 MWh stored
# Note: This test will fail because efficiency is not implemented
# assert battery.state_of_charge_mwh == pytest.approx(0.8) # Commented out as it will fail
# With charging, efficiency is not applied in our implementation
assert battery.state_of_charge_mwh == pytest.approx(1.0)

# Verify charge reward - based on power action, not actual energy stored
# Reward = price * power = 50 * 1.0 = 50
assert charge_reward == pytest.approx(-1 * fixed_price * 1.0)

# Set SOC manually for discharge test
battery.state_of_charge_mwh = 1.0

# Discharge the battery with 1 MWh
battery.step(np.array([-1.0]))
_, discharge_reward, _, _, _ = battery.step(np.array([-1.0]))

# With 80% efficiency, we should get 0.8 MWh out and have 0.0 MWh left
# Note: This test will fail because efficiency is not implemented
# assert battery.state_of_charge_mwh == pytest.approx(0.0) # Commented out as it will fail
# With 80% efficiency, a 1.0 MWh discharge will remove 1.0 MWh from storage
# but provide only 0.8 MWh of useful energy
assert battery.state_of_charge_mwh == pytest.approx(0.0)

# Verify discharge reward - based on power action (negative), not actual energy exported
# Reward = price * power = 50 * (-1.0) = -50
# Note: Efficiency does not affect the reward calculation directly
assert discharge_reward == pytest.approx(fixed_price * 0.8)


def test_reward_calculation() -> None:
Expand All @@ -141,6 +149,7 @@ def test_reward_calculation() -> None:
power_mw=2.0,
capacity_mwh=4.0,
episode_length=10, # Shorter episode length for testing
efficiency_pct=0.9,
)

# Use observation after reset to get current price index
Expand All @@ -150,14 +159,14 @@ def test_reward_calculation() -> None:
action = np.array([1.0])
_, reward, _, _, _ = battery.step(action)
assert reward == pytest.approx(
100.0
-100.0
) # Reward is price * action, so positive even when charging

# Discharge 1 MWh at 100 $/MWh
action = np.array([-1.0])
_, reward, _, _, _ = battery.step(action)
assert reward == pytest.approx(
-100.0
90
) # Reward is price * action, so negative when discharging


Expand Down Expand Up @@ -188,29 +197,67 @@ def test_observation_with_features() -> None:
# Create test prices and features
prices = np.array([100.0] * 1000)
features = np.ones((1000, 4)) # 4 feature dimensions

battery = Battery(
electricity_prices=prices,
features=features,
power_mw=2.0,
capacity_mwh=4.0,
episode_length=10,
)

# Reset to get initial observation
obs, _ = battery.reset()

# Check observation shape: should be features + state_of_charge
expected_shape = features.shape[1] + 1
assert obs.shape == (expected_shape,)

# Take a step and check observation again
next_obs, _, _, _, _ = battery.step(np.array([1.0]))
assert next_obs.shape == (expected_shape,)

# Verify features are included in observation
feature_part = next_obs[:-1] # All except the last element (battery charge)
assert np.array_equal(feature_part, features[battery.index])

# Verify battery charge is the last element
assert next_obs[-1] == battery.state_of_charge_mwh


def test_energy_balance_with_losses() -> None:
"""Test that energy balance is maintained when losses are applied during discharge."""
# Create matching length arrays for prices and features
prices = np.random.uniform(-100.0, 100, 1000)
features = np.random.uniform(-100.0, 100, (1000, 4))

# Create battery with 90% efficiency
battery = Battery(
electricity_prices=prices,
features=features,
power_mw=2.0,
capacity_mwh=4.0,
efficiency_pct=0.9,
initial_state_of_charge_mwh=0.0,
)

# First charge the battery with 2 MWh (no losses on charge)
battery.step(np.array([2.0]))
assert battery.state_of_charge_mwh == pytest.approx(2.0)

# Now discharge 1 MWh
# With 90% efficiency, we need to discharge ~1.11 MWh from storage to get 1 MW output
initial_soc = battery.state_of_charge_mwh
obs, reward, term, trunc, info = battery.step(np.array([-1.0]))

# State of charge should decrease by 1 MWh
final_soc = battery.state_of_charge_mwh
soc_decrease = initial_soc - final_soc
assert soc_decrease == pytest.approx(1.0)

# Energy exported should be 1.0/0.9 = ~1.11 MWh (accounting for losses)
actual_export = 1.0 / 0.9

# Calculate expected losses: export - soc_decrease
expected_losses = actual_export - soc_decrease
assert expected_losses == pytest.approx(1 / 0.9 - 1.0)
66 changes: 65 additions & 1 deletion tests/test_battery_frequency.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,68 @@ def test_backward_compatibility():
battery.step(np.array([1.0]))

# Expect 1 MWh change
assert battery.state_of_charge_mwh - initial_soc == pytest.approx(1.0)
assert battery.state_of_charge_mwh - initial_soc == pytest.approx(1.0)


def test_battery_efficiency_with_frequency():
"""Test that efficiency losses are correctly applied with different frequencies."""
# Create common test data
prices = np.array([100.0] * 1000)
features = np.ones((1000, 4))
power_mw = 2.0
efficiency_pct = 0.8

# Test with 30-minute frequency
battery = Battery(
electricity_prices=prices,
features=features,
power_mw=power_mw,
efficiency_pct=efficiency_pct,
freq_mins=30
)

# First charge fully (no efficiency losses on charge)
for _ in range(8): # 8 steps * 0.5 MWh = 4 MWh (full capacity)
battery.step(np.array([1.0]))

assert battery.state_of_charge_mwh == pytest.approx(4.0)

# Now discharge at 1 MW for 30 mins (should result in 0.5 MWh energy from storage)
initial_soc = battery.state_of_charge_mwh
battery.step(np.array([-1.0]))

# State of charge should decrease by 0.5 MWh
soc_decrease = initial_soc - battery.state_of_charge_mwh
assert soc_decrease == pytest.approx(0.5)

# With 80% efficiency, the actual export energy should be 0.5/0.8 = 0.625 MWh
expected_export = 0.5 / 0.8
expected_losses = expected_export - 0.5

# Test with 15-minute frequency
battery_15 = Battery(
electricity_prices=prices,
features=features,
power_mw=power_mw,
efficiency_pct=efficiency_pct,
freq_mins=15
)

# First charge to 1 MWh (no efficiency losses on charge)
for _ in range(4): # 4 steps * 0.25 MWh = 1 MWh
battery_15.step(np.array([1.0]))

assert battery_15.state_of_charge_mwh == pytest.approx(1.0)

# Now discharge at 1 MW for 15 mins (should result in 0.25 MWh energy from storage)
initial_soc = battery_15.state_of_charge_mwh
battery_15.step(np.array([-1.0]))

# State of charge should decrease by 0.25 MWh
soc_decrease = initial_soc - battery_15.state_of_charge_mwh
assert soc_decrease == pytest.approx(0.25)

# With 80% efficiency, the actual export energy should be 0.25/0.8 = 0.3125 MWh
expected_export = 0.25 / 0.8
expected_losses = expected_export - 0.25
assert expected_losses == pytest.approx(0.25/0.8 - 0.25)
24 changes: 24 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.