diff --git a/Makefile b/Makefile index 2056a6a0..5a3d85d6 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 7f19e920..d0b1462e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/src/energypy/battery.py b/src/energypy/battery.py index 1f71f59b..468d54d4 100644 --- a/src/energypy/battery.py +++ b/src/energypy/battery.py @@ -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 @@ -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, @@ -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 @@ -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=}" + ) diff --git a/tests/test_battery.py b/tests/test_battery.py index 8a584281..d307e088 100644 --- a/tests/test_battery.py +++ b/tests/test_battery.py @@ -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, @@ -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: @@ -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 @@ -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 @@ -188,7 +197,7 @@ 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, @@ -196,21 +205,59 @@ def test_observation_with_features() -> None: 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) diff --git a/tests/test_battery_frequency.py b/tests/test_battery_frequency.py index af6ae96f..e65b725f 100644 --- a/tests/test_battery_frequency.py +++ b/tests/test_battery_frequency.py @@ -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) \ No newline at end of file + 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) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 5c7eb3a5..b2a78a0a 100644 --- a/uv.lock +++ b/uv.lock @@ -477,6 +477,7 @@ test = [ { name = "coverage" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -499,6 +500,7 @@ test = [ { name = "coverage", specifier = ">=7.8.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.11.6" }, ] @@ -522,6 +524,15 @@ epy = [ { name = "typing-extensions" }, ] +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, +] + [[package]] name = "executing" version = "2.2.0" @@ -2227,6 +2238,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 }, ] +[[package]] +name = "pytest-xdist" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"