diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index ace360a..b4bf231 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -345,7 +345,7 @@ async def main(): try: energy_site = api.energySites.create(12345) - time_of_use_settings_response = await energy_site.time_of_use_settings(settings={"tou_settings": {"tariff_content_v2": {}}}) + time_of_use_settings_response = await energy_site.time_of_use_settings(settings={}) print(time_of_use_settings_response) except TeslaFleetError as e: print(e) @@ -353,6 +353,37 @@ async def main(): asyncio.run(main()) ``` +The top-level `get_tariff_periods` helper resolves the current buy and sell +rates from a raw `tariff_content_v2` object without making any API calls. +`unwrap_tariff_v2` accepts the `site_info()` response envelope, the +`tou_settings` write envelope, or a bare tariff object: + +```python +from datetime import datetime +from zoneinfo import ZoneInfo + +from tesla_fleet_api import get_tariff_periods, unwrap_tariff_v2 + +site_info = await energy_site.site_info() +tariff = unwrap_tariff_v2(site_info) +site_timezone = ZoneInfo("") +now = datetime.now(site_timezone) +resolution = get_tariff_periods(tariff, now, horizon_hours=24) +``` + +`now` must be timezone-aware and expressed in the site's local timezone because +the tariff object does not carry its own timezone. A naive `now` raises +`ValueError`. The result contains the current buy and sell rates, the current +period's start, the next change, the currency, and (when `horizon_hours` is +provided) a contiguous timeline of upcoming periods. Gaps in the buy schedule +are preserved as one merged `TariffPeriod` with both rates set to `None`; the +gap ends when the tariff resumes, or at the horizon deadline if it does not. +The helper returns `None` when no tariff season covers `now`. Missing rates +remain `None`, while a real zero price remains `0.0`. +Null or malformed inputs passed to `unwrap_tariff_v2` raise `InvalidResponse`. +This includes every accepted envelope or bare tariff object whose extracted +tariff lacks either `seasons` or `energy_charges`. + ## Device Commands Energy gateways (Powerwalls, etc.) support gRPC commands sent via `POST /api/1/energy_sites/{id}/command`. These are undocumented Tesla API endpoints that communicate directly with the gateway hardware. All device command methods require the `energy_cmds` scope. diff --git a/tesla_fleet_api.egg-info/SOURCES.txt b/tesla_fleet_api.egg-info/SOURCES.txt index fd2c561..84d18b7 100644 --- a/tesla_fleet_api.egg-info/SOURCES.txt +++ b/tesla_fleet_api.egg-info/SOURCES.txt @@ -5,6 +5,7 @@ tesla_fleet_api/__init__.py tesla_fleet_api/const.py tesla_fleet_api/exceptions.py tesla_fleet_api/py.typed +tesla_fleet_api/tariff.py tesla_fleet_api/util.py tesla_fleet_api.egg-info/PKG-INFO tesla_fleet_api.egg-info/SOURCES.txt @@ -91,8 +92,9 @@ tests/test_proto_compatibility.py tests/test_proto_coverage_lock.py tests/test_router.py tests/test_session_info_authentication.py +tests/test_tariff.py tests/test_tesla_private_key.py tests/test_teslemetry_authorized_clients.py tests/test_teslemetry_gateway_address.py tests/test_tessie_vehicle_params.py -tests/test_vehicle_image_state.py +tests/test_vehicle_image_state.py \ No newline at end of file diff --git a/tesla_fleet_api/__init__.py b/tesla_fleet_api/__init__.py index b637845..655fdfa 100644 --- a/tesla_fleet_api/__init__.py +++ b/tesla_fleet_api/__init__.py @@ -4,6 +4,13 @@ __version__ = "1.7.7" from tesla_fleet_api.const import Region, is_valid_region +from tesla_fleet_api.tariff import ( + TariffPeriod, + TariffRate, + TariffResolution, + get_tariff_periods, + unwrap_tariff_v2, +) from tesla_fleet_api.tesla.bluetooth import TeslaBluetooth from tesla_fleet_api.tesla.fleet import TeslaFleetApi from tesla_fleet_api.tesla.oauth import TeslaFleetOAuth @@ -13,6 +20,9 @@ __all__ = [ "Region", + "TariffPeriod", + "TariffRate", + "TariffResolution", "TeslaFleetApi", "TeslaBluetooth", "TeslaFleetOAuth", @@ -20,5 +30,7 @@ "Tessie", "firmware_at_least", "firmware_compare", + "get_tariff_periods", "is_valid_region", + "unwrap_tariff_v2", ] diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py new file mode 100644 index 0000000..a709b76 --- /dev/null +++ b/tesla_fleet_api/tariff.py @@ -0,0 +1,561 @@ +"""Pure, offline resolver for the Tesla Energy Tariff V2 (time-of-use) object. + +Consumes the raw ``tariff_content_v2`` object returned in +``EnergySite.site_info()`` responses or accepted under ``tou_settings`` when +writing time-of-use settings. There is no typed model for it elsewhere in this +library. Everything here is a pure function over caller-supplied data: no I/O, +no network, no vehicle/VPP access. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Any, Mapping, cast + +from tesla_fleet_api.exceptions import InvalidResponse + +_MINUTES_PER_DAY = 24 * 60 +_MINUTES_PER_WEEK = 7 * _MINUTES_PER_DAY + + +@dataclass(frozen=True, slots=True) +class TariffRate: + """One side (buy or sell) of a resolved tariff rate. + + ``price`` is ``None`` only when the rate is genuinely unresolvable + (missing from the tariff) - a real ``0.0`` price is returned as such. + """ + + price: float | None + period_name: str | None + season_name: str | None + + +@dataclass(frozen=True, slots=True) +class TariffPeriod: + """One resolved rate pair or unresolved gap for ``[start, end)``.""" + + start: datetime + end: datetime + buy: TariffRate + sell: TariffRate + + +@dataclass(frozen=True, slots=True) +class TariffResolution: + """The tariff rate in effect at the moment passed to :func:`get_tariff_periods`.""" + + buy: TariffRate + sell: TariffRate + current_start: datetime + next_change: datetime + currency: str | None + upcoming: list[TariffPeriod] | None + + +_UNRESOLVED_RATE = TariffRate(price=None, period_name=None, season_name=None) + + +def unwrap_tariff_v2(response: Any) -> dict[str, Any]: + """Extract the ``tariff_content_v2`` object from a raw API response. + + Accepts the ``site_info`` read envelope + (``{"response": {"tariff_content_v2": {...}}}``), the + ``time_of_use_settings`` write envelope + (``{"tou_settings": {"tariff_content_v2": {...}}}``), or the bare + ``tariff_content_v2`` object itself. Raises + :class:`~tesla_fleet_api.exceptions.InvalidResponse` for a null body, an + unrecognized envelope, or an extracted object without the minimal + Tariff V2 shape (``seasons`` and ``energy_charges``) - checked + identically for every path, since a malformed body (including an empty + ``{}`` nested under a recognized envelope) is not the same as "no + tariff configured". + """ + if response is None: + raise InvalidResponse("tariff response body was null") + if not isinstance(response, dict): + raise InvalidResponse(repr(response)) + body = cast("dict[str, Any]", response) + + candidate: Any + if "tariff_content_v2" in body: + candidate = body["tariff_content_v2"] + elif "response" in body or "tou_settings" in body: + envelope_key = "response" if "response" in body else "tou_settings" + inner = body[envelope_key] + candidate = ( + cast("dict[str, Any]", inner).get("tariff_content_v2") + if isinstance(inner, dict) + else None + ) + else: + # No recognized envelope key present - treat the input as the bare + # `tariff_content_v2` object itself. + candidate = body + + if ( + isinstance(candidate, dict) + and "seasons" in candidate + and "energy_charges" in candidate + ): + return cast("dict[str, Any]", candidate) + raise InvalidResponse(str(body)) + + +def get_tariff_periods( + tariff: Mapping[str, Any], + now: datetime, + *, + horizon_hours: float | None = None, +) -> TariffResolution | None: + """Resolve the buy/sell tariff rate in effect at ``now``. + + ``tariff`` is the already-unwrapped ``tariff_content_v2`` object (see + :func:`unwrap_tariff_v2`). ``now`` must be timezone-aware and expressed + in the site's own local timezone - the tariff object carries no + timezone of its own, so a naive ``now`` would silently resolve against + the wrong wall clock; this raises :class:`ValueError` instead. + + Returns ``None`` when no season in the tariff covers ``now``'s date. + When ``horizon_hours`` is given, ``upcoming`` is populated. See + ``docs/fleet_api_energy_sites.md`` for horizon and gap semantics. + """ + if now.tzinfo is None or now.tzinfo.utcoffset(now) is None: + raise ValueError("now must be timezone-aware") + + resolved = _resolve_at(tariff, now) + if resolved is None: + return None + + upcoming: list[TariffPeriod] | None = None + if horizon_hours is not None: + upcoming = [] + deadline = now + timedelta(hours=horizon_hours) + cursor = resolved + while True: + upcoming.append( + TariffPeriod( + start=cursor.current_start, + end=cursor.next_change, + buy=cursor.buy, + sell=cursor.sell, + ) + ) + if cursor.next_change >= deadline: + break + + # `cursor.next_change` may fall in a gap no buy period covers - + # advance THROUGH it to the next tariff boundary instead of + # stopping the walk here. A single jump (e.g. to a new season's + # start) can itself land in another gap (that season's own grid + # might not cover midnight either), so keep advancing until the + # buy side actually resolves or the horizon runs out, then + # record the whole span as one unresolved segment so `upcoming` + # stays a contiguous timeline with no silent hole. + gap_start = cursor.next_change + probe = gap_start + next_resolved = _resolve_at(tariff, probe) + while next_resolved is None: + gap_end = _next_resolvable(tariff, probe) + if gap_end is None or gap_end >= deadline: + probe = deadline + break + if gap_end <= probe: + raise ValueError("tariff gap boundary does not advance") + probe = gap_end + next_resolved = _resolve_at(tariff, probe) + + if probe > gap_start: + upcoming.append( + TariffPeriod( + start=gap_start, + end=probe, + buy=_UNRESOLVED_RATE, + sell=_UNRESOLVED_RATE, + ) + ) + if next_resolved is None: + break + if next_resolved.next_change <= cursor.next_change: + raise ValueError("tariff period boundaries do not advance") + cursor = next_resolved + + return TariffResolution( + buy=resolved.buy, + sell=resolved.sell, + current_start=resolved.current_start, + next_change=resolved.next_change, + currency=tariff.get("currency"), + upcoming=upcoming, + ) + + +@dataclass(frozen=True, slots=True) +class _Resolved: + buy: TariffRate + sell: TariffRate + current_start: datetime + next_change: datetime + + +def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None: + today = moment.date() + now_mow = _minute_of_week(moment) + + buy_season_name, buy_windows, buy_season_dates = _season_windows( + tariff.get("seasons"), today + ) + if buy_season_name is None: + return None + buy_match = _match_window(buy_windows, now_mow) + if buy_match is None: + return None + buy_period_name, buy_start, buy_end = buy_match + buy_rate = TariffRate( + price=_lookup_price( + tariff.get("energy_charges"), buy_season_name, buy_period_name + ), + period_name=buy_period_name, + season_name=buy_season_name, + ) + + sell_rate = TariffRate(price=None, period_name=None, season_name=None) + sell_match: tuple[str, int, int] | None = None + sell_windows: list[tuple[str, int, int]] = [] + sell_season_dates: tuple[date, date] | None = None + sell_gap_dates: tuple[date, date] | None = None + sell_tariff = tariff.get("sell_tariff") + if isinstance(sell_tariff, dict): + sell_tariff = cast("dict[str, Any]", sell_tariff) + sell_season_name, sell_windows, sell_season_dates = _season_windows( + sell_tariff.get("seasons"), today + ) + if sell_season_name is not None: + sell_match = _match_window(sell_windows, now_mow) + if sell_match is not None: + sell_period_name = sell_match[0] + sell_rate = TariffRate( + price=_lookup_price( + sell_tariff.get("energy_charges"), + sell_season_name, + sell_period_name, + ), + period_name=sell_period_name, + season_name=sell_season_name, + ) + else: + sell_gap_dates = _adjacent_season_dates(sell_tariff.get("seasons"), today) + + # Bound the resolved interval to the matched window(s)' own start/end, + # not the next start anywhere in the grid - a sparse tariff (gaps + # between periods) must transition at the current period's actual end, + # not carry its rate forward to whatever period happens to start next. + since_delta, until_delta = _window_offsets(now_mow, buy_start, buy_end) + if sell_match is not None: + sell_since, sell_until = _window_offsets(now_mow, sell_match[1], sell_match[2]) + since_delta = min(since_delta, sell_since) + until_delta = min(until_delta, sell_until) + elif sell_windows: + sell_since, sell_until = _inactive_window_offsets(now_mow, sell_windows) + since_delta = min(since_delta, sell_since) + until_delta = min(until_delta, sell_until) + + moment_floor = moment.replace(second=0, microsecond=0) + current_start = moment_floor - timedelta(minutes=since_delta) + next_change = moment_floor + timedelta(minutes=until_delta) + for season_dates in (buy_season_dates, sell_season_dates): + if season_dates is None: + continue + season_start, season_end = ( + datetime.combine(boundary, datetime.min.time(), tzinfo=moment.tzinfo) + for boundary in season_dates + ) + current_start = max(current_start, season_start) + next_change = min(next_change, season_end) + if sell_gap_dates is not None: + gap_start, gap_end = ( + datetime.combine(boundary, datetime.min.time(), tzinfo=moment.tzinfo) + for boundary in sell_gap_dates + ) + current_start = max(current_start, gap_start) + next_change = min(next_change, gap_end) + + return _Resolved( + buy=buy_rate, + sell=sell_rate, + current_start=current_start, + next_change=next_change, + ) + + +def _minute_of_week(moment: datetime) -> int: + """Monday 00:00 = 0 .. Sunday 23:59 = 10079, matching ``datetime.weekday()``.""" + return moment.weekday() * _MINUTES_PER_DAY + moment.hour * 60 + moment.minute + + +def _as_int(value: Any, default: int) -> int: + if value is None: + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _season_covers(season: Mapping[str, Any], today: date) -> bool: + from_month = season.get("fromMonth") + from_day = season.get("fromDay") + to_month = season.get("toMonth") + to_day = season.get("toDay") + if from_month is None or from_day is None or to_month is None or to_day is None: + return False + start, end = _season_dates(season, today) + return start <= today < end + + +def _season_windows( + seasons: Any, today: date +) -> tuple[str | None, list[tuple[str, int, int]], tuple[date, date] | None]: + """Find the season covering ``today`` and expand its period grid. + + Skips seasons with no ``tou_periods`` (an empty ``{}`` season object is + legal and present in real tariffs, e.g. an unused "Winter"). + """ + if not isinstance(seasons, dict): + return None, [], None + for name, season in cast("dict[str, Any]", seasons).items(): + if not isinstance(season, dict): + continue + season = cast("dict[str, Any]", season) + periods = season.get("tou_periods") + if not isinstance(periods, dict) or not periods: + continue + if _season_covers(season, today): + return ( + name, + _expand_periods(cast("dict[str, Any]", periods)), + _season_dates(season, today), + ) + return None, [], None + + +def _season_dates(season: Mapping[str, Any], today: date) -> tuple[date, date]: + start_month = _as_int(season.get("fromMonth"), 0) + start_day = _as_int(season.get("fromDay"), 0) + end_month = _as_int(season.get("toMonth"), 0) + end_day = _as_int(season.get("toDay"), 0) + start_fields = (start_month, start_day) + end_fields = (end_month, end_day) + + if start_fields <= end_fields: + start_year = end_year = today.year + elif (today.month, today.day) >= start_fields: + start_year, end_year = today.year, today.year + 1 + else: + start_year, end_year = today.year - 1, today.year + + start = _recurring_date(start_year, start_month, start_day) + end = _recurring_date(end_year, end_month, end_day) + timedelta(days=1) + return start, end + + +def _recurring_date(year: int, month: int, day: int) -> date: + try: + return date(year, month, day) + except ValueError: + if month == 2 and day == 29: + return date(year, 2, 28) + raise + + +def _adjacent_season_dates(seasons: Any, today: date) -> tuple[date, date] | None: + if not isinstance(seasons, dict): + return None + boundaries: set[date] = set() + for season in cast("dict[str, Any]", seasons).values(): + if not isinstance(season, dict): + continue + season = cast("dict[str, Any]", season) + periods = season.get("tou_periods") + if not isinstance(periods, dict) or not periods: + continue + start_fields = ( + _as_int(season.get("fromMonth"), 0), + _as_int(season.get("fromDay"), 0), + ) + end_fields = ( + _as_int(season.get("toMonth"), 0), + _as_int(season.get("toDay"), 0), + ) + for start_year in range(today.year - 2, today.year + 3): + end_year = start_year + (end_fields < start_fields) + boundaries.add(_recurring_date(start_year, *start_fields)) + boundaries.add(_recurring_date(end_year, *end_fields) + timedelta(days=1)) + previous = [boundary for boundary in boundaries if boundary <= today] + upcoming = [boundary for boundary in boundaries if boundary > today] + if not previous or not upcoming: + return None + return max(previous), min(upcoming) + + +def _next_resolvable(tariff: Mapping[str, Any], moment: datetime) -> datetime | None: + """Find the next moment at/after ``moment`` where the buy side resolves, + given that ``moment`` itself falls in a gap no buy period covers. + + Two distinct kinds of gap: a season covers ``moment``'s date but no + window in its grid matches (an intra-day/weekly gap, resolved via the + same nearest-boundary search used for an inactive sell grid), or no + season covers the date at all (resolved via the next season-start + boundary, the same search used for an uncovered sell season). Returns + ``None`` when no further boundary exists (the tariff never resumes). + """ + today = moment.date() + seasons = tariff.get("seasons") + _, buy_windows, season_dates = _season_windows(seasons, today) + if buy_windows: + now_mow = _minute_of_week(moment) + _, until = _inactive_window_offsets(now_mow, buy_windows) + moment_floor = moment.replace(second=0, microsecond=0) + next_window = moment_floor + timedelta(minutes=until) + if season_dates is None: + return next_window + season_end = datetime.combine( + season_dates[1], datetime.min.time(), tzinfo=moment.tzinfo + ) + return min(next_window, season_end) + boundary = _adjacent_season_dates(seasons, today) + if boundary is None: + return None + _, next_start = boundary + return datetime.combine(next_start, datetime.min.time(), tzinfo=moment.tzinfo) + + +def _expand_periods(tou_periods: Mapping[str, Any]) -> list[tuple[str, int, int]]: + windows: list[tuple[str, int, int]] = [] + for period_name, period_obj in tou_periods.items(): + if not isinstance(period_obj, dict): + continue + entries = cast("dict[str, Any]", period_obj).get("periods") + if not isinstance(entries, list): + continue + for entry in cast("list[Any]", entries): + if isinstance(entry, dict): + windows.extend( + _expand_entry(period_name, cast("dict[str, Any]", entry)) + ) + return windows + + +def _expand_entry( + period_name: str, entry: Mapping[str, Any] +) -> list[tuple[str, int, int]]: + """Expand one ``periods[]`` entry into a (name, start, end) minute-of-week + window per day-of-week it applies to. + + Missing fields default per the observed wire format: + ``fromDayOfWeek``/``fromHour``/``fromMinute`` -> 0, ``toDayOfWeek`` -> 6. + ``toHour: 24`` / ``toMinute: 60`` occur in real tariffs and mean "next + midnight" - plain minute-of-day arithmetic rolls them into the next day + without ever constructing an invalid ``datetime(hour=24)``. + """ + from_dow = _as_int(entry.get("fromDayOfWeek"), 0) + to_dow = _as_int(entry.get("toDayOfWeek"), 6) + from_hour = _as_int(entry.get("fromHour"), 0) + from_minute = _as_int(entry.get("fromMinute"), 0) + to_hour = _as_int(entry.get("toHour"), 0) + to_minute = _as_int(entry.get("toMinute"), 0) + + if from_dow <= to_dow: + days = list(range(from_dow, to_dow + 1)) + else: + days = [*range(from_dow, 7), *range(0, to_dow + 1)] + + windows: list[tuple[str, int, int]] = [] + for day in days: + start = day * _MINUTES_PER_DAY + from_hour * 60 + from_minute + end = day * _MINUTES_PER_DAY + to_hour * 60 + to_minute + if end <= start: + end += _MINUTES_PER_DAY + windows.append((period_name, start, end)) + return windows + + +def _window_contains(now_mow: int, start: int, duration: int) -> bool: + start_mod = start % _MINUTES_PER_WEEK + end = start_mod + duration + if start_mod <= now_mow < end: + return True + # Week-boundary wrap: a window ending after Sunday midnight is also + # "this week's" window one week earlier. + return start_mod <= now_mow + _MINUTES_PER_WEEK < end + + +def _match_window( + windows: list[tuple[str, int, int]], now_mow: int +) -> tuple[str, int, int] | None: + for name, start, end in windows: + if _window_contains(now_mow, start, end - start): + return name, start, end + return None + + +def _window_offsets(now_mow: int, start: int, end: int) -> tuple[int, int]: + """Return (minutes since this window's start, minutes until its end). + + ``start``/``end`` must be a window known to contain ``now_mow`` (see + ``_window_contains``) - the same week-boundary-wrap case is resolved + the same way here. + """ + duration = end - start + start_mod = start % _MINUTES_PER_WEEK + if start_mod <= now_mow < start_mod + duration: + since = now_mow - start_mod + else: + since = (now_mow + _MINUTES_PER_WEEK) - start_mod + return since, duration - since + + +def _inactive_window_offsets( + now_mow: int, windows: list[tuple[str, int, int]] +) -> tuple[int, int]: + boundaries = { + boundary % _MINUTES_PER_WEEK + for _, start, end in windows + for boundary in (start, end) + } + since = min((now_mow - boundary) % _MINUTES_PER_WEEK for boundary in boundaries) + until = min( + distance + for boundary in boundaries + if (distance := (boundary - now_mow) % _MINUTES_PER_WEEK) > 0 + ) + return since, until + + +def _lookup_price(charges: Any, season_name: str, period_name: str) -> float | None: + """``charges[season].rates[period]`` with ``season``/``period`` -> ``"ALL"`` fallback. + + Uses key-presence checks throughout, never truthiness - a legal ``0.0`` + price must never be treated as missing. + """ + if not isinstance(charges, dict): + return None + charges = cast("dict[str, Any]", charges) + rates: dict[str, Any] | None = None + for key in (season_name, "ALL"): + block = charges.get(key) + if isinstance(block, dict) and isinstance( + cast("dict[str, Any]", block).get("rates"), dict + ): + rates = cast("dict[str, Any]", cast("dict[str, Any]", block)["rates"]) + break + if rates is None: + return None + for key in (period_name, "ALL"): + if key in rates: + try: + return float(rates[key]) + except (TypeError, ValueError): + return None + return None diff --git a/tests/fixtures/tariff_v2_sample.json b/tests/fixtures/tariff_v2_sample.json new file mode 100644 index 0000000..02ac2ed --- /dev/null +++ b/tests/fixtures/tariff_v2_sample.json @@ -0,0 +1,1124 @@ +{ + "response": { + "tariff_content_v2": { + "code": "POWER_SYNC:FLOW_POWER", + "name": "Flow Power (PowerSync)", + "utility": "Flow Power", + "currency": "AUD", + "version": 1, + "daily_charges": [ + { + "name": "Charge" + } + ], + "demand_charges": { + "ALL": { + "rates": { + "ALL": 0 + } + }, + "Summer": {}, + "Winter": {} + }, + "energy_charges": { + "ALL": { + "rates": { + "ALL": 0 + } + }, + "Summer": { + "rates": { + "PERIOD_00_00": 0.24, + "PERIOD_00_30": 0.24, + "PERIOD_01_00": 0.24, + "PERIOD_01_30": 0.24, + "PERIOD_02_00": 0.24, + "PERIOD_02_30": 0.24, + "PERIOD_03_00": 0.24, + "PERIOD_03_30": 0.24, + "PERIOD_04_00": 0.24, + "PERIOD_04_30": 0.24, + "PERIOD_05_00": 0.24, + "PERIOD_05_30": 0.24, + "PERIOD_06_00": 0.31, + "PERIOD_06_30": 0.31, + "PERIOD_07_00": 0.31, + "PERIOD_07_30": 0.31, + "PERIOD_08_00": 0.31, + "PERIOD_08_30": 0.31, + "PERIOD_09_00": 0.31, + "PERIOD_09_30": 0.31, + "PERIOD_10_00": 0.31, + "PERIOD_10_30": 0.31, + "PERIOD_11_00": 0.31, + "PERIOD_11_30": 0.31, + "PERIOD_12_00": 0.31, + "PERIOD_12_30": 0.31, + "PERIOD_13_00": 0.31, + "PERIOD_13_30": 0.31, + "PERIOD_14_00": 0.31, + "PERIOD_14_30": 0.31, + "PERIOD_15_00": 0.31, + "PERIOD_15_30": 0.31, + "PERIOD_16_00": 0.42, + "PERIOD_16_30": 0.42, + "PERIOD_17_00": 0.42, + "PERIOD_17_30": 0.42, + "PERIOD_18_00": 0.42, + "PERIOD_18_30": 0.42, + "PERIOD_19_00": 0.42, + "PERIOD_19_30": 0.42, + "PERIOD_20_00": 0.42, + "PERIOD_20_30": 0.42, + "PERIOD_21_00": 0.31, + "PERIOD_21_30": 0.31, + "PERIOD_22_00": 0.31, + "PERIOD_22_30": 0.31, + "PERIOD_23_00": 0.31, + "PERIOD_23_30": 0.31 + } + }, + "Winter": {} + }, + "seasons": { + "Summer": { + "fromDay": 1, + "toDay": 31, + "fromMonth": 1, + "toMonth": 12, + "tou_periods": { + "PERIOD_00_00": { + "periods": [ + { + "toDayOfWeek": 6, + "toMinute": 30 + } + ] + }, + "PERIOD_00_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromMinute": 30, + "toHour": 1 + } + ] + }, + "PERIOD_01_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 1, + "toHour": 1, + "toMinute": 30 + } + ] + }, + "PERIOD_01_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 1, + "fromMinute": 30, + "toHour": 2 + } + ] + }, + "PERIOD_02_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 2, + "toHour": 2, + "toMinute": 30 + } + ] + }, + "PERIOD_02_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 2, + "fromMinute": 30, + "toHour": 3 + } + ] + }, + "PERIOD_03_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 3, + "toHour": 3, + "toMinute": 30 + } + ] + }, + "PERIOD_03_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 3, + "fromMinute": 30, + "toHour": 4 + } + ] + }, + "PERIOD_04_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 4, + "toHour": 4, + "toMinute": 30 + } + ] + }, + "PERIOD_04_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 4, + "fromMinute": 30, + "toHour": 5 + } + ] + }, + "PERIOD_05_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 5, + "toHour": 5, + "toMinute": 30 + } + ] + }, + "PERIOD_05_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 5, + "fromMinute": 30, + "toHour": 6 + } + ] + }, + "PERIOD_06_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "toHour": 6, + "toMinute": 30 + } + ] + }, + "PERIOD_06_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "fromMinute": 30, + "toHour": 7 + } + ] + }, + "PERIOD_07_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 7, + "toHour": 7, + "toMinute": 30 + } + ] + }, + "PERIOD_07_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 7, + "fromMinute": 30, + "toHour": 8 + } + ] + }, + "PERIOD_08_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 8, + "toHour": 8, + "toMinute": 30 + } + ] + }, + "PERIOD_08_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 8, + "fromMinute": 30, + "toHour": 9 + } + ] + }, + "PERIOD_09_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 9, + "toHour": 9, + "toMinute": 30 + } + ] + }, + "PERIOD_09_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 9, + "fromMinute": 30, + "toHour": 10 + } + ] + }, + "PERIOD_10_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 10, + "toHour": 10, + "toMinute": 30 + } + ] + }, + "PERIOD_10_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 10, + "fromMinute": 30, + "toHour": 11 + } + ] + }, + "PERIOD_11_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 11, + "toHour": 11, + "toMinute": 30 + } + ] + }, + "PERIOD_11_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 11, + "fromMinute": 30, + "toHour": 12 + } + ] + }, + "PERIOD_12_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 12, + "toHour": 12, + "toMinute": 30 + } + ] + }, + "PERIOD_12_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 12, + "fromMinute": 30, + "toHour": 13 + } + ] + }, + "PERIOD_13_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 13, + "toHour": 13, + "toMinute": 30 + } + ] + }, + "PERIOD_13_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 13, + "fromMinute": 30, + "toHour": 14 + } + ] + }, + "PERIOD_14_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 14, + "toHour": 14, + "toMinute": 30 + } + ] + }, + "PERIOD_14_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 14, + "fromMinute": 30, + "toHour": 15 + } + ] + }, + "PERIOD_15_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 15, + "toHour": 15, + "toMinute": 30 + } + ] + }, + "PERIOD_15_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 15, + "fromMinute": 30, + "toHour": 16 + } + ] + }, + "PERIOD_16_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 16, + "toHour": 16, + "toMinute": 30 + } + ] + }, + "PERIOD_16_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 16, + "fromMinute": 30, + "toHour": 17 + } + ] + }, + "PERIOD_17_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 17, + "toHour": 17, + "toMinute": 30 + } + ] + }, + "PERIOD_17_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 17, + "fromMinute": 30, + "toHour": 18 + } + ] + }, + "PERIOD_18_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 18, + "toHour": 18, + "toMinute": 30 + } + ] + }, + "PERIOD_18_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 18, + "fromMinute": 30, + "toHour": 19 + } + ] + }, + "PERIOD_19_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 19, + "toHour": 19, + "toMinute": 30 + } + ] + }, + "PERIOD_19_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 19, + "fromMinute": 30, + "toHour": 20 + } + ] + }, + "PERIOD_20_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 20, + "toHour": 20, + "toMinute": 30 + } + ] + }, + "PERIOD_20_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 20, + "fromMinute": 30, + "toHour": 21 + } + ] + }, + "PERIOD_21_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 21, + "toHour": 21, + "toMinute": 30 + } + ] + }, + "PERIOD_21_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 21, + "fromMinute": 30, + "toHour": 22 + } + ] + }, + "PERIOD_22_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 22, + "toHour": 22, + "toMinute": 30 + } + ] + }, + "PERIOD_22_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 22, + "fromMinute": 30, + "toHour": 23 + } + ] + }, + "PERIOD_23_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 23, + "toHour": 23, + "toMinute": 30 + } + ] + }, + "PERIOD_23_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 23, + "fromMinute": 30, + "toHour": 24 + } + ] + } + } + }, + "Winter": {} + }, + "sell_tariff": { + "name": "Flow Power (managed by PowerSync)", + "energy_charges": { + "ALL": { + "rates": { + "ALL": 0 + } + }, + "Summer": { + "rates": { + "PERIOD_00_00": 0.0, + "PERIOD_00_30": 0.0, + "PERIOD_01_00": 0.0, + "PERIOD_01_30": 0.0, + "PERIOD_02_00": 0.0, + "PERIOD_02_30": 0.0, + "PERIOD_03_00": 0.0, + "PERIOD_03_30": 0.0, + "PERIOD_04_00": 0.0, + "PERIOD_04_30": 0.0, + "PERIOD_05_00": 0.0, + "PERIOD_05_30": 0.0, + "PERIOD_06_00": 0.0, + "PERIOD_06_30": 0.0, + "PERIOD_07_00": 0.0, + "PERIOD_07_30": 0.0, + "PERIOD_08_00": 0.0, + "PERIOD_08_30": 0.0, + "PERIOD_09_00": 0.0, + "PERIOD_09_30": 0.0, + "PERIOD_10_00": 0.0, + "PERIOD_10_30": 0.0, + "PERIOD_11_00": 0.0, + "PERIOD_11_30": 0.0, + "PERIOD_12_00": 0.0, + "PERIOD_12_30": 0.0, + "PERIOD_13_00": 0.0, + "PERIOD_13_30": 0.0, + "PERIOD_14_00": 0.0, + "PERIOD_14_30": 0.0, + "PERIOD_15_00": 0.0, + "PERIOD_15_30": 0.0, + "PERIOD_16_00": 0.0, + "PERIOD_16_30": 0.0, + "PERIOD_17_00": 0.45, + "PERIOD_17_30": 0.45, + "PERIOD_18_00": 0.45, + "PERIOD_18_30": 0.0, + "PERIOD_19_00": 0.0, + "PERIOD_19_30": 0.0, + "PERIOD_20_00": 0.0, + "PERIOD_20_30": 0.0, + "PERIOD_21_00": 0.0, + "PERIOD_21_30": 0.0, + "PERIOD_22_00": 0.0, + "PERIOD_22_30": 0.0, + "PERIOD_23_00": 0.0, + "PERIOD_23_30": 0.0 + } + }, + "Winter": {} + }, + "seasons": { + "Summer": { + "fromDay": 1, + "toDay": 31, + "fromMonth": 1, + "toMonth": 12, + "tou_periods": { + "PERIOD_00_00": { + "periods": [ + { + "toDayOfWeek": 6, + "toMinute": 30 + } + ] + }, + "PERIOD_00_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromMinute": 30, + "toHour": 1 + } + ] + }, + "PERIOD_01_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 1, + "toHour": 1, + "toMinute": 30 + } + ] + }, + "PERIOD_01_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 1, + "fromMinute": 30, + "toHour": 2 + } + ] + }, + "PERIOD_02_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 2, + "toHour": 2, + "toMinute": 30 + } + ] + }, + "PERIOD_02_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 2, + "fromMinute": 30, + "toHour": 3 + } + ] + }, + "PERIOD_03_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 3, + "toHour": 3, + "toMinute": 30 + } + ] + }, + "PERIOD_03_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 3, + "fromMinute": 30, + "toHour": 4 + } + ] + }, + "PERIOD_04_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 4, + "toHour": 4, + "toMinute": 30 + } + ] + }, + "PERIOD_04_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 4, + "fromMinute": 30, + "toHour": 5 + } + ] + }, + "PERIOD_05_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 5, + "toHour": 5, + "toMinute": 30 + } + ] + }, + "PERIOD_05_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 5, + "fromMinute": 30, + "toHour": 6 + } + ] + }, + "PERIOD_06_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "toHour": 6, + "toMinute": 30 + } + ] + }, + "PERIOD_06_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "fromMinute": 30, + "toHour": 7 + } + ] + }, + "PERIOD_07_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 7, + "toHour": 7, + "toMinute": 30 + } + ] + }, + "PERIOD_07_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 7, + "fromMinute": 30, + "toHour": 8 + } + ] + }, + "PERIOD_08_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 8, + "toHour": 8, + "toMinute": 30 + } + ] + }, + "PERIOD_08_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 8, + "fromMinute": 30, + "toHour": 9 + } + ] + }, + "PERIOD_09_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 9, + "toHour": 9, + "toMinute": 30 + } + ] + }, + "PERIOD_09_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 9, + "fromMinute": 30, + "toHour": 10 + } + ] + }, + "PERIOD_10_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 10, + "toHour": 10, + "toMinute": 30 + } + ] + }, + "PERIOD_10_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 10, + "fromMinute": 30, + "toHour": 11 + } + ] + }, + "PERIOD_11_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 11, + "toHour": 11, + "toMinute": 30 + } + ] + }, + "PERIOD_11_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 11, + "fromMinute": 30, + "toHour": 12 + } + ] + }, + "PERIOD_12_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 12, + "toHour": 12, + "toMinute": 30 + } + ] + }, + "PERIOD_12_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 12, + "fromMinute": 30, + "toHour": 13 + } + ] + }, + "PERIOD_13_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 13, + "toHour": 13, + "toMinute": 30 + } + ] + }, + "PERIOD_13_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 13, + "fromMinute": 30, + "toHour": 14 + } + ] + }, + "PERIOD_14_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 14, + "toHour": 14, + "toMinute": 30 + } + ] + }, + "PERIOD_14_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 14, + "fromMinute": 30, + "toHour": 15 + } + ] + }, + "PERIOD_15_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 15, + "toHour": 15, + "toMinute": 30 + } + ] + }, + "PERIOD_15_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 15, + "fromMinute": 30, + "toHour": 16 + } + ] + }, + "PERIOD_16_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 16, + "toHour": 16, + "toMinute": 30 + } + ] + }, + "PERIOD_16_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 16, + "fromMinute": 30, + "toHour": 17 + } + ] + }, + "PERIOD_17_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 17, + "toHour": 17, + "toMinute": 30 + } + ] + }, + "PERIOD_17_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 17, + "fromMinute": 30, + "toHour": 18 + } + ] + }, + "PERIOD_18_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 18, + "toHour": 18, + "toMinute": 30 + } + ] + }, + "PERIOD_18_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 18, + "fromMinute": 30, + "toHour": 19 + } + ] + }, + "PERIOD_19_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 19, + "toHour": 19, + "toMinute": 30 + } + ] + }, + "PERIOD_19_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 19, + "fromMinute": 30, + "toHour": 20 + } + ] + }, + "PERIOD_20_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 20, + "toHour": 20, + "toMinute": 30 + } + ] + }, + "PERIOD_20_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 20, + "fromMinute": 30, + "toHour": 21 + } + ] + }, + "PERIOD_21_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 21, + "toHour": 21, + "toMinute": 30 + } + ] + }, + "PERIOD_21_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 21, + "fromMinute": 30, + "toHour": 22 + } + ] + }, + "PERIOD_22_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 22, + "toHour": 22, + "toMinute": 30 + } + ] + }, + "PERIOD_22_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 22, + "fromMinute": 30, + "toHour": 23 + } + ] + }, + "PERIOD_23_00": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 23, + "toHour": 23, + "toMinute": 30 + } + ] + }, + "PERIOD_23_30": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 23, + "fromMinute": 30, + "toHour": 24 + } + ] + } + } + }, + "Winter": {} + } + } + } + } +} diff --git a/tests/test_tariff.py b/tests/test_tariff.py new file mode 100644 index 0000000..0e5aebc --- /dev/null +++ b/tests/test_tariff.py @@ -0,0 +1,1003 @@ +"""Tests for :mod:`tesla_fleet_api.tariff`, the pure Tariff V2 rate resolver. + +``tests/fixtures/tariff_v2_sample.json`` is a live-shaped Tariff V2 capture +(48 half-hour buy/sell periods, one covering the whole year) modeled on a +real captured tariff, including its two real anomalies: +``toHour: 24`` on the last period of the day, and an empty ``"Winter": {}`` +season object. The Home Assistant reference this design ports matching logic +from crashes on the ``toHour: 24`` case (``base_day.replace(hour=24)``); the +regression test below locks in that this resolver does not. +""" + +from __future__ import annotations + +import copy +import json +from datetime import datetime, timedelta +from pathlib import Path +from unittest import TestCase +from zoneinfo import ZoneInfo + +from tesla_fleet_api.exceptions import InvalidResponse +from tesla_fleet_api.tariff import get_tariff_periods, unwrap_tariff_v2 + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "tariff_v2_sample.json" +TZ = ZoneInfo("Australia/Brisbane") + + +def _load_fixture() -> dict: + return json.loads(FIXTURE_PATH.read_text()) + + +def _fixture_tariff() -> dict: + return copy.deepcopy(unwrap_tariff_v2(_load_fixture())) + + +class UnwrapTariffV2Tests(TestCase): + def test_site_info_envelope(self): + response = _load_fixture() + tariff = unwrap_tariff_v2(response) + self.assertEqual(tariff["code"], "POWER_SYNC:FLOW_POWER") + + def test_time_of_use_settings_envelope(self): + tariff = _fixture_tariff() + response = {"tou_settings": {"tariff_content_v2": tariff}} + self.assertEqual(unwrap_tariff_v2(response), tariff) + + def test_bare_object(self): + tariff = _fixture_tariff() + self.assertEqual(unwrap_tariff_v2(tariff), tariff) + + def test_null_body_raises(self): + with self.assertRaises(InvalidResponse): + unwrap_tariff_v2(None) + + def test_malformed_shape_raises(self): + with self.assertRaises(InvalidResponse): + unwrap_tariff_v2({"response": {"nope": True}}) + + def test_empty_bare_dict_raises(self): + with self.assertRaises(InvalidResponse): + unwrap_tariff_v2({}) + + def test_unrelated_bare_dict_raises(self): + with self.assertRaises(InvalidResponse): + unwrap_tariff_v2({"unexpected": True}) + + def test_enveloped_empty_tariff_raises_via_response_envelope(self): + # The minimal-shape check must apply identically on every + # extraction path, not just the bare-object fallback - an empty + # `{}` nested under a recognized envelope is still malformed. + with self.assertRaises(InvalidResponse): + unwrap_tariff_v2({"response": {"tariff_content_v2": {}}}) + + def test_enveloped_empty_tariff_raises_via_tou_settings_envelope(self): + with self.assertRaises(InvalidResponse): + unwrap_tariff_v2({"tou_settings": {"tariff_content_v2": {}}}) + + def test_empty_tariff_raises_via_top_level_key(self): + with self.assertRaises(InvalidResponse): + unwrap_tariff_v2({"tariff_content_v2": {}}) + + +class FixtureTests(TestCase): + """Cover the live-shaped fixture's own anomalies.""" + + def setUp(self): + self.tariff = _fixture_tariff() + + def test_toHour_24_end_of_day_resolves_and_does_not_crash(self): + # 23:45 Monday falls in PERIOD_23_30 (23:30 -> 24:00), the exact + # entry the HA reference crashes on via `base_day.replace(hour=24)`. + now = datetime(2026, 7, 20, 23, 45, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "PERIOD_23_30") + # The period rolls into next-day midnight. + self.assertEqual( + result.next_change, + now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1), + ) + + def test_zero_sell_price_is_real_not_missing(self): + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now) + self.assertIsNotNone(result) + self.assertEqual(result.sell.price, 0.0) + self.assertIsNotNone(result.sell.period_name) + + def test_nonzero_sell_price_evening_peak(self): + now = datetime(2026, 7, 20, 17, 45, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now) + self.assertIsNotNone(result) + self.assertEqual(result.sell.price, 0.45) + self.assertEqual(result.buy.price, 0.42) + + def test_absent_sell_tariff_still_resolves_buy(self): + tariff = self.tariff + del tariff["sell_tariff"] + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(tariff, now) + self.assertIsNotNone(result) + self.assertIsNotNone(result.buy.price) + self.assertIsNone(result.sell.price) + self.assertIsNone(result.sell.period_name) + self.assertIsNone(result.sell.season_name) + + def test_missing_rate_for_period_returns_none_price(self): + tariff = self.tariff + del tariff["energy_charges"]["Summer"]["rates"]["PERIOD_10_00"] + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(tariff, now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "PERIOD_10_00") + self.assertIsNone(result.buy.price) + + def test_currency_passthrough(self): + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now) + self.assertEqual(result.currency, "AUD") + + def test_no_season_covers_now_returns_none(self): + tariff = self.tariff + tariff["seasons"]["Summer"]["fromMonth"] = 1 + tariff["seasons"]["Summer"]["toMonth"] = 1 + tariff["seasons"]["Summer"]["fromDay"] = 1 + tariff["seasons"]["Summer"]["toDay"] = 31 + del tariff["sell_tariff"] + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(tariff, now) + self.assertIsNone(result) + + def test_naive_now_raises_value_error(self): + now = datetime(2026, 7, 20, 10, 0) + with self.assertRaises(ValueError): + get_tariff_periods(self.tariff, now) + + def test_horizon_hours_produces_upcoming_periods(self): + now = datetime(2026, 7, 20, 23, 0, tzinfo=TZ) + result = get_tariff_periods(self.tariff, now, horizon_hours=2) + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + self.assertGreaterEqual(len(result.upcoming), 3) + self.assertEqual(result.upcoming[0].buy.period_name, "PERIOD_23_00") + + +def _season_geometry(from_month, from_day, to_month, to_day, tou_periods): + return { + "fromMonth": from_month, + "fromDay": from_day, + "toMonth": to_month, + "toDay": to_day, + "tou_periods": tou_periods, + } + + +class SeasonYearCrossTests(TestCase): + """A season spanning Oct -> Mar (year-cross) must be selected on both + sides of the new year boundary.""" + + def _tariff(self): + winter_periods = { + "ALL": {"periods": [{"toDayOfWeek": 6}]}, + } + summer_periods = { + "ON_PEAK": {"periods": [{"toDayOfWeek": 6}]}, + } + return { + "currency": "AUD", + "energy_charges": { + "Summer": {"rates": {"ON_PEAK": 0.4}}, + "Winter": {"rates": {"ALL": 0.2}}, + }, + "seasons": { + "Summer": _season_geometry(10, 1, 3, 31, summer_periods), + "Winter": _season_geometry(4, 1, 9, 30, winter_periods), + }, + } + + def test_late_december_selects_year_crossing_season(self): + now = datetime(2026, 12, 20, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.season_name, "Summer") + self.assertEqual(result.buy.price, 0.4) + + def test_early_january_still_selects_year_crossing_season(self): + now = datetime(2027, 1, 5, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.season_name, "Summer") + + def test_mid_year_selects_non_crossing_season(self): + now = datetime(2026, 7, 15, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.season_name, "Winter") + self.assertEqual(result.buy.price, 0.2) + + def test_season_boundary_delimits_period_and_upcoming(self): + now = datetime(2026, 3, 31, 23, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now, horizon_hours=2) + + self.assertIsNotNone(result) + boundary = datetime(2026, 4, 1, tzinfo=TZ) + self.assertEqual(result.next_change, boundary) + self.assertIsNotNone(result.upcoming) + self.assertEqual(result.upcoming[0].end, boundary) + self.assertEqual(result.upcoming[1].start, boundary) + self.assertEqual(result.upcoming[1].buy.season_name, "Winter") + self.assertEqual(result.upcoming[1].buy.price, 0.2) + + def test_season_start_delimits_current_period(self): + now = datetime(2026, 4, 1, 1, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + + self.assertIsNotNone(result) + self.assertEqual(result.current_start, datetime(2026, 4, 1, tzinfo=TZ)) + + +class LeapDaySeasonTests(TestCase): + def _tariff(self): + periods = { + "ALL": { + "periods": [{"fromDayOfWeek": 0, "toDayOfWeek": 0, "toHour": 24 * 7}] + } + } + return { + "currency": "AUD", + "energy_charges": {"Leap": {"rates": {"ALL": 0.2}}}, + "seasons": { + "Leap": _season_geometry(2, 29, 3, 31, periods), + }, + } + + def test_non_leap_year_clamps_active_season_start(self): + now = datetime(2026, 3, 1, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + + self.assertIsNotNone(result) + self.assertEqual(result.current_start, datetime(2026, 2, 28, tzinfo=TZ)) + + def test_non_leap_february_28_is_covered(self): + now = datetime(2026, 2, 28, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + + self.assertIsNotNone(result) + self.assertEqual(result.buy.season_name, "Leap") + + def test_adjacent_sell_season_clamps_next_start(self): + tariff = self._tariff() + tariff["seasons"] = { + "ALL": _season_geometry( + 1, + 1, + 12, + 31, + { + "ALL": { + "periods": [ + { + "fromDayOfWeek": 0, + "toDayOfWeek": 0, + "toHour": 24 * 14, + } + ] + } + }, + ) + } + tariff["sell_tariff"] = { + "energy_charges": {"Leap": {"rates": {"ALL": 0.1}}}, + "seasons": { + "Leap": _season_geometry( + 2, + 29, + 3, + 31, + {"ALL": {"periods": [{"toDayOfWeek": 6, "toHour": 24}]}}, + ) + }, + } + now = datetime(2026, 2, 20, 12, 0, tzinfo=TZ) + result = get_tariff_periods(tariff, now) + + self.assertIsNotNone(result) + self.assertEqual(result.next_change, datetime(2026, 2, 28, tzinfo=TZ)) + + +class DayOfWeekWrapTests(TestCase): + """A period spanning Friday -> Monday (a long-weekend rate) must wrap + the day-of-week range correctly, applying in full to each day in + [Fri..Mon] and to no other day.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": { + "ALL": { + "rates": { + "LONG_WEEKEND": 0.15, + "WEEKDAY": 0.30, + } + } + }, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": { + "LONG_WEEKEND": { + "periods": [ + { + "fromDayOfWeek": 4, + "toDayOfWeek": 0, + "fromHour": 0, + "toHour": 24, + } + ] + }, + "WEEKDAY": { + "periods": [ + { + "fromDayOfWeek": 1, + "toDayOfWeek": 3, + "fromHour": 0, + "toHour": 24, + } + ] + }, + }, + } + }, + } + + def test_saturday_is_long_weekend_rate(self): + # 2026-07-18 is a Saturday, inside the Fri(4)->Mon(0) wrap. + now = datetime(2026, 7, 18, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "LONG_WEEKEND") + self.assertEqual(result.buy.price, 0.15) + + def test_monday_is_long_weekend_rate_wrapping_from_friday(self): + # 2026-07-20 is a Monday - the last day of the Fri(4)->Mon(0) wrap. + now = datetime(2026, 7, 20, 8, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "LONG_WEEKEND") + + def test_tuesday_is_weekday_rate(self): + # 2026-07-21 is a Tuesday - outside the Fri->Mon wrap. + now = datetime(2026, 7, 21, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "WEEKDAY") + self.assertEqual(result.buy.price, 0.30) + + +class MidnightCrossingPeriodTests(TestCase): + """A period whose local window crosses midnight without hitting the + ``toHour: 24`` special case (e.g. 22:00 -> 06:00 overnight rate).""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": {"ALL": {"rates": {"OVERNIGHT": 0.18, "DAY": 0.32}}}, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": { + "OVERNIGHT": { + "periods": [{"toDayOfWeek": 6, "fromHour": 22, "toHour": 6}] + }, + "DAY": { + "periods": [ + { + "toDayOfWeek": 6, + "fromHour": 6, + "toHour": 22, + } + ] + }, + }, + } + }, + } + + def test_just_after_midnight_is_still_overnight(self): + now = datetime(2026, 7, 20, 1, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "OVERNIGHT") + self.assertEqual(result.buy.price, 0.18) + + def test_just_before_midnight_is_overnight(self): + now = datetime(2026, 7, 20, 23, 30, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "OVERNIGHT") + + def test_daytime_is_day_rate(self): + now = datetime(2026, 7, 20, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "DAY") + + def test_next_change_crosses_midnight_correctly(self): + now = datetime(2026, 7, 20, 23, 30, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + # OVERNIGHT ends at 06:00 the next calendar day. + expected = datetime(2026, 7, 21, 6, 0, tzinfo=TZ) + self.assertEqual(result.next_change, expected) + + +class SparsePeriodTests(TestCase): + """A tariff whose single TOU period doesn't cover the whole day (a gap + outside 06:00-09:00) must end the current period at its own actual + end, not carry the rate forward to whatever period starts next + elsewhere in the grid.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": {"ALL": {"rates": {"MORNING": 0.20}}}, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": { + "MORNING": { + "periods": [{"toDayOfWeek": 6, "fromHour": 6, "toHour": 9}] + }, + }, + } + }, + } + + def test_next_change_is_the_periods_own_end_not_the_next_days_start(self): + now = datetime(2026, 7, 20, 7, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "MORNING") + self.assertEqual(result.current_start, datetime(2026, 7, 20, 6, 0, tzinfo=TZ)) + self.assertEqual(result.next_change, datetime(2026, 7, 20, 9, 0, tzinfo=TZ)) + + def test_outside_the_period_no_rate_resolves(self): + # A moment in the gap (09:00-06:00 the next day) is not covered by + # any period, so the whole tariff resolves to nothing rather than + # the stale MORNING rate. + now = datetime(2026, 7, 20, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNone(result) + + def test_upcoming_advances_through_a_gap_to_the_next_days_period(self): + # A 48-hour horizon must not stop at the first gap - it should + # surface an explicit unresolved gap segment and then continue on + # to tomorrow's (and the day after's) MORNING period. + now = datetime(2026, 7, 20, 7, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now, horizon_hours=48) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + starts_and_names = [(p.start, p.buy.period_name) for p in result.upcoming] + self.assertEqual( + starts_and_names, + [ + (datetime(2026, 7, 20, 6, 0, tzinfo=TZ), "MORNING"), + (datetime(2026, 7, 20, 9, 0, tzinfo=TZ), None), + (datetime(2026, 7, 21, 6, 0, tzinfo=TZ), "MORNING"), + (datetime(2026, 7, 21, 9, 0, tzinfo=TZ), None), + (datetime(2026, 7, 22, 6, 0, tzinfo=TZ), "MORNING"), + ], + ) + gap_period = result.upcoming[1] + self.assertIsNone(gap_period.buy.price) + self.assertIsNone(gap_period.sell.price) + self.assertEqual(gap_period.end, datetime(2026, 7, 21, 6, 0, tzinfo=TZ)) + self.assertGreaterEqual(result.upcoming[-1].end, now + timedelta(hours=48)) + + def test_upcoming_covers_a_multi_day_gap_weekend_only_period(self): + # A period active only on weekends leaves a 5-day gap (Mon-Fri) - + # the walk must skip straight across it to the *following* + # weekend, not just the next calendar day. + tariff = { + "currency": "AUD", + "energy_charges": {"ALL": {"rates": {"WEEKEND": 0.1}}}, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": { + "WEEKEND": { + "periods": [{"fromDayOfWeek": 5, "toDayOfWeek": 6}] + }, + }, + } + }, + } + # 2026-07-25 is a Saturday - inside the WEEKEND period. + now = datetime(2026, 7, 25, 10, 0, tzinfo=TZ) + result = get_tariff_periods(tariff, now, horizon_hours=24 * 10) + + self.assertIsNotNone(result) + self.assertEqual(result.buy.price, 0.1) + self.assertIsNotNone(result.upcoming) + weekend_starts = [ + p.start for p in result.upcoming if p.buy.period_name == "WEEKEND" + ] + self.assertEqual( + weekend_starts, + [ + datetime(2026, 7, 25, tzinfo=TZ), + datetime(2026, 7, 26, tzinfo=TZ), + datetime(2026, 8, 1, tzinfo=TZ), + datetime(2026, 8, 2, tzinfo=TZ), + ], + ) + gap_segment = next( + p for p in result.upcoming if p.start == datetime(2026, 7, 27, tzinfo=TZ) + ) + self.assertIsNone(gap_segment.buy.price) + self.assertEqual(gap_segment.end, datetime(2026, 8, 1, tzinfo=TZ)) + + def test_season_transition_precedes_next_weekly_window(self): + tariff = { + "currency": "AUD", + "energy_charges": { + "Early": {"rates": {"WEEKEND": 0.1}}, + "Late": {"rates": {"ALL": 0.2}}, + }, + "seasons": { + "Early": _season_geometry( + 7, + 1, + 7, + 29, + {"WEEKEND": {"periods": [{"fromDayOfWeek": 5, "toDayOfWeek": 6}]}}, + ), + "Late": _season_geometry( + 7, 30, 8, 31, {"ALL": {"periods": [{"toDayOfWeek": 6}]}} + ), + }, + } + now = datetime(2026, 7, 26, 12, 0, tzinfo=TZ) + result = get_tariff_periods(tariff, now, horizon_hours=24 * 7) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + late_season = next( + period for period in result.upcoming if period.buy.season_name == "Late" + ) + self.assertEqual(late_season.start, datetime(2026, 7, 30, tzinfo=TZ)) + self.assertEqual(late_season.buy.price, 0.2) + gap = next( + period + for period in result.upcoming + if period.start == datetime(2026, 7, 27, tzinfo=TZ) + ) + self.assertEqual(gap.end, late_season.start) + + +class UncoveredSeasonGapTests(TestCase): + """A tariff whose only season covers part of the year (a real gap the + rest of the year, e.g. a Summer-only plan) must let `upcoming` skip + across the uncovered months to the season's next start, or clip + cleanly to the horizon deadline when the season never resumes within + it - never silently stop early.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": {"Summer": {"rates": {"ALL": 0.4}}}, + "seasons": { + "Summer": _season_geometry( + 6, 1, 7, 31, {"ALL": {"periods": [{"toDayOfWeek": 6}]}} + ), + }, + } + + def test_gap_clips_to_deadline_when_season_never_resumes_in_horizon(self): + now = datetime(2026, 7, 29, 12, 0, tzinfo=TZ) + deadline = now + timedelta(hours=24 * 20) + result = get_tariff_periods(self._tariff(), now, horizon_hours=24 * 20) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + self.assertEqual(result.upcoming[-1].end, deadline) + self.assertIsNone(result.upcoming[-1].buy.price) + + def test_gap_reaches_next_seasons_start_within_a_longer_horizon(self): + now = datetime(2026, 7, 29, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now, horizon_hours=24 * 365) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + next_summer = next( + p + for p in result.upcoming + if p.buy.season_name == "Summer" and p.start.year == 2027 + ) + self.assertEqual(next_summer.start, datetime(2027, 6, 1, tzinfo=TZ)) + self.assertEqual(next_summer.buy.price, 0.4) + + +class ChainedGapTests(TestCase): + """A gap can require more than one jump to resolve: the next season's + own start can itself land inside another gap (its grid doesn't cover + midnight either). The walk must chain through every jump and merge + them into one contiguous unresolved segment, not stop at the first + jump's landing point.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": {"Summer": {"rates": {"MORNING": 0.4}}}, + "seasons": { + "Summer": _season_geometry( + 6, + 1, + 7, + 31, + { + "MORNING": { + "periods": [{"toDayOfWeek": 6, "fromHour": 6, "toHour": 9}] + } + }, + ), + }, + } + + def test_season_boundary_landing_in_another_gap_is_chained_through(self): + # Summer only exists Jun-Jul, and even within Summer the only + # period is 06:00-09:00 daily - so jumping to next Summer's start + # (Jun 1 00:00) still isn't resolvable; the walk must chain to the + # season's own first 06:00 window. + now = datetime(2026, 7, 31, 7, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now, horizon_hours=24 * 310) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + merged_gap = next( + p + for p in result.upcoming + if p.start == datetime(2026, 7, 31, 9, 0, tzinfo=TZ) + ) + self.assertEqual(merged_gap.end, datetime(2027, 6, 1, 6, 0, tzinfo=TZ)) + self.assertIsNone(merged_gap.buy.price) + next_morning = next( + p + for p in result.upcoming + if p.start == datetime(2027, 6, 1, 6, 0, tzinfo=TZ) + ) + self.assertEqual(next_morning.buy.period_name, "MORNING") + self.assertEqual(next_morning.buy.price, 0.4) + + +class InactiveSellPeriodTests(TestCase): + def _tariff(self, sell_season=None): + all_day = { + "ALL": { + "periods": [{"fromDayOfWeek": 0, "toDayOfWeek": 0, "toHour": 24 * 7}] + } + } + morning = { + "MORNING": {"periods": [{"toDayOfWeek": 6, "fromHour": 6, "toHour": 9}]} + } + season = { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + } + return { + "currency": "AUD", + "energy_charges": {"ALL": {"rates": {"ALL": 0.25}}}, + "seasons": {"ALL": {**season, "tou_periods": all_day}}, + "sell_tariff": { + "energy_charges": {"ALL": {"rates": {"MORNING": 0.1}}}, + "seasons": {"ALL": {**(sell_season or season), "tou_periods": morning}}, + }, + } + + def test_inactive_sell_rate_uses_adjacent_sell_boundaries(self): + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + + self.assertIsNotNone(result) + self.assertIsNone(result.sell.price) + self.assertEqual(result.current_start, datetime(2026, 7, 20, 9, 0, tzinfo=TZ)) + self.assertEqual(result.next_change, datetime(2026, 7, 21, 6, 0, tzinfo=TZ)) + + def test_upcoming_enters_next_sell_window(self): + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now, horizon_hours=24) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + self.assertEqual(result.upcoming[0].end, datetime(2026, 7, 21, 6, 0, tzinfo=TZ)) + self.assertEqual(result.upcoming[1].sell.price, 0.1) + + def test_uncovered_sell_season_uses_next_season_start(self): + sell_season = { + "fromMonth": 7, + "fromDay": 25, + "toMonth": 7, + "toDay": 31, + } + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(sell_season), now) + + self.assertIsNotNone(result) + self.assertIsNone(result.sell.price) + self.assertEqual(result.next_change, datetime(2026, 7, 25, tzinfo=TZ)) + + def test_uncovered_sell_season_uses_previous_season_end(self): + sell_season = { + "fromMonth": 7, + "fromDay": 25, + "toMonth": 7, + "toDay": 31, + } + now = datetime(2026, 8, 2, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(sell_season), now) + + self.assertIsNotNone(result) + self.assertIsNone(result.sell.price) + self.assertEqual(result.current_start, datetime(2026, 8, 1, tzinfo=TZ)) + + +class LongHorizonTests(TestCase): + def test_upcoming_is_not_silently_truncated(self): + now = datetime(2026, 7, 20, 0, 0, tzinfo=TZ) + result = get_tariff_periods(_fixture_tariff(), now, horizon_hours=24 * 210) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + self.assertGreater(len(result.upcoming), 10_000) + self.assertGreaterEqual(result.upcoming[-1].end, now + timedelta(days=210)) + + +class CurrentStartAfterSellBoundaryTests(TestCase): + """When buy and sell schedules differ and both are currently active, + ``current_start`` must be the LATER of the two period starts - the + returned (buy, sell) pair is only valid from whichever side began + most recently, not from whenever the buy period alone started.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": {"ALL": {"rates": {"ALL": 0.30}}}, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": {"ALL": {"periods": [{"toDayOfWeek": 6}]}}, + } + }, + "sell_tariff": { + "energy_charges": {"ALL": {"rates": {"DAY": 0.05}}}, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": { + "DAY": { + "periods": [ + {"toDayOfWeek": 6, "fromHour": 9, "toHour": 17} + ] + } + }, + } + }, + }, + } + + def test_current_start_is_the_later_of_buy_and_sell_starts(self): + # Buy has been active since 00:00; sell's DAY window only started + # at 09:00 - the combined pair is only valid from 09:00 on. + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + + self.assertIsNotNone(result) + self.assertEqual(result.current_start, datetime(2026, 7, 20, 9, 0, tzinfo=TZ)) + + +class DstFallBackWallClockTests(TestCase): + """Walking `upcoming` across a DST fall-back night must keep each + day's local midnight boundary and weekday-based rate correct - a bug + that added elapsed wall-clock minutes as if they were UTC-elapsed time + would drift the local day label across the fold.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": {"ALL": {"rates": {"WD": 0.30, "WE": 0.15}}}, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": { + "WD": {"periods": [{"fromDayOfWeek": 0, "toDayOfWeek": 4}]}, + "WE": {"periods": [{"fromDayOfWeek": 5, "toDayOfWeek": 6}]}, + }, + } + }, + } + + def test_weekday_boundaries_stay_correct_across_fall_back(self): + # Sydney's 2026 DST fall-back is 2026-04-05 (Sunday) 03:00 -> 02:00. + sydney = ZoneInfo("Australia/Sydney") + now = datetime(2026, 4, 3, 12, 0, tzinfo=sydney) # Friday + result = get_tariff_periods(self._tariff(), now, horizon_hours=24 * 5) + + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + expected = [ + (datetime(2026, 4, 3, tzinfo=sydney), 4, "WD"), + (datetime(2026, 4, 4, tzinfo=sydney), 5, "WE"), + (datetime(2026, 4, 5, tzinfo=sydney), 6, "WE"), + (datetime(2026, 4, 6, tzinfo=sydney), 0, "WD"), + (datetime(2026, 4, 7, tzinfo=sydney), 1, "WD"), + ] + for period, (start, weekday, period_name) in zip( + result.upcoming, expected, strict=False + ): + self.assertEqual(period.start, start) + self.assertEqual(period.start.weekday(), weekday) + self.assertEqual(period.buy.period_name, period_name) + self.assertEqual(period.end, start + timedelta(days=1)) + + +class SeasonBoundaryUpcomingWalkTests(TestCase): + """`upcoming` must re-resolve the season (and thus the price) for each + future segment, including across more than one season transition + within the horizon, rather than reusing the season resolved for + `now`.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": { + "Spring": {"rates": {"ALL": 0.10}}, + "Summer": {"rates": {"ALL": 0.40}}, + "Autumn": {"rates": {"ALL": 0.20}}, + }, + "seasons": { + "Spring": _season_geometry( + 3, 1, 5, 31, {"ALL": {"periods": [{"toDayOfWeek": 6}]}} + ), + "Summer": _season_geometry( + 6, 1, 7, 31, {"ALL": {"periods": [{"toDayOfWeek": 6}]}} + ), + "Autumn": _season_geometry( + 8, 1, 8, 31, {"ALL": {"periods": [{"toDayOfWeek": 6}]}} + ), + }, + } + + def test_horizon_spanning_two_season_transitions_prices_each_segment(self): + # 3 days before Summer starts, horizon reaches 3 days into Autumn - + # crossing both the Spring->Summer and Summer->Autumn boundaries. + now = datetime(2026, 5, 29, 12, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now, horizon_hours=24 * 65) + self.assertIsNotNone(result) + self.assertIsNotNone(result.upcoming) + + seasons_seen = [period.buy.season_name for period in result.upcoming] + self.assertIn("Spring", seasons_seen) + self.assertIn("Summer", seasons_seen) + self.assertIn("Autumn", seasons_seen) + + # Each daily segment resolves its own season/price - a segment + # anywhere inside Summer must never carry Spring's or Autumn's price. + for period in result.upcoming: + if period.buy.season_name == "Summer": + self.assertEqual(period.buy.price, 0.40) + elif period.buy.season_name == "Spring": + self.assertEqual(period.buy.price, 0.10) + elif period.buy.season_name == "Autumn": + self.assertEqual(period.buy.price, 0.20) + + # The segment straddling the Spring->Summer boundary switches at + # exactly that boundary, not one grid-tick later or earlier. + last_spring = next( + p for p in reversed(result.upcoming) if p.buy.season_name == "Spring" + ) + first_summer = next(p for p in result.upcoming if p.buy.season_name == "Summer") + self.assertEqual(last_spring.end, datetime(2026, 6, 1, tzinfo=TZ)) + self.assertEqual(first_summer.start, datetime(2026, 6, 1, tzinfo=TZ)) + + # Likewise for the Summer->Autumn boundary later in the same horizon. + last_summer = next( + p for p in reversed(result.upcoming) if p.buy.season_name == "Summer" + ) + first_autumn = next(p for p in result.upcoming if p.buy.season_name == "Autumn") + self.assertEqual(last_summer.end, datetime(2026, 8, 1, tzinfo=TZ)) + self.assertEqual(first_autumn.start, datetime(2026, 8, 1, tzinfo=TZ)) + + +class DstBoundaryTests(TestCase): + """A period boundary that falls on the day of a DST transition must + keep the intended local wall-clock label (e.g. "changes at 02:30" + stays 02:30), built directly in the site timezone rather than via + arithmetic on a UTC instant - the latter would land an hour off.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": {"ALL": {"rates": {"NIGHT": 0.2, "DAY": 0.3}}}, + "seasons": { + "ALL": { + "fromMonth": 1, + "fromDay": 1, + "toMonth": 12, + "toDay": 31, + "tou_periods": { + "NIGHT": { + "periods": [{"toDayOfWeek": 6, "fromHour": 0, "toHour": 2}] + }, + "DAY": { + "periods": [{"toDayOfWeek": 6, "fromHour": 2, "toHour": 24}] + }, + }, + } + }, + } + + def test_boundary_keeps_local_wall_clock_across_spring_forward(self): + # Sydney's 2026 spring-forward is 2026-10-04 02:00 -> 03:00 local. + sydney = ZoneInfo("Australia/Sydney") + now = datetime(2026, 10, 4, 1, 0, tzinfo=sydney) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.period_name, "NIGHT") + # The NIGHT->DAY boundary is defined at local 02:00, still 02:00 + # even though this calendar day skips straight to 03:00. + expected = datetime(2026, 10, 4, 2, 0, tzinfo=sydney) + self.assertEqual(result.next_change.replace(tzinfo=sydney), expected) + self.assertEqual(result.next_change.hour, 2) + + +class EmptySeasonRatesFallbackTests(TestCase): + """When the matched season key exists in `energy_charges` but carries + no `rates` block (e.g. an unused `"Winter": {}` alongside an `"ALL"` + default), the price lookup must fall back to `charges["ALL"]` rather + than resolving to `None`.""" + + def _tariff(self): + return { + "currency": "AUD", + "energy_charges": { + "ALL": {"rates": {"ALL": 0.25}}, + "Winter": {}, + }, + "seasons": { + "Winter": _season_geometry( + 1, 1, 12, 31, {"ALL": {"periods": [{"toDayOfWeek": 6}]}} + ), + }, + } + + def test_empty_season_rates_falls_back_to_all(self): + now = datetime(2026, 7, 20, 10, 0, tzinfo=TZ) + result = get_tariff_periods(self._tariff(), now) + self.assertIsNotNone(result) + self.assertEqual(result.buy.season_name, "Winter") + self.assertEqual(result.buy.price, 0.25)