From 79db3d9550e558607340d16dceae5b57af9efaa8 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:15:38 +1000 Subject: [PATCH 01/14] feat(energysite): add pure Tariff V2 rate resolver Add get_tariff_periods, a pure offline resolver over the raw tariff_content_v2 object, returning the current buy/sell rate plus the next boundary (including buy/sell season start/end boundaries, not just weekly period-grid starts). Ports the Home Assistant teslemetry integration's season/day-of-week/midnight-cross matching logic without its file - the reference crashes on real data's toHour: 24 end-of-day periods because it constructs datetime.replace(hour=24); this resolver represents instants as minute-of-week instead, which sidesteps that crash entirely, and treats a 0.0 sell price as a real value rather than a missing one. Includes a live-shaped 48-half-hour-period fixture plus tests for season year-crossing and boundary delimiting, day-of-week wrap, midnight-crossing periods, the toHour: 24 regression, absent sell_tariff, missing rates, and naive-now rejection. Documents the helper's usage in docs/fleet_api_energy_sites.md. --- AGENTS.md | 4 + docs/fleet_api_energy_sites.md | 29 +- tesla_fleet_api/__init__.py | 12 + tesla_fleet_api/tariff.py | 425 ++++++++++ tests/fixtures/tariff_v2_sample.json | 1124 ++++++++++++++++++++++++++ tests/test_tariff.py | 346 ++++++++ 6 files changed, 1939 insertions(+), 1 deletion(-) create mode 100644 tesla_fleet_api/tariff.py create mode 100644 tests/fixtures/tariff_v2_sample.json create mode 100644 tests/test_tariff.py diff --git a/AGENTS.md b/AGENTS.md index f55e5fc..b425d2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,10 @@ Scope flags on `TeslaFleetApi.__init__` control which submodules are instantiate `util.py` holds small, dependency-free helpers shared across the library, re-exported from the top-level package. `firmware_compare(a, b) -> int` compares dotted, numeric, week-based Tesla firmware version strings (e.g. `2025.14.3`) correctly — plain string comparison misorders them (`"2025.10" < "2025.9"`). It returns 1/-1/0, right-pads shorter versions with zeros before comparing, and treats unparseable strings (e.g. `"Unknown"`) as sorting behind any parseable version. `firmware_at_least(firmware, minimum) -> bool` is a thin wrapper (`firmware_compare(firmware, minimum) >= 0`) for the common "does this vehicle's firmware support feature X" gate — ported from Home Assistant core PR #175745, which fixed the same lexicographic bug in the `teslemetry` integration. Deliberately implemented as native tuple comparison rather than taking on an `AwesomeVersion` dependency, matching this library's narrow, purpose-built dependency list (no general-purpose version-parsing lib elsewhere). +### Tariff V2 Resolver + +`tariff.py` (`get_tariff_periods`, re-exported from the top-level package like `util.py`) is a pure, offline resolver over the raw `tariff_content_v2` object (`response.tariff_content_v2`, or nested under `tou_settings` on the write path - see `EnergySite.time_of_use_settings`/`site_info` in `tesla/energysite.py`). There is no other typed model for this shape in the library; the vehicle protobuf `set_rate_tariff`/`get_rate_tariff` (`vehicle/commands.py`) is a different, unrelated structure. `unwrap_tariff_v2(response)` handles the envelope variants and raises `InvalidResponse` on a malformed/null body, mirroring `_authorized_clients_list` in `teslemetry/energysite.py`. The tariff object carries no timezone - callers must pass a tz-aware `now` already in the site's local zone (`site_info`'s raw `installation_time_zone`, not currently modeled). Internally the resolver represents instants as minute-of-week (`weekday*1440 + hour*60 + minute`) rather than constructing `datetime.replace(hour=...)`, which is what lets it handle real tariffs' `toHour: 24`/`toMinute: 60` (next-midnight) end times without the crash the Home Assistant `teslemetry` integration's `calendar.py` reference hits on the same data. Price lookups use key-presence checks, never truthiness, since a `0.0` sell price is a legitimate value, not a missing one. Its user-facing contract and example are documented in `docs/fleet_api_energy_sites.md`. `tests/test_tariff.py` and `tests/fixtures/tariff_v2_sample.json` (a live-shaped 48-half-hour-period capture) cover the edge cases: season year-crossing and boundary delimiting, day-of-week wrap, midnight-crossing periods, the `toHour: 24` regression, absent `sell_tariff`, missing rates, and naive-`now` rejection. + ### Release Process No release-please or version-bump automation. To ship: bump `version` in `pyproject.toml` and `__version__` in `tesla_fleet_api/__init__.py` in a `Bump version to X.Y.Z` commit on `main`, then push a matching `vX.Y.Z` tag. `.github/workflows/python-publish.yml` runs on every push but only builds+publishes to PyPI (and creates a GitHub Release) when `github.ref` starts with `refs/tags/` — pushing the tag is what actually ships the release; merging to `main` alone does not. diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index ace360a..51596ee 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,33 @@ 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) upcoming periods. It returns `None` when no tariff season covers +`now`. Missing rates remain `None`, while a real zero price remains `0.0`. +Malformed or null response envelopes passed to `unwrap_tariff_v2` raise +`InvalidResponse`. + ## 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/__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..2259f22 --- /dev/null +++ b/tesla_fleet_api/tariff.py @@ -0,0 +1,425 @@ +"""Pure, offline resolver for the Tesla Energy Tariff V2 (time-of-use) object. + +Consumes the raw ``tariff_content_v2`` object as returned by +``EnergySite.site_info()`` (nested under ``tou_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 buy/sell rate pair in effect 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 + + +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 or + any shape that doesn't carry a ``tariff_content_v2`` dict - a malformed + body 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) + + if "tariff_content_v2" in body: + tariff = body["tariff_content_v2"] + if isinstance(tariff, dict): + return cast("dict[str, Any]", tariff) + raise InvalidResponse(str(body)) + + for envelope_key in ("response", "tou_settings"): + if envelope_key in body: + inner = body[envelope_key] + if isinstance(inner, dict) and isinstance( + cast("dict[str, Any]", inner).get("tariff_content_v2"), dict + ): + return cast( + "dict[str, Any]", cast("dict[str, Any]", inner)["tariff_content_v2"] + ) + raise InvalidResponse(str(body)) + + # No recognized envelope key present - treat the input as the bare + # `tariff_content_v2` object itself. + return 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 with every + buy/sell period between ``now`` and ``now + horizon_hours``. + """ + 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 + # Bounded defensively against a pathological tariff whose grid never + # advances; real tariffs resolve a new boundary on every iteration. + for _ in range(10_000): + upcoming.append( + TariffPeriod( + start=cursor.current_start, + end=cursor.next_change, + buy=cursor.buy, + sell=cursor.sell, + ) + ) + if cursor.next_change >= deadline: + break + next_resolved = _resolve_at(tariff, cursor.next_change) + if next_resolved is None: + break + 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_match[0] + 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_windows: list[tuple[str, int, int]] = [] + sell_season_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, + ) + + starts = [start for _, start, _ in buy_windows] + [ + start for _, start, _ in sell_windows + ] + since_delta, until_delta = _bracket(now_mow, starts) + + 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) + + 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 = (_as_int(from_month, 0), _as_int(from_day, 0)) + end = (_as_int(to_month, 0), _as_int(to_day, 0)) + point = (today.month, today.day) + if start <= end: + return start <= point <= end + # Year-crossing season (e.g. October -> March). + return point >= start or point <= 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 = date(start_year, start_month, start_day) + end = date(end_year, end_month, end_day) + timedelta(days=1) + return start, end + + +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 _bracket(now_mow: int, starts: list[int]) -> tuple[int, int]: + """Return (minutes since the most recent boundary, minutes to the next + strictly-future boundary) across every period-start in ``starts``.""" + mods = sorted({s % _MINUTES_PER_WEEK for s in starts}) + if not mods: + return 0, _MINUTES_PER_WEEK + + since = _MINUTES_PER_WEEK + until = _MINUTES_PER_WEEK + for boundary in mods: + d_since = now_mow - boundary + if d_since < 0: + d_since += _MINUTES_PER_WEEK + since = min(since, d_since) + + d_until = boundary - now_mow + if d_until <= 0: + d_until += _MINUTES_PER_WEEK + until = min(until, d_until) + 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..7635a52 --- /dev/null +++ b/tests/test_tariff.py @@ -0,0 +1,346 @@ +"""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}}) + + +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 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) From 3f78bbaff6e8154f0047c5c8932fe250eea5a89f Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:26:06 +1000 Subject: [PATCH 02/14] fix(energysite): harden tariff resolver edge cases Tighten unwrap_tariff_v2's bare-object fallback to require the minimal Tariff V2 shape (seasons + energy_charges) before accepting it, raising InvalidResponse otherwise - an empty or unrelated dict is malformed data, not "no tariff configured". Bound current_start/next_change to the matched buy/sell window's own start/end instead of the next period-start anywhere in the grid, so a sparse tariff (gaps between periods) transitions to no active period at the correct time instead of carrying the old rate across the gap. Added tests confirming season re-resolution across multiple season transitions within one horizon walk, DST-safe wall-clock boundary construction across a spring-forward transition, and the existing season-key-present-but-no-rates ALL fallback - all three already resolved correctly and now have regression coverage. --- tesla_fleet_api/tariff.py | 60 ++++++------ tests/test_tariff.py | 188 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 28 deletions(-) diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index 2259f22..6ca449b 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -90,8 +90,13 @@ def unwrap_tariff_v2(response: Any) -> dict[str, Any]: raise InvalidResponse(str(body)) # No recognized envelope key present - treat the input as the bare - # `tariff_content_v2` object itself. - return body + # `tariff_content_v2` object itself, but only if it carries the + # minimal Tariff V2 shape (`seasons` + `energy_charges`). Anything + # else (an empty dict, an unrelated payload) is malformed, not "no + # tariff configured". + if "seasons" in body and "energy_charges" in body: + return body + raise InvalidResponse(str(body)) def get_tariff_periods( @@ -172,7 +177,7 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None buy_match = _match_window(buy_windows, now_mow) if buy_match is None: return None - buy_period_name = buy_match[0] + 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 @@ -182,7 +187,7 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None ) sell_rate = TariffRate(price=None, period_name=None, season_name=None) - sell_windows: list[tuple[str, int, int]] = [] + sell_match: tuple[str, int, int] | None = None sell_season_dates: tuple[date, date] | None = None sell_tariff = tariff.get("sell_tariff") if isinstance(sell_tariff, dict): @@ -204,10 +209,15 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None season_name=sell_season_name, ) - starts = [start for _, start, _ in buy_windows] + [ - start for _, start, _ in sell_windows - ] - since_delta, until_delta = _bracket(now_mow, starts) + # 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) moment_floor = moment.replace(second=0, microsecond=0) current_start = moment_floor - timedelta(minutes=since_delta) @@ -375,26 +385,20 @@ def _match_window( return None -def _bracket(now_mow: int, starts: list[int]) -> tuple[int, int]: - """Return (minutes since the most recent boundary, minutes to the next - strictly-future boundary) across every period-start in ``starts``.""" - mods = sorted({s % _MINUTES_PER_WEEK for s in starts}) - if not mods: - return 0, _MINUTES_PER_WEEK - - since = _MINUTES_PER_WEEK - until = _MINUTES_PER_WEEK - for boundary in mods: - d_since = now_mow - boundary - if d_since < 0: - d_since += _MINUTES_PER_WEEK - since = min(since, d_since) - - d_until = boundary - now_mow - if d_until <= 0: - d_until += _MINUTES_PER_WEEK - until = min(until, d_until) - return since, until +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 _lookup_price(charges: Any, season_name: str, period_name: str) -> float | None: diff --git a/tests/test_tariff.py b/tests/test_tariff.py index 7635a52..ef67c68 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -56,6 +56,14 @@ 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}) + class FixtureTests(TestCase): """Cover the live-shaped fixture's own anomalies.""" @@ -344,3 +352,183 @@ def test_next_change_crosses_midnight_correctly(self): # 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) + + +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) From 6998443a3214e5fcd3f903356ed959d1c22ff5c7 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:30:46 +1000 Subject: [PATCH 03/14] no-mistakes(review): Fix inactive sell boundaries and horizon truncation --- tesla_fleet_api/tariff.py | 28 +++++++++++++++-- tests/test_tariff.py | 64 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index 6ca449b..6e4e9af 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -129,9 +129,7 @@ def get_tariff_periods( upcoming = [] deadline = now + timedelta(hours=horizon_hours) cursor = resolved - # Bounded defensively against a pathological tariff whose grid never - # advances; real tariffs resolve a new boundary on every iteration. - for _ in range(10_000): + while True: upcoming.append( TariffPeriod( start=cursor.current_start, @@ -145,6 +143,8 @@ def get_tariff_periods( next_resolved = _resolve_at(tariff, cursor.next_change) 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( @@ -188,6 +188,7 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None 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_tariff = tariff.get("sell_tariff") if isinstance(sell_tariff, dict): @@ -218,6 +219,10 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | 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) @@ -401,6 +406,23 @@ def _window_offsets(now_mow: int, start: int, end: int) -> tuple[int, int]: 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. diff --git a/tests/test_tariff.py b/tests/test_tariff.py index ef67c68..0a6593b 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -396,6 +396,70 @@ def test_outside_the_period_no_rate_resolves(self): self.assertIsNone(result) +class InactiveSellPeriodTests(TestCase): + def _tariff(self): + 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": {**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) + + +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 SeasonBoundaryUpcomingWalkTests(TestCase): """`upcoming` must re-resolve the season (and thus the price) for each future segment, including across more than one season transition From f4aa679cb8adbd8c777ee90e2e1774334655da4e Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:32:33 +1000 Subject: [PATCH 04/14] no-mistakes(review): Handle inactive sell season boundaries --- tesla_fleet_api/tariff.py | 42 +++++++++++++++++++++++++++++++++++++++ tests/test_tariff.py | 34 +++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index 6e4e9af..db99528 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -190,6 +190,7 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | 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) @@ -209,6 +210,10 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None 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 @@ -236,6 +241,13 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None ) 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, @@ -321,6 +333,36 @@ def _season_dates(season: Mapping[str, Any], today: date) -> tuple[date, date]: return start, end +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(date(start_year, *start_fields)) + boundaries.add(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 _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(): diff --git a/tests/test_tariff.py b/tests/test_tariff.py index 0a6593b..5c13cda 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -397,7 +397,7 @@ def test_outside_the_period_no_rate_resolves(self): class InactiveSellPeriodTests(TestCase): - def _tariff(self): + def _tariff(self, sell_season=None): all_day = { "ALL": { "periods": [ @@ -422,7 +422,9 @@ def _tariff(self): "seasons": {"ALL": {**season, "tou_periods": all_day}}, "sell_tariff": { "energy_charges": {"ALL": {"rates": {"MORNING": 0.1}}}, - "seasons": {"ALL": {**season, "tou_periods": morning}}, + "seasons": { + "ALL": {**(sell_season or season), "tou_periods": morning} + }, }, } @@ -444,6 +446,34 @@ def test_upcoming_enters_next_sell_window(self): 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): From 73f88809c5573dfd619f19785657f834d59d2106 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:34:27 +1000 Subject: [PATCH 05/14] no-mistakes(review): Clamp recurring leap-day season boundaries --- tesla_fleet_api/tariff.py | 28 +++++++++------ tests/test_tariff.py | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index db99528..755c7b0 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -278,13 +278,8 @@ def _season_covers(season: Mapping[str, Any], today: date) -> bool: 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 = (_as_int(from_month, 0), _as_int(from_day, 0)) - end = (_as_int(to_month, 0), _as_int(to_day, 0)) - point = (today.month, today.day) - if start <= end: - return start <= point <= end - # Year-crossing season (e.g. October -> March). - return point >= start or point <= end + start, end = _season_dates(season, today) + return start <= today < end def _season_windows( @@ -328,11 +323,20 @@ def _season_dates(season: Mapping[str, Any], today: date) -> tuple[date, date]: else: start_year, end_year = today.year - 1, today.year - start = date(start_year, start_month, start_day) - end = date(end_year, end_month, end_day) + timedelta(days=1) + 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 @@ -354,8 +358,10 @@ def _adjacent_season_dates(seasons: Any, today: date) -> tuple[date, date] | Non ) for start_year in range(today.year - 2, today.year + 3): end_year = start_year + (end_fields < start_fields) - boundaries.add(date(start_year, *start_fields)) - boundaries.add(date(end_year, *end_fields) + timedelta(days=1)) + 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: diff --git a/tests/test_tariff.py b/tests/test_tariff.py index 5c13cda..adbe790 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -222,6 +222,77 @@ def test_season_start_delimits_current_period(self): 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 From 6d5926898db0547908a943204ee2973649986835 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:37:08 +1000 Subject: [PATCH 06/14] style(tariff): apply ruff format Cosmetic line-wrap cleanup only, no behavior change. --- tesla_fleet_api/tariff.py | 8 ++------ tests/test_tariff.py | 24 ++++++------------------ 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index 755c7b0..d937df1 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -211,9 +211,7 @@ def _resolve_at(tariff: Mapping[str, Any], moment: datetime) -> _Resolved | None season_name=sell_season_name, ) else: - sell_gap_dates = _adjacent_season_dates( - sell_tariff.get("seasons"), today - ) + 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 @@ -359,9 +357,7 @@ def _adjacent_season_dates(seasons: Any, today: date) -> tuple[date, date] | Non 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) - ) + 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: diff --git a/tests/test_tariff.py b/tests/test_tariff.py index adbe790..bac629a 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -226,9 +226,7 @@ class LeapDaySeasonTests(TestCase): def _tariff(self): periods = { "ALL": { - "periods": [ - {"fromDayOfWeek": 0, "toDayOfWeek": 0, "toHour": 24 * 7} - ] + "periods": [{"fromDayOfWeek": 0, "toDayOfWeek": 0, "toHour": 24 * 7}] } } return { @@ -471,15 +469,11 @@ class InactiveSellPeriodTests(TestCase): def _tariff(self, sell_season=None): all_day = { "ALL": { - "periods": [ - {"fromDayOfWeek": 0, "toDayOfWeek": 0, "toHour": 24 * 7} - ] + "periods": [{"fromDayOfWeek": 0, "toDayOfWeek": 0, "toHour": 24 * 7}] } } morning = { - "MORNING": { - "periods": [{"toDayOfWeek": 6, "fromHour": 6, "toHour": 9}] - } + "MORNING": {"periods": [{"toDayOfWeek": 6, "fromHour": 6, "toHour": 9}]} } season = { "fromMonth": 1, @@ -493,9 +487,7 @@ def _tariff(self, sell_season=None): "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} - }, + "seasons": {"ALL": {**(sell_season or season), "tou_periods": morning}}, }, } @@ -549,16 +541,12 @@ def test_uncovered_sell_season_uses_previous_season_end(self): 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 - ) + 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) - ) + self.assertGreaterEqual(result.upcoming[-1].end, now + timedelta(days=210)) class SeasonBoundaryUpcomingWalkTests(TestCase): From 137c9d8769d804ae6dc23cdf3b205effc8ffe543 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:39:18 +1000 Subject: [PATCH 07/14] test(tariff): lock in current_start-after-sell-boundary and DST fall-back correctness Round-3 Codex re-review (node sibling) flagged three more scenarios: sell gaps affecting next_change, wall-clock day derivation across a DST fall-back, and current_start honoring the later of buy/sell starts. All three already resolve correctly under the existing minute-of-week + wall-clock-timedelta design (verified interactively before writing these tests) - this commit adds the regression coverage rather than a behavior change. --- tests/test_tariff.py | 97 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/test_tariff.py b/tests/test_tariff.py index bac629a..58fa9d0 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -549,6 +549,103 @@ def test_upcoming_is_not_silently_truncated(self): 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 From 02f3d67aa93d68f3fd0d821b817ad2f5ab7f0aba Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Thu, 23 Jul 2026 21:43:53 +1000 Subject: [PATCH 08/14] no-mistakes(document): Correct tariff resolver documentation --- AGENTS.md | 4 ---- tesla_fleet_api/tariff.py | 15 ++++++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b425d2a..f55e5fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,10 +91,6 @@ Scope flags on `TeslaFleetApi.__init__` control which submodules are instantiate `util.py` holds small, dependency-free helpers shared across the library, re-exported from the top-level package. `firmware_compare(a, b) -> int` compares dotted, numeric, week-based Tesla firmware version strings (e.g. `2025.14.3`) correctly — plain string comparison misorders them (`"2025.10" < "2025.9"`). It returns 1/-1/0, right-pads shorter versions with zeros before comparing, and treats unparseable strings (e.g. `"Unknown"`) as sorting behind any parseable version. `firmware_at_least(firmware, minimum) -> bool` is a thin wrapper (`firmware_compare(firmware, minimum) >= 0`) for the common "does this vehicle's firmware support feature X" gate — ported from Home Assistant core PR #175745, which fixed the same lexicographic bug in the `teslemetry` integration. Deliberately implemented as native tuple comparison rather than taking on an `AwesomeVersion` dependency, matching this library's narrow, purpose-built dependency list (no general-purpose version-parsing lib elsewhere). -### Tariff V2 Resolver - -`tariff.py` (`get_tariff_periods`, re-exported from the top-level package like `util.py`) is a pure, offline resolver over the raw `tariff_content_v2` object (`response.tariff_content_v2`, or nested under `tou_settings` on the write path - see `EnergySite.time_of_use_settings`/`site_info` in `tesla/energysite.py`). There is no other typed model for this shape in the library; the vehicle protobuf `set_rate_tariff`/`get_rate_tariff` (`vehicle/commands.py`) is a different, unrelated structure. `unwrap_tariff_v2(response)` handles the envelope variants and raises `InvalidResponse` on a malformed/null body, mirroring `_authorized_clients_list` in `teslemetry/energysite.py`. The tariff object carries no timezone - callers must pass a tz-aware `now` already in the site's local zone (`site_info`'s raw `installation_time_zone`, not currently modeled). Internally the resolver represents instants as minute-of-week (`weekday*1440 + hour*60 + minute`) rather than constructing `datetime.replace(hour=...)`, which is what lets it handle real tariffs' `toHour: 24`/`toMinute: 60` (next-midnight) end times without the crash the Home Assistant `teslemetry` integration's `calendar.py` reference hits on the same data. Price lookups use key-presence checks, never truthiness, since a `0.0` sell price is a legitimate value, not a missing one. Its user-facing contract and example are documented in `docs/fleet_api_energy_sites.md`. `tests/test_tariff.py` and `tests/fixtures/tariff_v2_sample.json` (a live-shaped 48-half-hour-period capture) cover the edge cases: season year-crossing and boundary delimiting, day-of-week wrap, midnight-crossing periods, the `toHour: 24` regression, absent `sell_tariff`, missing rates, and naive-`now` rejection. - ### Release Process No release-please or version-bump automation. To ship: bump `version` in `pyproject.toml` and `__version__` in `tesla_fleet_api/__init__.py` in a `Bump version to X.Y.Z` commit on `main`, then push a matching `vX.Y.Z` tag. `.github/workflows/python-publish.yml` runs on every push but only builds+publishes to PyPI (and creates a GitHub Release) when `github.ref` starts with `refs/tags/` — pushing the tag is what actually ships the release; merging to `main` alone does not. diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index d937df1..d171d2d 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -1,10 +1,10 @@ """Pure, offline resolver for the Tesla Energy Tariff V2 (time-of-use) object. -Consumes the raw ``tariff_content_v2`` object as returned by -``EnergySite.site_info()`` (nested under ``tou_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. +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 @@ -63,8 +63,9 @@ def unwrap_tariff_v2(response: Any) -> dict[str, Any]: (``{"tou_settings": {"tariff_content_v2": {...}}}``), or the bare ``tariff_content_v2`` object itself. Raises :class:`~tesla_fleet_api.exceptions.InvalidResponse` for a null body or - any shape that doesn't carry a ``tariff_content_v2`` dict - a malformed - body is not the same as "no tariff configured". + an unrecognized envelope or a bare object without the minimal Tariff V2 + shape (``seasons`` and ``energy_charges``) - a malformed body is not the + same as "no tariff configured". """ if response is None: raise InvalidResponse("tariff response body was null") From b272f65f6482b013b42cc3eca2dd44d549aabd76 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Fri, 24 Jul 2026 06:51:57 +1000 Subject: [PATCH 09/14] fix(tariff): apply minimal-shape validation to every unwrap_tariff_v2 path The seasons+energy_charges check only ran on the bare-dict fallback - the tariff_content_v2 top-level key, response envelope, and tou_settings envelope paths all returned their extracted dict without it, so an enveloped empty {} silently passed through instead of raising InvalidResponse. Refactored to extract a single candidate across all four paths and validate it once, so the check is now identical everywhere. --- tesla_fleet_api/tariff.py | 54 +++++++++++++++++++-------------------- tests/test_tariff.py | 15 +++++++++++ 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index d171d2d..cff4763 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -62,10 +62,12 @@ def unwrap_tariff_v2(response: Any) -> dict[str, Any]: ``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 or - an unrecognized envelope or a bare object without the minimal Tariff V2 - shape (``seasons`` and ``energy_charges``) - a malformed body is not the - same as "no tariff configured". + :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") @@ -73,30 +75,28 @@ def unwrap_tariff_v2(response: Any) -> dict[str, Any]: raise InvalidResponse(repr(response)) body = cast("dict[str, Any]", response) + candidate: Any if "tariff_content_v2" in body: - tariff = body["tariff_content_v2"] - if isinstance(tariff, dict): - return cast("dict[str, Any]", tariff) - raise InvalidResponse(str(body)) - - for envelope_key in ("response", "tou_settings"): - if envelope_key in body: - inner = body[envelope_key] - if isinstance(inner, dict) and isinstance( - cast("dict[str, Any]", inner).get("tariff_content_v2"), dict - ): - return cast( - "dict[str, Any]", cast("dict[str, Any]", inner)["tariff_content_v2"] - ) - raise InvalidResponse(str(body)) - - # No recognized envelope key present - treat the input as the bare - # `tariff_content_v2` object itself, but only if it carries the - # minimal Tariff V2 shape (`seasons` + `energy_charges`). Anything - # else (an empty dict, an unrelated payload) is malformed, not "no - # tariff configured". - if "seasons" in body and "energy_charges" in body: - return 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)) diff --git a/tests/test_tariff.py b/tests/test_tariff.py index 58fa9d0..072e43a 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -64,6 +64,21 @@ 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.""" From 7166727ceb19e8470f570c173410e46b687f020d Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Fri, 24 Jul 2026 06:55:59 +1000 Subject: [PATCH 10/14] no-mistakes(document): Document tariff validation across every envelope --- docs/fleet_api_energy_sites.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index 51596ee..6909510 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -377,8 +377,9 @@ the tariff object does not carry its own timezone. A naive `now` raises period's start, the next change, the currency, and (when `horizon_hours` is provided) upcoming periods. It returns `None` when no tariff season covers `now`. Missing rates remain `None`, while a real zero price remains `0.0`. -Malformed or null response envelopes passed to `unwrap_tariff_v2` raise -`InvalidResponse`. +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 From 214274edba8f458d92587f1a2663ff522ab2e316 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Fri, 24 Jul 2026 07:11:38 +1000 Subject: [PATCH 11/14] fix(tariff): advance the upcoming walk through gaps instead of stopping When the moment right after the currently-active period fell in a gap (no buy period covers it - e.g. a sparse 06:00-09:00 daily tariff, or a season that only covers part of the year), the horizon walk hit _resolve_at returning None and broke immediately, so upcoming silently omitted every later in-horizon period past the first gap. Added _next_resolvable, which finds the next moment where the buy side resolves again - either the grid's own next window start (reusing the same nearest-boundary search already used for an inactive sell grid) when a season still covers the date, or the next season's start (reusing the existing adjacent-season-boundary search) when no season covers it at all. The walk now records the gap itself as an explicit unresolved TariffPeriod (both sides None) and continues from the gap's end, so upcoming stays a contiguous timeline with no silent hole, and clips cleanly to the horizon deadline if the tariff never resumes within it. Added regression tests: a sparse daily period whose gap spans into the next day, a weekend-only period whose gap spans a full working week, and a season that only covers part of the year (clipped-at-deadline and reaches-next-season-start cases). --- tesla_fleet_api/tariff.py | 59 ++++++++++++++++++- tests/test_tariff.py | 115 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index cff4763..1a6e2a7 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -54,6 +54,9 @@ class TariffResolution: 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. @@ -143,7 +146,35 @@ def get_tariff_periods( break next_resolved = _resolve_at(tariff, cursor.next_change) if next_resolved is None: - break + # `cursor.next_change` falls in a gap no buy period covers - + # advance THROUGH it to the next tariff boundary instead of + # stopping the walk here, recording the gap itself as an + # unresolved segment so `upcoming` stays a contiguous + # timeline with no silent hole. + gap_end = _next_resolvable(tariff, cursor.next_change) + if gap_end is None or gap_end >= deadline: + gap_end = deadline if gap_end is None else min(gap_end, deadline) + if gap_end > cursor.next_change: + upcoming.append( + TariffPeriod( + start=cursor.next_change, + end=gap_end, + buy=_UNRESOLVED_RATE, + sell=_UNRESOLVED_RATE, + ) + ) + break + upcoming.append( + TariffPeriod( + start=cursor.next_change, + end=gap_end, + buy=_UNRESOLVED_RATE, + sell=_UNRESOLVED_RATE, + ) + ) + next_resolved = _resolve_at(tariff, gap_end) + 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 @@ -366,6 +397,32 @@ def _adjacent_season_dates(seasons: Any, today: date) -> tuple[date, date] | Non 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_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) + return moment_floor + timedelta(minutes=until) + 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(): diff --git a/tests/test_tariff.py b/tests/test_tariff.py index 072e43a..1723a33 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -479,6 +479,121 @@ def test_outside_the_period_no_rate_resolves(self): 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)) + + +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 InactiveSellPeriodTests(TestCase): def _tariff(self, sell_season=None): From 1f75a286948b267426fb3517b1707f618cf130ee Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Fri, 24 Jul 2026 07:13:57 +1000 Subject: [PATCH 12/14] fix(tariff): chain through consecutive gaps in the upcoming walk A single jump out of a gap (e.g. to a new season's start) can itself land in another gap - that season's own grid might not cover midnight either. The previous fix only handled one jump per gap and then broke if the landing point was still unresolved. Now the walk keeps calling _next_resolvable until the buy side actually resolves or the horizon runs out, merging every chained jump into one contiguous unresolved segment. Added a regression test where a season only has a 06:00-09:00 daily period: jumping to next season's start (00:00) still isn't resolvable, requiring a second jump to that season's own first window. --- tesla_fleet_api/tariff.py | 48 +++++++++++++++++++----------------- tests/test_tariff.py | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 23 deletions(-) diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index 1a6e2a7..3904be4 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -144,37 +144,39 @@ def get_tariff_periods( ) if cursor.next_change >= deadline: break - next_resolved = _resolve_at(tariff, cursor.next_change) - if next_resolved is None: - # `cursor.next_change` falls in a gap no buy period covers - - # advance THROUGH it to the next tariff boundary instead of - # stopping the walk here, recording the gap itself as an - # unresolved segment so `upcoming` stays a contiguous - # timeline with no silent hole. - gap_end = _next_resolvable(tariff, cursor.next_change) + + # `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: - gap_end = deadline if gap_end is None else min(gap_end, deadline) - if gap_end > cursor.next_change: - upcoming.append( - TariffPeriod( - start=cursor.next_change, - end=gap_end, - buy=_UNRESOLVED_RATE, - sell=_UNRESOLVED_RATE, - ) - ) + 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=cursor.next_change, - end=gap_end, + start=gap_start, + end=probe, buy=_UNRESOLVED_RATE, sell=_UNRESOLVED_RATE, ) ) - next_resolved = _resolve_at(tariff, gap_end) - if next_resolved is None: - break + 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 diff --git a/tests/test_tariff.py b/tests/test_tariff.py index 1723a33..4dd01d0 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -595,6 +595,58 @@ def test_gap_reaches_next_seasons_start_within_a_longer_horizon(self): 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 = { From bf6ad77cc0829992271df2cf8aecf0d1d9cc1554 Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Fri, 24 Jul 2026 07:16:33 +1000 Subject: [PATCH 13/14] no-mistakes(review): Respect season boundaries when advancing tariff gaps --- tesla_fleet_api.egg-info/SOURCES.txt | 4 ++- tesla_fleet_api/tariff.py | 10 +++++-- tests/test_tariff.py | 43 ++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) 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/tariff.py b/tesla_fleet_api/tariff.py index 3904be4..69c0ab3 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -412,12 +412,18 @@ def _next_resolvable(tariff: Mapping[str, Any], moment: datetime) -> datetime | """ today = moment.date() seasons = tariff.get("seasons") - _, buy_windows, _ = _season_windows(seasons, today) + _, 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) - return moment_floor + timedelta(minutes=until) + 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 diff --git a/tests/test_tariff.py b/tests/test_tariff.py index 4dd01d0..d99d0b7 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -551,6 +551,49 @@ def test_upcoming_covers_a_multi_day_gap_weekend_only_period(self): 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 From 9ef55cc94d7d259a45fedd94221fda629f19561c Mon Sep 17 00:00:00 2001 From: firstmate crewmate Date: Fri, 24 Jul 2026 07:19:33 +1000 Subject: [PATCH 14/14] no-mistakes(document): Document tariff gaps and normalize formatting --- docs/fleet_api_energy_sites.md | 7 +++++-- tesla_fleet_api/tariff.py | 6 +++--- tests/test_tariff.py | 10 ++-------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/fleet_api_energy_sites.md b/docs/fleet_api_energy_sites.md index 6909510..b4bf231 100644 --- a/docs/fleet_api_energy_sites.md +++ b/docs/fleet_api_energy_sites.md @@ -375,8 +375,11 @@ resolution = get_tariff_periods(tariff, now, horizon_hours=24) 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) upcoming periods. It returns `None` when no tariff season covers -`now`. Missing rates remain `None`, while a real zero price remains `0.0`. +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`. diff --git a/tesla_fleet_api/tariff.py b/tesla_fleet_api/tariff.py index 69c0ab3..a709b76 100644 --- a/tesla_fleet_api/tariff.py +++ b/tesla_fleet_api/tariff.py @@ -34,7 +34,7 @@ class TariffRate: @dataclass(frozen=True, slots=True) class TariffPeriod: - """One buy/sell rate pair in effect for ``[start, end)``.""" + """One resolved rate pair or unresolved gap for ``[start, end)``.""" start: datetime end: datetime @@ -118,8 +118,8 @@ def get_tariff_periods( 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 with every - buy/sell period between ``now`` and ``now + horizon_hours``. + 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") diff --git a/tests/test_tariff.py b/tests/test_tariff.py index d99d0b7..0e5aebc 100644 --- a/tests/test_tariff.py +++ b/tests/test_tariff.py @@ -564,11 +564,7 @@ def test_season_transition_precedes_next_weekly_window(self): 1, 7, 29, - { - "WEEKEND": { - "periods": [{"fromDayOfWeek": 5, "toDayOfWeek": 6}] - } - }, + {"WEEKEND": {"periods": [{"fromDayOfWeek": 5, "toDayOfWeek": 6}]}}, ), "Late": _season_geometry( 7, 30, 8, 31, {"ALL": {"periods": [{"toDayOfWeek": 6}]}} @@ -581,9 +577,7 @@ def test_season_transition_precedes_next_weekly_window(self): self.assertIsNotNone(result) self.assertIsNotNone(result.upcoming) late_season = next( - period - for period in result.upcoming - if period.buy.season_name == "Late" + 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)