-
Notifications
You must be signed in to change notification settings - Fork 12
Resolves MaStr#136 #282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Resolves MaStr#136 #282
Changes from all commits
8f1ebf9
6fc8952
e0d38c2
ab92f2a
91cbb2b
80b7e70
dc0571d
f31ca82
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # PowerShell version of run_tests.sh | ||
|
|
||
| # Activate virtual environment if it exists (Windows path used by venv) | ||
| if (Test-Path -Path .\.venv\Scripts\Activate.ps1) { | ||
| . .\.venv\Scripts\Activate.ps1 | ||
| } | ||
|
|
||
| # Ensure pytest and helpers are installed | ||
| python -m pip install --upgrade pip | ||
| python -m pip install pytest pytest-cov pytest-asyncio | ||
|
|
||
| # Run pytest with coverage and logging options | ||
| $params = @( | ||
| 'tests/', | ||
| '--cov=src/batcontrol', | ||
| '--log-cli-level=DEBUG', | ||
| '--log-cli-format=%(asctime)s [%(levelname)8s] %(name)s: %(message)s', | ||
| '--log-cli-date-format=%Y-%m-%d %H:%M:%S' | ||
| ) | ||
|
|
||
| python -m pytest @params | ||
|
|
||
| exit $LASTEXITCODE |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| from .tibber import Tibber | ||
| from .evcc import Evcc | ||
| from .energyforecast import Energyforecast | ||
| from .tariffzones import TariffZones | ||
| from .dynamictariff_interface import TariffInterface | ||
|
|
||
|
|
||
|
|
@@ -129,6 +130,31 @@ def create_tarif_provider(config: dict, timezone, | |
| if provider.lower() == 'energyforecast_96': | ||
| selected_tariff.upgrade_48h_to_96h() | ||
|
|
||
| elif provider.lower() == 'tariff_zones': | ||
| # require tariffs for zone 1 and zone 2 | ||
| required_fields = ['tariff_zone_1', 'tariff_zone_2'] | ||
| for field in required_fields: | ||
| if field not in config.keys(): | ||
| raise RuntimeError( | ||
| f'[DynTariff] Please include {field} in your configuration file' | ||
| ) | ||
| # read values and optional price parameters | ||
| tariff_zone_1 = float(config.get('tariff_zone_1')) | ||
| tariff_zone_2 = float(config.get('tariff_zone_2')) | ||
| zone_1_start = int(config.get('zone_1_start', 7)) | ||
| zone_1_end = int(config.get('zone_1_end', 22)) | ||
| selected_tariff = TariffZones( | ||
| timezone, | ||
| min_time_between_api_calls, | ||
| delay_evaluation_by_seconds, | ||
| target_resolution=target_resolution | ||
| ) | ||
| # store configured values in instance | ||
| selected_tariff.tariff_zone_1 = tariff_zone_1 | ||
| selected_tariff.tariff_zone_2 = tariff_zone_2 | ||
| selected_tariff.zone_1_start = zone_1_start | ||
| selected_tariff.zone_1_end = zone_1_end | ||
|
Comment on lines
+144
to
+156
|
||
|
|
||
| else: | ||
| raise RuntimeError(f'[DynamicTariff] Unkown provider {provider}') | ||
| raise RuntimeError(f'[DynamicTariff] Unknown provider {provider}') | ||
| return selected_tariff | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| """Tariff_zones provider | ||
|
|
||
| Simple dynamic tariff provider that returns a repeating two zone tariff. | ||
| Config options (in utility config for provider): | ||
| - type: tariff_zones | ||
| - tariff_zone_1: price for zone 1 hours (float) | ||
| - tariff_zone_2: price for zone 2 hours (float) | ||
| - zone_1_start: hour when tariff zone 1 starts (int, default 7) | ||
| - zone_1_end: hour when tariff zone 1 ends (int, default 22) | ||
|
|
||
| The class produces hourly prices (native_resolution=60) for the next 48 | ||
| hours aligned to the current hour. The baseclass will handle conversion to | ||
| 15min if the target resolution is 15. | ||
|
|
||
| Note: | ||
| The charge rate is not evenly distributed across the low price hours. | ||
| If you prefer a more even distribution during the low price hours, you can adjust the | ||
| soften_price_difference_on_charging to enabled | ||
| and | ||
| max_grid_charge_rate to a low value, e.g. capacity of the battery divided | ||
| by the hours of low price periods. | ||
|
|
||
| If you prefer a late charging start (=optimize efficiency, have battery only short | ||
| time at high SOC), you can adjust the | ||
| soften_price_difference_on_charging to disabled | ||
| """ | ||
| import datetime | ||
| import logging | ||
| from .baseclass import DynamicTariffBaseclass | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class TariffZones(DynamicTariffBaseclass): | ||
| """Two-tier tariff: zone 1 / zone 2 fixed prices.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| timezone, | ||
| min_time_between_API_calls=0, | ||
| delay_evaluation_by_seconds=0, | ||
| target_resolution: int = 60, | ||
| ): | ||
| super().__init__( | ||
| timezone, | ||
| min_time_between_API_calls, | ||
| delay_evaluation_by_seconds, | ||
| target_resolution=target_resolution, | ||
| native_resolution=60, | ||
| ) | ||
|
|
||
| # default zone boundaries | ||
| self._zone_1_start = 7 | ||
| self._zone_1_end = 22 | ||
|
|
||
|
|
||
|
|
||
| def _get_prices_native(self) -> dict[int, float]: | ||
| """Build hourly prices for the next 48 hours, hour-aligned. | ||
|
|
||
| Returns a dict mapping interval index (0 = start of current hour) | ||
| to price (float). | ||
| """ | ||
| raw = self.get_raw_data() | ||
| # allow values from raw data (cache) if present | ||
| tariff_zone_1 = raw.get('tariff_zone_1', self.tariff_zone_1) | ||
| tariff_zone_2 = raw.get('tariff_zone_2', self.tariff_zone_2) | ||
| zone_1_start = int(raw.get('zone_1_start', self.zone_1_start)) | ||
| zone_1_end = int(raw.get('zone_1_end', self.zone_1_end)) | ||
|
|
||
| # validate hours are integers in range [0, 23] | ||
| zone_1_start = self._validate_hour(zone_1_start, 'zone_1_start') | ||
| zone_1_end = self._validate_hour(zone_1_end, 'zone_1_end') | ||
|
|
||
| now = datetime.datetime.now().astimezone(self.timezone) | ||
| # Align to start of current hour | ||
| current_hour_start = now.replace(minute=0, second=0, microsecond=0) | ||
|
|
||
| prices = {} | ||
| # produce next 48 hours | ||
| for rel_hour in range(0, 48): | ||
| ts = current_hour_start + datetime.timedelta(hours=rel_hour) | ||
| h = ts.hour | ||
| if zone_1_start <= zone_1_end: | ||
| is_day = (h >= zone_1_start and h < zone_1_end) | ||
| else: | ||
| # wrap-around (e.g., zone_1_start=20, zone_1_end=6) | ||
| is_day = not (h >= zone_1_end and h < zone_1_start) | ||
|
|
||
| prices[rel_hour] = tariff_zone_1 if is_day else tariff_zone_2 | ||
|
|
||
| logger.debug('tariffZones: Generated %d hourly prices', len(prices)) | ||
| return prices | ||
|
Comment on lines
+58
to
+93
|
||
|
|
||
| def _validate_hour(self, val: int, name: str) -> int: | ||
| try: | ||
| ival = int(val) | ||
| except Exception: | ||
| raise ValueError(f'[{name}] must be an integer between 0 and 23') | ||
| if ival < 0 or ival > 23: | ||
| raise ValueError(f'[{name}] must be between 0 and 23 (got {ival})') | ||
| return ival | ||
|
|
||
| @property | ||
| def zone_1_start(self) -> int: | ||
| return self._zone_1_start | ||
|
|
||
| @zone_1_start.setter | ||
| def zone_1_start(self, value: int) -> None: | ||
| self._zone_1_start = self._validate_hour(value, 'zone_1_start') | ||
|
|
||
| @property | ||
| def zone_1_end(self) -> int: | ||
| return self._zone_1_end | ||
|
|
||
| @zone_1_end.setter | ||
| def zone_1_end(self, value: int) -> None: | ||
| self._zone_1_end = self._validate_hour(value, 'zone_1_end') | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import datetime | ||
| import pytest | ||
| import pytz | ||
|
|
||
| from batcontrol.dynamictariff.tariffzones import TariffZones | ||
|
|
||
|
|
||
| class DummyTariffZones(TariffZones): | ||
| """Concrete test subclass implementing the abstract provider method.""" | ||
| def __init__(self, timezone): | ||
| super().__init__(timezone) | ||
| # provide default zone prices for tests | ||
| self.tariff_zone_1 = 1.0 | ||
| self.tariff_zone_2 = 2.0 | ||
|
|
||
| def get_raw_data_from_provider(self) -> dict: | ||
| return {} | ||
|
|
||
|
|
||
| def make_tz(): | ||
| return pytz.timezone('Europe/Berlin') | ||
|
|
||
|
|
||
| def test_validate_hour_accepts_integer(): | ||
| tz = make_tz() | ||
| t = DummyTariffZones(tz) | ||
| assert t._validate_hour(0, 'zone_1_start') == 0 | ||
| assert t._validate_hour(23, 'zone_1_end') == 23 | ||
|
|
||
|
|
||
| def test_validate_hour_rejects_out_of_range(): | ||
| tz = make_tz() | ||
| t = DummyTariffZones(tz) | ||
| with pytest.raises(ValueError): | ||
| t._validate_hour(-1, 'zone_1_start') | ||
| with pytest.raises(ValueError): | ||
| t._validate_hour(24, 'zone_1_end') | ||
|
|
||
|
|
||
| def test_validate_hour_accepts_float_by_int_conversion(): | ||
| tz = make_tz() | ||
| t = DummyTariffZones(tz) | ||
| # Current implementation converts floats via int(), so 7.9 -> 7 | ||
| assert t._validate_hour(7.9, 'zone_1_start') == 7 | ||
|
|
||
|
|
||
| def test_validate_hour_rejects_string_decimal(): | ||
| tz = make_tz() | ||
| t = DummyTariffZones(tz) | ||
| # Strings with decimal point cannot be int()-cast -> ValueError | ||
| with pytest.raises(ValueError): | ||
| t._validate_hour('7.5', 'zone_1_start') | ||
|
|
||
|
|
||
| def test_property_setters_and_getters(): | ||
| tz = make_tz() | ||
| t = DummyTariffZones(tz) | ||
| t.zone_1_start = 5 | ||
| t.zone_1_end = 22 | ||
| assert t.zone_1_start == 5 | ||
| assert t.zone_1_end == 22 | ||
|
|
||
| with pytest.raises(ValueError): | ||
| t.zone_1_start = -2 | ||
| with pytest.raises(ValueError): | ||
| t.zone_1_end = 100 | ||
|
|
||
|
|
||
| def test_get_prices_native_uses_raw_data_boundaries(): | ||
| tz = make_tz() | ||
| t = DummyTariffZones(tz) | ||
|
|
||
| # prepare raw data and store it in the provider cache | ||
| raw = { | ||
| 'tariff_zone_1': 10.0, | ||
| 'tariff_zone_2': 20.0, | ||
| 'zone_1_start': 7, | ||
| 'zone_1_end': 22, | ||
| } | ||
| t.store_raw_data(raw) | ||
|
|
||
| prices = t._get_prices_native() | ||
| assert len(prices) == 48 | ||
|
|
||
| # Compute current hour start same way as provider | ||
| now = datetime.datetime.now().astimezone(t.timezone) | ||
| current_hour_start = now.replace(minute=0, second=0, microsecond=0) | ||
|
|
||
| for rel_hour, price in prices.items(): | ||
| ts = current_hour_start + datetime.timedelta(hours=rel_hour) | ||
| h = ts.hour | ||
| if 7 <= 22: | ||
| is_day = (h >= 7 and h < 22) | ||
| else: | ||
| is_day = not (h >= 22 and h < 7) | ||
|
|
||
| expected = raw['tariff_zone_1'] if is_day else raw['tariff_zone_2'] | ||
| assert price == expected |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tariff values are extracted as floats, but there's no validation to ensure they are positive numbers or reasonable values. Negative or extremely large tariff values could cause issues in the battery control logic. Consider adding validation to ensure tariff_zone_1 and tariff_zone_2 are positive floats within a reasonable range.