From 8dbab2bd601b1478c7b2e7a8a91edd64ba78c2db Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 23 Jul 2026 20:57:45 +1000 Subject: [PATCH 1/6] Add getTariffPeriods: pure Tariff V2 buy/sell rate resolver Mirrors the python-tesla-fleet-api sibling's shape and edge cases (season year-cross, day-of-week wrap, midnight cross, toHour:24 normalization, ALL fallback, key-presence price lookup) using a JS-appropriate approach: an IANA timeZone string plus Intl.DateTimeFormat instead of a tz-aware Date. Also fixes TariffContentV2.seasons, which was mistyped as an array when the real payload is an object keyed by season name. --- AGENTS.md | 1 + src/index.ts | 2 + src/tariff.ts | 227 +++++++++++++++++++++++++++++ src/types/site_info.ts | 46 ++++-- test/fixtures/moorinya-tariff.ts | 190 +++++++++++++++++++++++++ test/tariff.test.ts | 235 +++++++++++++++++++++++++++++++ 6 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 src/tariff.ts create mode 100644 test/fixtures/moorinya-tariff.ts create mode 100644 test/tariff.test.ts diff --git a/AGENTS.md b/AGENTS.md index 0d23b0d..8439b23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - **`resp.requestUuid` must never gate acceptance of a `SessionInfo` reply - only cross-check it when the vehicle actually populated it.** Real VCSEC hardware leaves it empty (protocol.md: "due to memory constraints, the `request_uuid` field is typically not populated in replies from VCSEC"); an equality-or-reject guard here silently breaks every VCSEC command (`door_lock`, `door_unlock`, `actuate_truck`, `charge_port_door_open/close`, `remote_start_drive`) against real vehicles even though `test/helpers/fakevehicle.ts` (which always echoes it) can't catch the regression. The `session_info_tag` HMAC - which binds the *sent* uuid as `TAG_CHALLENGE` - is the actual authentication; `FakeVehicle.omitRequestUuidLikeRealVcsec()` reproduces the real-hardware shape in tests. - For `DOMAIN_VEHICLE_SECURITY`, `Commands.dispatch` holds the domain's session lock across the *entire* dispatch (handshake + build + send + retry), not just message-build - VCSEC requires messages to arrive in strict counter order and the spec warns against simultaneous requests to it at all, unlike Infotainment (sliding window), which keeps the narrower build-only lock. - `Commands`'s `#privateKey`/`#publicKey` are native private class fields (not `protected`/TS-only `private`) - the raw signing key must not be reachable off the instance at all (e.g. via `JSON.stringify` or a structured log), not merely inaccessible to outside *code*. Tests that need to observe key derivation do so through what actually goes out on the wire (a captured handshake message), not by reading the field. +- `src/tariff.ts`'s `getTariffPeriods(tariff, now, opts)` is a pure Tariff V2 rate resolver mirroring `python-tesla-fleet-api`'s sibling. The tariff object carries no timezone; the caller must pass `opts.timeZone` (an IANA string, e.g. from `site_info.installation_time_zone`) - the resolver has no other way to get site-local wall-clock parts from a JS `Date`, which is always a UTC instant. Internally it matches on a minute-of-week representation built from independent day-of-week-range and time-of-day-range checks (see the module's `dowInRange`/`buildInstances`) - not a single contiguous minute-of-week span - because a `tou_periods` entry's `fromDayOfWeek..toDayOfWeek` means "this daily time window recurs on each of these weekdays", not "one span from this day+time to that day+time". `TariffContentV2` (`src/types/site_info.ts`) types `seasons` as `Record` (an object keyed by season name, not an array) - that mismatch was a real bug fixed alongside the resolver; don't regress it back to an array shape. ## Maintaining this file diff --git a/src/index.ts b/src/index.ts index ffde326..765ac5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,3 +16,5 @@ export { SignedCommandFaultError, RetryableSignedCommandFaultError, } from "./signing/errors.js"; +export { getTariffPeriods } from "./tariff.js"; +export type { TariffRate, TariffPeriod, TariffResolution } from "./tariff.js"; diff --git a/src/tariff.ts b/src/tariff.ts new file mode 100644 index 0000000..7b41f86 --- /dev/null +++ b/src/tariff.ts @@ -0,0 +1,227 @@ +import { EnergyCharges, Season, TariffContentV2, TouPeriods } from "./types/site_info.js"; + +export interface TariffRate { + price: number | null; + periodName: string | null; + seasonName: string | null; +} + +export interface TariffPeriod { + start: Date; + end: Date; + buy: TariffRate; + sell: TariffRate; +} + +export interface TariffResolution { + buy: TariffRate; + sell: TariffRate; + currentStart: Date; + nextChange: Date; + currency: string | null; + upcoming: TariffPeriod[] | null; +} + +const MINUTES_PER_DAY = 1440; +const MINUTES_PER_WEEK = MINUTES_PER_DAY * 7; + +type WallClock = { dow: number; minuteOfDay: number; month: number; day: number }; + +type Instance = { periodName: string; start: number; end: number }; + +function getWallClock(now: Date, timeZone: string): WallClock { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(now); + const get = (type: string): number => Number(parts.find((p) => p.type === type)?.value); + const year = get("year"); + const month = get("month"); + const day = get("day"); + const hour = get("hour"); + const minute = get("minute"); + // Weekday derived from the site-local date (not name matching): Tesla is Mon=0..Sun=6, JS Date.getUTCDay() is Sun=0..Sat=6. + const jsDow = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + const dow = (jsDow + 6) % 7; + return { dow, minuteOfDay: hour * 60 + minute, month, day }; +} + +function dowInRange(d: number, fromDow: number, toDow: number): boolean { + return fromDow <= toDow ? d >= fromDow && d <= toDow : d >= fromDow || d <= toDow; +} + +function seasonContains(season: Season, month: number, day: number): boolean { + if (!season?.tou_periods) return false; + const fromMonth = season.fromMonth ?? 1; + const fromDay = season.fromDay ?? 1; + const toMonth = season.toMonth ?? 12; + const toDay = season.toDay ?? 31; + const from = fromMonth * 100 + fromDay; + const to = toMonth * 100 + toDay; + const cur = month * 100 + day; + return from <= to ? cur >= from && cur <= to : cur >= from || cur <= to; +} + +function findSeason(seasons: Record | undefined, month: number, day: number): { name: string; season: Season } | null { + if (!seasons) return null; + for (const [name, season] of Object.entries(seasons)) { + if (seasonContains(season, month, day)) { + return { name, season }; + } + } + return null; +} + +/** One instance per eligible weekday per week cycle (-1/0/+1), so day-of-week wrap and midnight cross fall out of plain interval containment. */ +function buildInstances(touPeriods: TouPeriods): Instance[] { + const instances: Instance[] = []; + for (const [periodName, def] of Object.entries(touPeriods)) { + for (const window of def.periods ?? []) { + const fromDow = window.fromDayOfWeek ?? 0; + const toDow = window.toDayOfWeek ?? 6; + const fromMin = (window.fromHour ?? 0) * 60 + (window.fromMinute ?? 0); + const toMin = (window.toHour ?? 0) * 60 + (window.toMinute ?? 0); + const duration = toMin > fromMin ? toMin - fromMin : MINUTES_PER_DAY - fromMin + toMin; + if (duration <= 0) continue; + for (let anchorDow = 0; anchorDow < 7; anchorDow++) { + if (!dowInRange(anchorDow, fromDow, toDow)) continue; + for (const weekOffset of [-1, 0, 1]) { + const start = weekOffset * MINUTES_PER_WEEK + anchorDow * MINUTES_PER_DAY + fromMin; + instances.push({ periodName, start, end: start + duration }); + } + } + } + } + return instances; +} + +function findContaining(instances: Instance[], atMinute: number): Instance | null { + let best: Instance | null = null; + for (const instance of instances) { + if (instance.start <= atMinute && atMinute < instance.end) { + if (!best || instance.start > best.start) best = instance; + } + } + return best; +} + +function findNextStart(instances: Instance[], afterMinute: number): number | null { + let best: number | null = null; + for (const instance of instances) { + if (instance.start > afterMinute && (best === null || instance.start < best)) { + best = instance.start; + } + } + return best; +} + +function lookupPrice(charges: EnergyCharges | undefined, seasonName: string, periodName: string): number | null { + if (!charges) return null; + const seasonRates = (Object.prototype.hasOwnProperty.call(charges, seasonName) ? charges[seasonName] : charges["ALL"])?.rates; + if (!seasonRates) return null; + const value = Object.prototype.hasOwnProperty.call(seasonRates, periodName) ? seasonRates[periodName] : seasonRates["ALL"]; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +type GridResolution = { + rate: TariffRate; + seasonName: string; + instances: Instance[]; + charges: EnergyCharges | undefined; + currentStartOffset: number; + nextChangeOffset: number; +}; + +function resolveGrid(seasons: Record | undefined, charges: EnergyCharges | undefined, wallClock: WallClock, nowAbs: number): GridResolution | null { + const matched = findSeason(seasons, wallClock.month, wallClock.day); + if (!matched) return null; + const instances = buildInstances(matched.season.tou_periods!); + if (instances.length === 0) return null; + const current = findContaining(instances, nowAbs); + if (!current) return null; + const nextChangeOffset = findNextStart(instances, nowAbs) ?? current.end; + return { + rate: { + price: lookupPrice(charges, matched.name, current.periodName), + periodName: current.periodName, + seasonName: matched.name, + }, + seasonName: matched.name, + instances, + charges, + currentStartOffset: current.start, + nextChangeOffset, + }; +} + +const EMPTY_RATE: TariffRate = { price: null, periodName: null, seasonName: null }; + +function buildUpcoming(buyGrid: GridResolution, sellGrid: GridResolution | null, nowAbs: number, horizonMinutes: number, toDate: (offset: number) => Date): TariffPeriod[] { + const horizonEnd = nowAbs + horizonMinutes; + const boundaries = new Set([nowAbs, horizonEnd]); + for (const instance of buyGrid.instances) { + if (instance.start > nowAbs && instance.start < horizonEnd) boundaries.add(instance.start); + } + if (sellGrid) { + for (const instance of sellGrid.instances) { + if (instance.start > nowAbs && instance.start < horizonEnd) boundaries.add(instance.start); + } + } + const sorted = [...boundaries].sort((a, b) => a - b); + const periods: TariffPeriod[] = []; + for (let i = 0; i < sorted.length - 1; i++) { + const segStart = sorted[i]; + const segEnd = sorted[i + 1]; + if (segStart === segEnd) continue; + const buyInstance = findContaining(buyGrid.instances, segStart); + const sellInstance = sellGrid ? findContaining(sellGrid.instances, segStart) : null; + periods.push({ + start: toDate(segStart), + end: toDate(segEnd), + buy: buyInstance ? { price: lookupPrice(buyGrid.charges, buyGrid.seasonName, buyInstance.periodName), periodName: buyInstance.periodName, seasonName: buyGrid.seasonName } : EMPTY_RATE, + sell: sellInstance ? { price: lookupPrice(sellGrid!.charges, sellGrid!.seasonName, sellInstance.periodName), periodName: sellInstance.periodName, seasonName: sellGrid!.seasonName } : EMPTY_RATE, + }); + } + return periods; +} + +/** + * Resolves the current buy/sell time-of-use rate for a Tariff V2 object at a given instant. + * `tariff` carries no timezone; the caller must supply the site's IANA zone (e.g. from + * `site_info.installation_time_zone`) so wall-clock period boundaries match correctly. + */ +export function getTariffPeriods(tariff: TariffContentV2, now: Date, opts?: { timeZone: string; horizonHours?: number }): TariffResolution | null { + if (!tariff?.seasons || !tariff?.energy_charges) return null; + if (!opts?.timeZone) throw new Error("getTariffPeriods requires opts.timeZone"); + + const wallClock = getWallClock(now, opts.timeZone); + const nowAbs = wallClock.dow * MINUTES_PER_DAY + wallClock.minuteOfDay; + + const buyGrid = resolveGrid(tariff.seasons, tariff.energy_charges, wallClock, nowAbs); + if (!buyGrid) return null; + const sellGrid = tariff.sell_tariff ? resolveGrid(tariff.sell_tariff.seasons ?? tariff.seasons, tariff.sell_tariff.energy_charges, wallClock, nowAbs) : null; + + const toDate = (offset: number): Date => new Date(now.getTime() + (offset - nowAbs) * 60_000); + + const nextChangeOffset = sellGrid ? Math.min(buyGrid.nextChangeOffset, sellGrid.nextChangeOffset) : buyGrid.nextChangeOffset; + + const resolution: TariffResolution = { + buy: buyGrid.rate, + sell: sellGrid ? sellGrid.rate : EMPTY_RATE, + currentStart: toDate(buyGrid.currentStartOffset), + nextChange: toDate(nextChangeOffset), + currency: typeof tariff.currency === "string" ? tariff.currency : null, + upcoming: null, + }; + + if (opts.horizonHours) { + resolution.upcoming = buildUpcoming(buyGrid, sellGrid, nowAbs, opts.horizonHours * 60, toDate); + } + + return resolution; +} diff --git a/src/types/site_info.ts b/src/types/site_info.ts index 88f02a8..056a072 100644 --- a/src/types/site_info.ts +++ b/src/types/site_info.ts @@ -110,20 +110,50 @@ export type UserSettings = { off_grid_vehicle_charging_enabled: boolean; }; +export type TariffPeriodWindow = { + fromDayOfWeek?: number; + toDayOfWeek?: number; + fromHour?: number; + toHour?: number; + fromMinute?: number; + toMinute?: number; +}; + +export type TouPeriods = Record; + +export type Season = { + fromDay?: number; + toDay?: number; + fromMonth?: number; + toMonth?: number; + tou_periods?: TouPeriods; +}; + +export type EnergyCharges = Record }>; + +export type SellTariff = { + name?: string; + utility?: string; + daily_charges?: Record[]; + demand_charges?: Record; + energy_charges: EnergyCharges; + seasons: Record; +}; + export type TariffContentV2 = { version: number; - monthly_minimum_bill: number; - min_applicable_demand: number; - max_applicable_demand: number; - monthly_charges: number; + monthly_minimum_bill?: number; + min_applicable_demand?: number; + max_applicable_demand?: number; + monthly_charges?: number; utility: string; code: string; name: string; currency: string; daily_charges: Record[]; - daily_demand_charges: Record; + daily_demand_charges?: Record; demand_charges: Record; - energy_charges: Record; - seasons: Record[]; - sell_tariff: Record; + energy_charges: EnergyCharges; + seasons: Record; + sell_tariff?: SellTariff; }; diff --git a/test/fixtures/moorinya-tariff.ts b/test/fixtures/moorinya-tariff.ts new file mode 100644 index 0000000..2a1af16 --- /dev/null +++ b/test/fixtures/moorinya-tariff.ts @@ -0,0 +1,190 @@ +import { TariffContentV2, TouPeriods } from "../../src/types/site_info.js"; + +/** Every day, half-hour buy/sell periods (e.g. PERIOD_17_30 = 17:30-18:00), reused across seasons. */ +const HALF_HOUR_PERIODS: TouPeriods = { + 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 }] }, +}; + +/** Live Tariff V2 capture: Teslemetry `get_energy_tariff`, site `Moorinya` (site_id 2533979794926773), 2026-07-23. */ +export const MOORINYA_TARIFF: TariffContentV2 = { + 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.3088, + PERIOD_00_30: 0.3073, + PERIOD_01_00: 0.306, + PERIOD_01_30: 0.3048, + PERIOD_02_00: 0.3068, + PERIOD_02_30: 0.3043, + PERIOD_03_00: 0.3044, + PERIOD_03_30: 0.3044, + PERIOD_04_00: 0.3053, + PERIOD_04_30: 0.3088, + PERIOD_05_00: 0.3156, + PERIOD_05_30: 0.3169, + PERIOD_06_00: 0.3172, + PERIOD_06_30: 0.3198, + PERIOD_07_00: 0.3294, + PERIOD_07_30: 0.327, + PERIOD_08_00: 0.3089, + PERIOD_08_30: 0.2814, + PERIOD_09_00: 0.2675, + PERIOD_09_30: 0.2675, + PERIOD_10_00: 0.2484, + PERIOD_10_30: 0.2409, + PERIOD_11_00: 0.2382, + PERIOD_11_30: 0.2409, + PERIOD_12_00: 0.2382, + PERIOD_12_30: 0.2377, + PERIOD_13_00: 0.2382, + PERIOD_13_30: 0.237, + PERIOD_14_00: 0.2338, + PERIOD_14_30: 0.237, + PERIOD_15_00: 0.2364, + PERIOD_15_30: 0.2516, + PERIOD_16_00: 0.2847, + PERIOD_16_30: 0.3156, + PERIOD_17_00: 0.3333, + PERIOD_17_30: 0.3387, + PERIOD_18_00: 0.3396, + PERIOD_18_30: 0.3387, + PERIOD_19_00: 0.3387, + PERIOD_19_30: 0.3404, + PERIOD_20_00: 0.3387, + PERIOD_20_30: 0.3207, + PERIOD_21_00: 0.3156, + PERIOD_21_30: 0.3156, + PERIOD_22_00: 0.3207, + PERIOD_22_30: 0.3156, + PERIOD_23_00: 0.3132, + PERIOD_23_30: 0.3107, + }, + }, + Winter: {}, + }, + seasons: { + Summer: { fromDay: 1, toDay: 31, fromMonth: 1, toMonth: 12, tou_periods: HALF_HOUR_PERIODS }, + Winter: {}, + }, + sell_tariff: { + name: "Flow Power (managed by PowerSync)", + utility: "Flow Power", + daily_charges: [{ name: "Charge" }], + demand_charges: { ALL: { rates: { ALL: 0 } }, Summer: {}, Winter: {} }, + energy_charges: { + ALL: { rates: { ALL: 0 } }, + Summer: { + rates: { + PERIOD_00_00: 0, + PERIOD_00_30: 0, + PERIOD_01_00: 0, + PERIOD_01_30: 0, + PERIOD_02_00: 0, + PERIOD_02_30: 0, + PERIOD_03_00: 0, + PERIOD_03_30: 0, + PERIOD_04_00: 0, + PERIOD_04_30: 0, + PERIOD_05_00: 0, + PERIOD_05_30: 0, + PERIOD_06_00: 0, + PERIOD_06_30: 0, + PERIOD_07_00: 0, + PERIOD_07_30: 0, + PERIOD_08_00: 0, + PERIOD_08_30: 0, + PERIOD_09_00: 0, + PERIOD_09_30: 0, + PERIOD_10_00: 0, + PERIOD_10_30: 0, + PERIOD_11_00: 0, + PERIOD_11_30: 0, + PERIOD_12_00: 0, + PERIOD_12_30: 0, + PERIOD_13_00: 0, + PERIOD_13_30: 0, + PERIOD_14_00: 0, + PERIOD_14_30: 0, + PERIOD_15_00: 0, + PERIOD_15_30: 0, + PERIOD_16_00: 0, + PERIOD_16_30: 0, + PERIOD_17_00: 0, + PERIOD_17_30: 0.45, + PERIOD_18_00: 0.45, + PERIOD_18_30: 0.45, + PERIOD_19_00: 0.45, + PERIOD_19_30: 0, + PERIOD_20_00: 0, + PERIOD_20_30: 0, + PERIOD_21_00: 0, + PERIOD_21_30: 0, + PERIOD_22_00: 0, + PERIOD_22_30: 0, + PERIOD_23_00: 0, + PERIOD_23_30: 0, + }, + }, + Winter: {}, + }, + seasons: { + Summer: { fromDay: 1, toDay: 31, fromMonth: 1, toMonth: 12, tou_periods: HALF_HOUR_PERIODS }, + Winter: {}, + }, + }, +}; diff --git a/test/tariff.test.ts b/test/tariff.test.ts new file mode 100644 index 0000000..890a062 --- /dev/null +++ b/test/tariff.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; +import { getTariffPeriods } from "../src/tariff.js"; +import { TariffContentV2 } from "../src/types/site_info.js"; +import { MOORINYA_TARIFF } from "./fixtures/moorinya-tariff.js"; + +const TZ = "Australia/Brisbane"; + +function brisbane(year: number, month: number, day: number, hour: number, minute = 0): Date { + return new Date(Date.UTC(year, month - 1, day, hour - 10, minute)); +} + +describe("getTariffPeriods: live Moorinya fixture", () => { + it("resolves the current buy/sell rate at a normal half-hour boundary", () => { + const now = brisbane(2026, 7, 23, 7, 15); + const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + expect(result).not.toBeNull(); + expect(result!.buy).toEqual({ price: 0.3294, periodName: "PERIOD_07_00", seasonName: "Summer" }); + expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 7, 0)); + expect(result!.nextChange).toEqual(brisbane(2026, 7, 23, 7, 30)); + expect(result!.currency).toBe("AUD"); + }); + + it("returns a real 0.0 sell price, not null (key-presence, not truthiness)", () => { + const now = brisbane(2026, 7, 23, 10, 15); + const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + expect(result!.sell.price).toBe(0); + expect(result!.sell.periodName).toBe("PERIOD_10_00"); + }); + + it("resolves a nonzero sell peak rate", () => { + const now = brisbane(2026, 7, 23, 18, 15); + const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + expect(result!.sell.price).toBe(0.45); + expect(result!.sell.periodName).toBe("PERIOD_18_00"); + }); + + it("normalizes toHour:24 instead of crashing, and rolls nextChange into the next day", () => { + const now = brisbane(2026, 7, 23, 23, 45); + const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + expect(result!.buy).toEqual({ price: 0.3107, periodName: "PERIOD_23_30", seasonName: "Summer" }); + expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 23, 30)); + expect(result!.nextChange).toEqual(brisbane(2026, 7, 24, 0, 0)); + }); + + it("wraps forward past midnight into PERIOD_00_00", () => { + const now = brisbane(2026, 7, 24, 0, 10); + const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + expect(result!.buy.periodName).toBe("PERIOD_00_00"); + expect(result!.currentStart).toEqual(brisbane(2026, 7, 24, 0, 0)); + expect(result!.nextChange).toEqual(brisbane(2026, 7, 24, 0, 30)); + }); + + it("builds an upcoming schedule across the requested horizon", () => { + const now = brisbane(2026, 7, 23, 7, 15); + const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ, horizonHours: 1 }); + expect(result!.upcoming).not.toBeNull(); + const upcoming = result!.upcoming!; + expect(upcoming[0].start).toEqual(now); + expect(upcoming[0].buy.periodName).toBe("PERIOD_07_00"); + expect(upcoming[upcoming.length - 1].end).toEqual(brisbane(2026, 7, 23, 8, 15)); + expect(upcoming.some((p) => p.buy.periodName === "PERIOD_07_30")).toBe(true); + }); + + it("throws when the caller omits the site timeZone", () => { + const now = brisbane(2026, 7, 23, 7, 15); + expect(() => getTariffPeriods(MOORINYA_TARIFF, now)).toThrow(/timeZone/); + }); +}); + +describe("getTariffPeriods: season year-cross", () => { + const YEAR_CROSS_TARIFF: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Year Cross", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { Summer: { rates: { ALL_DAY: 0.5 } } }, + seasons: { + Summer: { fromMonth: 10, fromDay: 1, toMonth: 3, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } }, + }, + }; + + it("matches a season that straddles New Year (Oct->Mar)", () => { + const now = brisbane(2026, 1, 15, 12, 0); + const result = getTariffPeriods(YEAR_CROSS_TARIFF, now, { timeZone: TZ }); + expect(result!.buy).toEqual({ price: 0.5, periodName: "ALL_DAY", seasonName: "Summer" }); + }); + + it("returns null when no season covers the date", () => { + const now = brisbane(2026, 6, 15, 12, 0); + const result = getTariffPeriods(YEAR_CROSS_TARIFF, now, { timeZone: TZ }); + expect(result).toBeNull(); + }); +}); + +describe("getTariffPeriods: day-of-week wrap", () => { + const WEEK_WRAP_TARIFF: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Week Wrap", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { WEEKEND: 0.2, WEEKDAY: 0.4 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + // Fri(4) -> Mon(0): covers Fri, Sat, Sun, Mon + WEEKEND: { periods: [{ fromDayOfWeek: 4, toDayOfWeek: 0, toHour: 24 }] }, + // Tue(1) -> Thu(3) + WEEKDAY: { periods: [{ fromDayOfWeek: 1, toDayOfWeek: 3, toHour: 24 }] }, + }, + }, + }, + }; + + it("selects the wrapped weekend period on a Saturday", () => { + const now = brisbane(2026, 7, 25, 12, 0); // Saturday + const result = getTariffPeriods(WEEK_WRAP_TARIFF, now, { timeZone: TZ }); + expect(result!.buy).toEqual({ price: 0.2, periodName: "WEEKEND", seasonName: "ALL" }); + }); + + it("selects the non-wrapped weekday period on a Wednesday", () => { + const now = brisbane(2026, 7, 29, 12, 0); // Wednesday + const result = getTariffPeriods(WEEK_WRAP_TARIFF, now, { timeZone: TZ }); + expect(result!.buy).toEqual({ price: 0.4, periodName: "WEEKDAY", seasonName: "ALL" }); + }); +}); + +describe("getTariffPeriods: midnight-crossing period", () => { + const MIDNIGHT_CROSS_TARIFF: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Midnight Cross", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { NIGHT: 0.15, DAY: 0.25 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + NIGHT: { periods: [{ toDayOfWeek: 6, fromHour: 23, toHour: 1 }] }, + DAY: { periods: [{ toDayOfWeek: 6, fromHour: 1, toHour: 23 }] }, + }, + }, + }, + }; + + it("matches the night period after 23:00 on the same day", () => { + const now = brisbane(2026, 7, 23, 23, 30); + const result = getTariffPeriods(MIDNIGHT_CROSS_TARIFF, now, { timeZone: TZ }); + expect(result!.buy.periodName).toBe("NIGHT"); + expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 23, 0)); + expect(result!.nextChange).toEqual(brisbane(2026, 7, 24, 1, 0)); + }); + + it("matches the same night period's earlier-morning instance from the previous day", () => { + const now = brisbane(2026, 7, 24, 0, 30); + const result = getTariffPeriods(MIDNIGHT_CROSS_TARIFF, now, { timeZone: TZ }); + expect(result!.buy.periodName).toBe("NIGHT"); + expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 23, 0)); + expect(result!.nextChange).toEqual(brisbane(2026, 7, 24, 1, 0)); + }); +}); + +describe("getTariffPeriods: missing / partial data", () => { + it("returns sell = all-null when sell_tariff is absent (buy-only plan)", () => { + const tariff: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Buy Only", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { ALL_DAY: 0.3 } } }, + seasons: { ALL: { fromMonth: 1, fromDay: 1, toMonth: 12, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } } }, + }; + const now = brisbane(2026, 7, 23, 12, 0); + const result = getTariffPeriods(tariff, now, { timeZone: TZ }); + expect(result!.buy).toEqual({ price: 0.3, periodName: "ALL_DAY", seasonName: "ALL" }); + expect(result!.sell).toEqual({ price: null, periodName: null, seasonName: null }); + }); + + it("returns price = null when a matched period has no rate and no ALL fallback", () => { + const tariff: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Missing Rate", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { Solo: { rates: {} } }, + seasons: { Solo: { fromMonth: 1, fromDay: 1, toMonth: 12, toDay: 31, tou_periods: { ONLY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } } }, + }; + const now = brisbane(2026, 7, 23, 12, 0); + const result = getTariffPeriods(tariff, now, { timeZone: TZ }); + expect(result!.buy).toEqual({ price: null, periodName: "ONLY", seasonName: "Solo" }); + }); + + it("returns null when the tariff has no seasons at all", () => { + const tariff = { version: 1 } as unknown as TariffContentV2; + const now = brisbane(2026, 7, 23, 12, 0); + expect(getTariffPeriods(tariff, now, { timeZone: TZ })).toBeNull(); + }); + + it("returns null when only an empty season object is present", () => { + const tariff: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Empty Season", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: {}, + seasons: { Winter: {} }, + }; + const now = brisbane(2026, 7, 23, 12, 0); + expect(getTariffPeriods(tariff, now, { timeZone: TZ })).toBeNull(); + }); +}); From 52170d0595c4675b3ca923fa30e96b4dabfe243d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 23 Jul 2026 21:08:39 +1000 Subject: [PATCH 2/6] Remove identifying site name/id from tariff fixture The fixture's real values (rates, period geometry) came from a live capture but are not sensitive; only the source site's name and id were. --- .../{moorinya-tariff.ts => sample-tariff.ts} | 4 ++-- test/tariff.test.ts | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) rename test/fixtures/{moorinya-tariff.ts => sample-tariff.ts} (98%) diff --git a/test/fixtures/moorinya-tariff.ts b/test/fixtures/sample-tariff.ts similarity index 98% rename from test/fixtures/moorinya-tariff.ts rename to test/fixtures/sample-tariff.ts index 2a1af16..7a8b77b 100644 --- a/test/fixtures/moorinya-tariff.ts +++ b/test/fixtures/sample-tariff.ts @@ -52,8 +52,8 @@ const HALF_HOUR_PERIODS: TouPeriods = { PERIOD_23_30: { periods: [{ toDayOfWeek: 6, fromHour: 23, fromMinute: 30, toHour: 24 }] }, }; -/** Live Tariff V2 capture: Teslemetry `get_energy_tariff`, site `Moorinya` (site_id 2533979794926773), 2026-07-23. */ -export const MOORINYA_TARIFF: TariffContentV2 = { +/** Live Tariff V2 capture: Teslemetry `get_energy_tariff`, site "Test Site" (site_id 1000000000000000), 2026-07-23. */ +export const SAMPLE_TARIFF: TariffContentV2 = { code: "POWER_SYNC:FLOW_POWER", name: "Flow Power (PowerSync)", utility: "Flow Power", diff --git a/test/tariff.test.ts b/test/tariff.test.ts index 890a062..856ed22 100644 --- a/test/tariff.test.ts +++ b/test/tariff.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { getTariffPeriods } from "../src/tariff.js"; import { TariffContentV2 } from "../src/types/site_info.js"; -import { MOORINYA_TARIFF } from "./fixtures/moorinya-tariff.js"; +import { SAMPLE_TARIFF } from "./fixtures/sample-tariff.js"; const TZ = "Australia/Brisbane"; @@ -9,10 +9,10 @@ function brisbane(year: number, month: number, day: number, hour: number, minute return new Date(Date.UTC(year, month - 1, day, hour - 10, minute)); } -describe("getTariffPeriods: live Moorinya fixture", () => { +describe("getTariffPeriods: live sample fixture", () => { it("resolves the current buy/sell rate at a normal half-hour boundary", () => { const now = brisbane(2026, 7, 23, 7, 15); - const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + const result = getTariffPeriods(SAMPLE_TARIFF, now, { timeZone: TZ }); expect(result).not.toBeNull(); expect(result!.buy).toEqual({ price: 0.3294, periodName: "PERIOD_07_00", seasonName: "Summer" }); expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 7, 0)); @@ -22,21 +22,21 @@ describe("getTariffPeriods: live Moorinya fixture", () => { it("returns a real 0.0 sell price, not null (key-presence, not truthiness)", () => { const now = brisbane(2026, 7, 23, 10, 15); - const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + const result = getTariffPeriods(SAMPLE_TARIFF, now, { timeZone: TZ }); expect(result!.sell.price).toBe(0); expect(result!.sell.periodName).toBe("PERIOD_10_00"); }); it("resolves a nonzero sell peak rate", () => { const now = brisbane(2026, 7, 23, 18, 15); - const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + const result = getTariffPeriods(SAMPLE_TARIFF, now, { timeZone: TZ }); expect(result!.sell.price).toBe(0.45); expect(result!.sell.periodName).toBe("PERIOD_18_00"); }); it("normalizes toHour:24 instead of crashing, and rolls nextChange into the next day", () => { const now = brisbane(2026, 7, 23, 23, 45); - const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + const result = getTariffPeriods(SAMPLE_TARIFF, now, { timeZone: TZ }); expect(result!.buy).toEqual({ price: 0.3107, periodName: "PERIOD_23_30", seasonName: "Summer" }); expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 23, 30)); expect(result!.nextChange).toEqual(brisbane(2026, 7, 24, 0, 0)); @@ -44,7 +44,7 @@ describe("getTariffPeriods: live Moorinya fixture", () => { it("wraps forward past midnight into PERIOD_00_00", () => { const now = brisbane(2026, 7, 24, 0, 10); - const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ }); + const result = getTariffPeriods(SAMPLE_TARIFF, now, { timeZone: TZ }); expect(result!.buy.periodName).toBe("PERIOD_00_00"); expect(result!.currentStart).toEqual(brisbane(2026, 7, 24, 0, 0)); expect(result!.nextChange).toEqual(brisbane(2026, 7, 24, 0, 30)); @@ -52,7 +52,7 @@ describe("getTariffPeriods: live Moorinya fixture", () => { it("builds an upcoming schedule across the requested horizon", () => { const now = brisbane(2026, 7, 23, 7, 15); - const result = getTariffPeriods(MOORINYA_TARIFF, now, { timeZone: TZ, horizonHours: 1 }); + const result = getTariffPeriods(SAMPLE_TARIFF, now, { timeZone: TZ, horizonHours: 1 }); expect(result!.upcoming).not.toBeNull(); const upcoming = result!.upcoming!; expect(upcoming[0].start).toEqual(now); @@ -63,7 +63,7 @@ describe("getTariffPeriods: live Moorinya fixture", () => { it("throws when the caller omits the site timeZone", () => { const now = brisbane(2026, 7, 23, 7, 15); - expect(() => getTariffPeriods(MOORINYA_TARIFF, now)).toThrow(/timeZone/); + expect(() => getTariffPeriods(SAMPLE_TARIFF, now)).toThrow(/timeZone/); }); }); From 1e38b07bc71914e6f1853ca84e9ecbdf8bedd37f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 23 Jul 2026 21:25:46 +1000 Subject: [PATCH 3/6] Fix nextChange gap-capping, season re-resolution, DST conversion, and ALL fallback Address four correctness issues from the Codex PR review: - nextChange now stops at the active period's own end for a sparse tariff, instead of reporting the next period found arbitrarily far in the future. - The upcoming schedule re-resolves the season on each calendar day instead of reusing the season resolved for `now`, so a horizon crossing a season boundary re-prices and re-labels correctly. - Boundary instants are now reconstructed from their wall-clock label via an iterative UTC-offset solve, instead of adding elapsed real minutes to `now` - the two diverge across a DST transition. - Price lookup falls back to the ALL season/period bucket when the matched season key is present but carries no rates at all (not just when the key is absent). This replaces the point-in-time resolver's minute-of-week/instances model with a day-by-day walk (dayPeriods), which both fixes the season re-resolution issue and unifies the logic used for the current-rate lookup and the upcoming-schedule builder. --- AGENTS.md | 2 +- src/tariff.ts | 271 +++++++++++++++++++++++++++++--------------- test/tariff.test.ts | 127 +++++++++++++++++++++ 3 files changed, 308 insertions(+), 92 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8439b23..9bb056e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - **`resp.requestUuid` must never gate acceptance of a `SessionInfo` reply - only cross-check it when the vehicle actually populated it.** Real VCSEC hardware leaves it empty (protocol.md: "due to memory constraints, the `request_uuid` field is typically not populated in replies from VCSEC"); an equality-or-reject guard here silently breaks every VCSEC command (`door_lock`, `door_unlock`, `actuate_truck`, `charge_port_door_open/close`, `remote_start_drive`) against real vehicles even though `test/helpers/fakevehicle.ts` (which always echoes it) can't catch the regression. The `session_info_tag` HMAC - which binds the *sent* uuid as `TAG_CHALLENGE` - is the actual authentication; `FakeVehicle.omitRequestUuidLikeRealVcsec()` reproduces the real-hardware shape in tests. - For `DOMAIN_VEHICLE_SECURITY`, `Commands.dispatch` holds the domain's session lock across the *entire* dispatch (handshake + build + send + retry), not just message-build - VCSEC requires messages to arrive in strict counter order and the spec warns against simultaneous requests to it at all, unlike Infotainment (sliding window), which keeps the narrower build-only lock. - `Commands`'s `#privateKey`/`#publicKey` are native private class fields (not `protected`/TS-only `private`) - the raw signing key must not be reachable off the instance at all (e.g. via `JSON.stringify` or a structured log), not merely inaccessible to outside *code*. Tests that need to observe key derivation do so through what actually goes out on the wire (a captured handshake message), not by reading the field. -- `src/tariff.ts`'s `getTariffPeriods(tariff, now, opts)` is a pure Tariff V2 rate resolver mirroring `python-tesla-fleet-api`'s sibling. The tariff object carries no timezone; the caller must pass `opts.timeZone` (an IANA string, e.g. from `site_info.installation_time_zone`) - the resolver has no other way to get site-local wall-clock parts from a JS `Date`, which is always a UTC instant. Internally it matches on a minute-of-week representation built from independent day-of-week-range and time-of-day-range checks (see the module's `dowInRange`/`buildInstances`) - not a single contiguous minute-of-week span - because a `tou_periods` entry's `fromDayOfWeek..toDayOfWeek` means "this daily time window recurs on each of these weekdays", not "one span from this day+time to that day+time". `TariffContentV2` (`src/types/site_info.ts`) types `seasons` as `Record` (an object keyed by season name, not an array) - that mismatch was a real bug fixed alongside the resolver; don't regress it back to an array shape. +- `src/tariff.ts`'s `getTariffPeriods(tariff, now, opts)` is a pure Tariff V2 rate resolver mirroring `python-tesla-fleet-api`'s sibling. The tariff object carries no timezone; the caller must pass `opts.timeZone` (an IANA string, e.g. from `site_info.installation_time_zone`) - the resolver has no other way to get site-local wall-clock parts from a JS `Date`, which is always a UTC instant. It resolves one calendar day at a time (`dayPeriods`), not a multi-day minute-of-week span, because a `tou_periods` entry's `fromDayOfWeek..toDayOfWeek` means "this daily time window recurs on each of these weekdays", not "one span from this day+time to that day+time"; `nextChange`/`upcoming` re-resolve the season fresh on each day so a horizon crossing a season boundary re-prices correctly, and a gap between scheduled periods reports the gap (not the next period found arbitrarily far out). Converting a resolved wall-clock boundary back to a `Date` goes through `wallClockToUtcMillis` (iterative, since the zone's UTC offset at the target instant is what's being solved for) rather than adding elapsed real minutes to `now` - the two diverge across a DST transition. `TariffContentV2` (`src/types/site_info.ts`) types `seasons` as `Record` (an object keyed by season name, not an array) - that mismatch was a real bug fixed alongside the resolver; don't regress it back to an array shape. ## Maintaining this file diff --git a/src/tariff.ts b/src/tariff.ts index 7b41f86..886195f 100644 --- a/src/tariff.ts +++ b/src/tariff.ts @@ -23,13 +23,13 @@ export interface TariffResolution { } const MINUTES_PER_DAY = 1440; -const MINUTES_PER_WEEK = MINUTES_PER_DAY * 7; -type WallClock = { dow: number; minuteOfDay: number; month: number; day: number }; +type WallClock = { year: number; month: number; day: number; dow: number; minuteOfDay: number }; -type Instance = { periodName: string; start: number; end: number }; +/** One day's period windows, expressed in that day's own minute-of-day (0..1440). */ +type DaySegment = { periodName: string; startMin: number; endMin: number; trueStartMin: number }; -function getWallClock(now: Date, timeZone: string): WallClock { +function formatParts(date: Date, timeZone: string): Record { const parts = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", @@ -37,18 +37,45 @@ function getWallClock(now: Date, timeZone: string): WallClock { day: "2-digit", hour: "2-digit", minute: "2-digit", + second: "2-digit", hourCycle: "h23", - }).formatToParts(now); - const get = (type: string): number => Number(parts.find((p) => p.type === type)?.value); - const year = get("year"); - const month = get("month"); - const day = get("day"); - const hour = get("hour"); - const minute = get("minute"); + }).formatToParts(date); + const out: Record = {}; + for (const p of parts) out[p.type] = Number(p.value); + return out; +} + +function getWallClock(now: Date, timeZone: string): WallClock { + const { year, month, day, hour, minute } = formatParts(now, timeZone); // Weekday derived from the site-local date (not name matching): Tesla is Mon=0..Sun=6, JS Date.getUTCDay() is Sun=0..Sat=6. const jsDow = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); const dow = (jsDow + 6) % 7; - return { dow, minuteOfDay: hour * 60 + minute, month, day }; + return { year, month, day, dow, minuteOfDay: hour * 60 + minute }; +} + +/** + * Converts a target site-local wall-clock (year/month/day/hour/minute in `timeZone`) to the UTC + * instant it represents. Iterates because the zone's UTC offset at the target instant is itself + * what we're solving for (DST) - 3 rounds converges for any real-world offset change. + */ +function wallClockToUtcMillis(year: number, month: number, day: number, hour: number, minute: number, timeZone: string): number { + const target = Date.UTC(year, month - 1, day, hour, minute); + let guess = target; + for (let i = 0; i < 3; i++) { + const p = formatParts(new Date(guess), timeZone); + const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + const offset = asUtc - guess; + const next = target - offset; + if (next === guess) break; + guess = next; + } + return guess; +} + +/** Adds `deltaMinutes` of wall-clock time (not elapsed real time) to a wall-clock reading. */ +function shiftWallClock(wallClock: WallClock, deltaMinutes: number): { year: number; month: number; day: number; hour: number; minute: number } { + const naive = new Date(Date.UTC(wallClock.year, wallClock.month - 1, wallClock.day, 0, wallClock.minuteOfDay + deltaMinutes)); + return { year: naive.getUTCFullYear(), month: naive.getUTCMonth() + 1, day: naive.getUTCDate(), hour: naive.getUTCHours(), minute: naive.getUTCMinutes() }; } function dowInRange(d: number, fromDow: number, toDow: number): boolean { @@ -77,114 +104,169 @@ function findSeason(seasons: Record | undefined, month: number, return null; } -/** One instance per eligible weekday per week cycle (-1/0/+1), so day-of-week wrap and midnight cross fall out of plain interval containment. */ -function buildInstances(touPeriods: TouPeriods): Instance[] { - const instances: Instance[] = []; +/** + * Expands a season's `tou_periods` into the windows active on one specific calendar day (given + * as a Tesla weekday, Mon=0..Sun=6). A period whose `fromDayOfWeek..toDayOfWeek` includes `dow` + * contributes its own window (clamped to this day, `endMin` capped at 1440 if it crosses + * midnight); a period that crosses midnight and includes *yesterday* also contributes its + * early-morning continuation here, `trueStartMin` carrying its real (pre-midnight) start so a + * currently-active continuation can still report an accurate `currentStart`. + */ +function dayPeriods(touPeriods: TouPeriods, dow: number): DaySegment[] { + const segments: DaySegment[] = []; + const prevDow = (dow + 6) % 7; for (const [periodName, def] of Object.entries(touPeriods)) { for (const window of def.periods ?? []) { const fromDow = window.fromDayOfWeek ?? 0; const toDow = window.toDayOfWeek ?? 6; const fromMin = (window.fromHour ?? 0) * 60 + (window.fromMinute ?? 0); const toMin = (window.toHour ?? 0) * 60 + (window.toMinute ?? 0); - const duration = toMin > fromMin ? toMin - fromMin : MINUTES_PER_DAY - fromMin + toMin; - if (duration <= 0) continue; - for (let anchorDow = 0; anchorDow < 7; anchorDow++) { - if (!dowInRange(anchorDow, fromDow, toDow)) continue; - for (const weekOffset of [-1, 0, 1]) { - const start = weekOffset * MINUTES_PER_WEEK + anchorDow * MINUTES_PER_DAY + fromMin; - instances.push({ periodName, start, end: start + duration }); - } + const crossesMidnight = toMin <= fromMin; + if (dowInRange(dow, fromDow, toDow)) { + const endMin = crossesMidnight ? MINUTES_PER_DAY : toMin; + if (endMin > fromMin) segments.push({ periodName, startMin: fromMin, endMin, trueStartMin: fromMin }); + } + if (crossesMidnight && toMin > 0 && dowInRange(prevDow, fromDow, toDow)) { + segments.push({ periodName, startMin: 0, endMin: toMin, trueStartMin: fromMin - MINUTES_PER_DAY }); } } } - return instances; -} - -function findContaining(instances: Instance[], atMinute: number): Instance | null { - let best: Instance | null = null; - for (const instance of instances) { - if (instance.start <= atMinute && atMinute < instance.end) { - if (!best || instance.start > best.start) best = instance; - } - } - return best; -} - -function findNextStart(instances: Instance[], afterMinute: number): number | null { - let best: number | null = null; - for (const instance of instances) { - if (instance.start > afterMinute && (best === null || instance.start < best)) { - best = instance.start; - } - } - return best; + return segments; } function lookupPrice(charges: EnergyCharges | undefined, seasonName: string, periodName: string): number | null { if (!charges) return null; - const seasonRates = (Object.prototype.hasOwnProperty.call(charges, seasonName) ? charges[seasonName] : charges["ALL"])?.rates; + // A season key can be present but carry no rates at all (e.g. an empty `"Winter": {}` stub + // alongside a populated `"ALL"`) - that must fall back to ALL exactly like a missing key. + const seasonRates = charges[seasonName]?.rates ?? charges["ALL"]?.rates; if (!seasonRates) return null; const value = Object.prototype.hasOwnProperty.call(seasonRates, periodName) ? seasonRates[periodName] : seasonRates["ALL"]; return typeof value === "number" && Number.isFinite(value) ? value : null; } -type GridResolution = { +const EMPTY_RATE: TariffRate = { price: null, periodName: null, seasonName: null }; + +type PointResolution = { rate: TariffRate; - seasonName: string; - instances: Instance[]; - charges: EnergyCharges | undefined; - currentStartOffset: number; - nextChangeOffset: number; + currentStartGM: number; + nextChangeGM: number; }; -function resolveGrid(seasons: Record | undefined, charges: EnergyCharges | undefined, wallClock: WallClock, nowAbs: number): GridResolution | null { +/** + * Resolves the rate active at `wallClock` (site-local "now"), and the next minute-of-week + * boundary at which it changes - capped at the active period's own end so a sparse tariff + * (a real gap between scheduled periods) reports the gap, not the next period found arbitrarily + * far in the future. Both values are in GM units: wall-clock minutes relative to `wallClock`. + */ +function resolveAt(seasons: Record | undefined, charges: EnergyCharges | undefined, wallClock: WallClock, now: Date, timeZone: string): PointResolution | null { const matched = findSeason(seasons, wallClock.month, wallClock.day); if (!matched) return null; - const instances = buildInstances(matched.season.tou_periods!); - if (instances.length === 0) return null; - const current = findContaining(instances, nowAbs); + const todaySegs = dayPeriods(matched.season.tou_periods!, wallClock.dow); + const current = todaySegs.find((s) => s.startMin <= wallClock.minuteOfDay && wallClock.minuteOfDay < s.endMin); if (!current) return null; - const nextChangeOffset = findNextStart(instances, nowAbs) ?? current.end; + + const laterTodayStarts = todaySegs.filter((s) => s.startMin > wallClock.minuteOfDay).map((s) => s.startMin); + let nextMin = current.endMin; + if (laterTodayStarts.length > 0) { + nextMin = Math.min(nextMin, Math.min(...laterTodayStarts)); + } else if (current.endMin >= MINUTES_PER_DAY) { + // The active period runs up to midnight with nothing else scheduled today - check whether + // it's the same period continuing tomorrow (a midnight-crossing window) before treating + // midnight itself as the boundary. + const tomorrowDate = new Date(now.getTime() + (MINUTES_PER_DAY - wallClock.minuteOfDay) * 60_000); + const tomorrowWallClock = getWallClock(tomorrowDate, timeZone); + const tomorrowMatched = findSeason(seasons, tomorrowWallClock.month, tomorrowWallClock.day); + const tomorrowSegs = tomorrowMatched ? dayPeriods(tomorrowMatched.season.tou_periods!, tomorrowWallClock.dow) : []; + const continuation = tomorrowMatched?.name === matched.name ? tomorrowSegs.find((s) => s.startMin === 0 && s.periodName === current.periodName) : undefined; + if (continuation) { + nextMin = MINUTES_PER_DAY + continuation.endMin; + } else if (tomorrowSegs.length > 0) { + nextMin = MINUTES_PER_DAY + Math.min(...tomorrowSegs.map((s) => s.startMin)); + } + } + return { - rate: { - price: lookupPrice(charges, matched.name, current.periodName), - periodName: current.periodName, - seasonName: matched.name, - }, - seasonName: matched.name, - instances, - charges, - currentStartOffset: current.start, - nextChangeOffset, + rate: { price: lookupPrice(charges, matched.name, current.periodName), periodName: current.periodName, seasonName: matched.name }, + currentStartGM: current.trueStartMin - wallClock.minuteOfDay, + nextChangeGM: nextMin - wallClock.minuteOfDay, }; } -const EMPTY_RATE: TariffRate = { price: null, periodName: null, seasonName: null }; +type ScheduleSegment = { startGM: number; endGM: number; periodName: string; seasonName: string }; -function buildUpcoming(buyGrid: GridResolution, sellGrid: GridResolution | null, nowAbs: number, horizonMinutes: number, toDate: (offset: number) => Date): TariffPeriod[] { - const horizonEnd = nowAbs + horizonMinutes; - const boundaries = new Set([nowAbs, horizonEnd]); - for (const instance of buyGrid.instances) { - if (instance.start > nowAbs && instance.start < horizonEnd) boundaries.add(instance.start); +/** Walks one calendar day at a time across the horizon, re-resolving the season fresh on each day so a horizon that crosses a season boundary re-prices and re-labels correctly. */ +function collectSegments(seasons: Record | undefined, wallClock: WallClock, now: Date, timeZone: string, horizonMinutes: number): ScheduleSegment[] { + const segments: ScheduleSegment[] = []; + let dayStartGM = -wallClock.minuteOfDay; + while (dayStartGM < horizonMinutes) { + const dayDate = new Date(now.getTime() + dayStartGM * 60_000); + const dayWallClock = getWallClock(dayDate, timeZone); + const matched = findSeason(seasons, dayWallClock.month, dayWallClock.day); + if (matched) { + for (const seg of dayPeriods(matched.season.tou_periods!, dayWallClock.dow)) { + const startGM = dayStartGM + seg.startMin; + const endGM = dayStartGM + seg.endMin; + if (endGM > 0 && startGM < horizonMinutes) { + segments.push({ startGM, endGM, periodName: seg.periodName, seasonName: matched.name }); + } + } + } + dayStartGM += MINUTES_PER_DAY; } - if (sellGrid) { - for (const instance of sellGrid.instances) { - if (instance.start > nowAbs && instance.start < horizonEnd) boundaries.add(instance.start); + segments.sort((a, b) => a.startGM - b.startGM); + const merged: ScheduleSegment[] = []; + for (const seg of segments) { + const last = merged[merged.length - 1]; + if (last && last.endGM === seg.startGM && last.periodName === seg.periodName && last.seasonName === seg.seasonName) { + last.endGM = seg.endGM; + } else { + merged.push({ ...seg }); } } + return merged; +} + +function buildUpcoming( + buySeasons: Record | undefined, + buyCharges: EnergyCharges | undefined, + sellSeasons: Record | undefined, + sellCharges: EnergyCharges | undefined, + hasSell: boolean, + now: Date, + wallClock: WallClock, + timeZone: string, + horizonMinutes: number, + toDate: (gm: number) => Date, +): TariffPeriod[] { + const buySegs = collectSegments(buySeasons, wallClock, now, timeZone, horizonMinutes); + const sellSegs = hasSell ? collectSegments(sellSeasons, wallClock, now, timeZone, horizonMinutes) : []; + + const boundaries = new Set([0, horizonMinutes]); + const addBoundary = (gm: number): void => { + if (gm > 0 && gm < horizonMinutes) boundaries.add(gm); + }; + for (const s of buySegs) { + addBoundary(s.startGM); + addBoundary(s.endGM); + } + for (const s of sellSegs) { + addBoundary(s.startGM); + addBoundary(s.endGM); + } + const sorted = [...boundaries].sort((a, b) => a - b); const periods: TariffPeriod[] = []; for (let i = 0; i < sorted.length - 1; i++) { const segStart = sorted[i]; const segEnd = sorted[i + 1]; if (segStart === segEnd) continue; - const buyInstance = findContaining(buyGrid.instances, segStart); - const sellInstance = sellGrid ? findContaining(sellGrid.instances, segStart) : null; + const buyAt = buySegs.find((s) => s.startGM <= segStart && segStart < s.endGM); + const sellAt = sellSegs.find((s) => s.startGM <= segStart && segStart < s.endGM); periods.push({ start: toDate(segStart), end: toDate(segEnd), - buy: buyInstance ? { price: lookupPrice(buyGrid.charges, buyGrid.seasonName, buyInstance.periodName), periodName: buyInstance.periodName, seasonName: buyGrid.seasonName } : EMPTY_RATE, - sell: sellInstance ? { price: lookupPrice(sellGrid!.charges, sellGrid!.seasonName, sellInstance.periodName), periodName: sellInstance.periodName, seasonName: sellGrid!.seasonName } : EMPTY_RATE, + buy: buyAt ? { price: lookupPrice(buyCharges, buyAt.seasonName, buyAt.periodName), periodName: buyAt.periodName, seasonName: buyAt.seasonName } : EMPTY_RATE, + sell: sellAt ? { price: lookupPrice(sellCharges, sellAt.seasonName, sellAt.periodName), periodName: sellAt.periodName, seasonName: sellAt.seasonName } : EMPTY_RATE, }); } return periods; @@ -198,29 +280,36 @@ function buildUpcoming(buyGrid: GridResolution, sellGrid: GridResolution | null, export function getTariffPeriods(tariff: TariffContentV2, now: Date, opts?: { timeZone: string; horizonHours?: number }): TariffResolution | null { if (!tariff?.seasons || !tariff?.energy_charges) return null; if (!opts?.timeZone) throw new Error("getTariffPeriods requires opts.timeZone"); + const timeZone = opts.timeZone; - const wallClock = getWallClock(now, opts.timeZone); - const nowAbs = wallClock.dow * MINUTES_PER_DAY + wallClock.minuteOfDay; + const wallClock = getWallClock(now, timeZone); + const buy = resolveAt(tariff.seasons, tariff.energy_charges, wallClock, now, timeZone); + if (!buy) return null; - const buyGrid = resolveGrid(tariff.seasons, tariff.energy_charges, wallClock, nowAbs); - if (!buyGrid) return null; - const sellGrid = tariff.sell_tariff ? resolveGrid(tariff.sell_tariff.seasons ?? tariff.seasons, tariff.sell_tariff.energy_charges, wallClock, nowAbs) : null; + const sellSeasons = tariff.sell_tariff?.seasons ?? tariff.seasons; + const sell = tariff.sell_tariff ? resolveAt(sellSeasons, tariff.sell_tariff.energy_charges, wallClock, now, timeZone) : null; - const toDate = (offset: number): Date => new Date(now.getTime() + (offset - nowAbs) * 60_000); + // Boundaries are wall-clock minute deltas, so converting one back to an instant means + // re-deriving the target wall-clock label and reconverting *that*, not adding elapsed + // real minutes to `now` - the two differ across a DST transition. + const toDate = (gm: number): Date => { + const target = shiftWallClock(wallClock, gm); + return new Date(wallClockToUtcMillis(target.year, target.month, target.day, target.hour, target.minute, timeZone)); + }; - const nextChangeOffset = sellGrid ? Math.min(buyGrid.nextChangeOffset, sellGrid.nextChangeOffset) : buyGrid.nextChangeOffset; + const nextChangeGM = sell ? Math.min(buy.nextChangeGM, sell.nextChangeGM) : buy.nextChangeGM; const resolution: TariffResolution = { - buy: buyGrid.rate, - sell: sellGrid ? sellGrid.rate : EMPTY_RATE, - currentStart: toDate(buyGrid.currentStartOffset), - nextChange: toDate(nextChangeOffset), + buy: buy.rate, + sell: sell ? sell.rate : EMPTY_RATE, + currentStart: toDate(buy.currentStartGM), + nextChange: toDate(nextChangeGM), currency: typeof tariff.currency === "string" ? tariff.currency : null, upcoming: null, }; if (opts.horizonHours) { - resolution.upcoming = buildUpcoming(buyGrid, sellGrid, nowAbs, opts.horizonHours * 60, toDate); + resolution.upcoming = buildUpcoming(tariff.seasons, tariff.energy_charges, sellSeasons, tariff.sell_tariff?.energy_charges, !!tariff.sell_tariff, now, wallClock, timeZone, opts.horizonHours * 60, toDate); } return resolution; diff --git a/test/tariff.test.ts b/test/tariff.test.ts index 856ed22..87ada7f 100644 --- a/test/tariff.test.ts +++ b/test/tariff.test.ts @@ -232,4 +232,131 @@ describe("getTariffPeriods: missing / partial data", () => { const now = brisbane(2026, 7, 23, 12, 0); expect(getTariffPeriods(tariff, now, { timeZone: TZ })).toBeNull(); }); + + it("falls back to ALL when the matched season key has no rates at all (not just an absent key)", () => { + const tariff: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Empty Season Rates", + currency: "USD", + daily_charges: [], + demand_charges: {}, + // "Solo" is matched by date but carries no `rates` key at all - must still fall back to ALL. + energy_charges: { ALL: { rates: { ALL_DAY: 0.6 } }, Solo: {} }, + seasons: { Solo: { fromMonth: 1, fromDay: 1, toMonth: 12, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } } }, + }; + const now = brisbane(2026, 7, 23, 12, 0); + const result = getTariffPeriods(tariff, now, { timeZone: TZ }); + expect(result!.buy).toEqual({ price: 0.6, periodName: "ALL_DAY", seasonName: "Solo" }); + }); +}); + +describe("getTariffPeriods: sparse schedule (gap between periods)", () => { + const SPARSE_TARIFF: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Sparse", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { MORNING: 0.4, EVENING: 0.5 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + // 10:00-11:00, then a real gap, then 17:00-18:00 - nothing scheduled in between. + MORNING: { periods: [{ toDayOfWeek: 6, fromHour: 10, toHour: 11 }] }, + EVENING: { periods: [{ toDayOfWeek: 6, fromHour: 17, toHour: 18 }] }, + }, + }, + }, + }; + + it("reports nextChange at the active period's own end, not the next period found later", () => { + const now = brisbane(2026, 7, 23, 10, 30); + const result = getTariffPeriods(SPARSE_TARIFF, now, { timeZone: TZ }); + expect(result!.buy).toEqual({ price: 0.4, periodName: "MORNING", seasonName: "ALL" }); + expect(result!.nextChange).toEqual(brisbane(2026, 7, 23, 11, 0)); + }); + + it("returns null while sitting inside the gap itself", () => { + const now = brisbane(2026, 7, 23, 12, 0); + expect(getTariffPeriods(SPARSE_TARIFF, now, { timeZone: TZ })).toBeNull(); + }); + + it("omits the gap from the upcoming schedule instead of stretching MORNING across it", () => { + const now = brisbane(2026, 7, 23, 10, 30); + const result = getTariffPeriods(SPARSE_TARIFF, now, { timeZone: TZ, horizonHours: 8 }); + const upcoming = result!.upcoming!; + const morning = upcoming.find((p) => p.buy.periodName === "MORNING")!; + expect(morning.end).toEqual(brisbane(2026, 7, 23, 11, 0)); + const gap = upcoming.find((p) => p.start.getTime() === morning.end.getTime())!; + expect(gap.buy).toEqual({ price: null, periodName: null, seasonName: null }); + }); +}); + +describe("getTariffPeriods: upcoming re-resolves seasons across a boundary", () => { + const SEASON_SWITCH_TARIFF: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Season Switch", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { Summer: { rates: { ALL_DAY: 0.5 } }, Winter: { rates: { ALL_DAY: 0.2 } } }, + seasons: { + Summer: { fromMonth: 1, fromDay: 1, toMonth: 7, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } }, + Winter: { fromMonth: 8, fromDay: 1, toMonth: 12, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } }, + }, + }; + + it("relabels and reprices upcoming segments once the horizon crosses into the next season", () => { + const now = brisbane(2026, 7, 31, 12, 0); + const result = getTariffPeriods(SEASON_SWITCH_TARIFF, now, { timeZone: TZ, horizonHours: 36 }); + const upcoming = result!.upcoming!; + const before = upcoming.find((p) => p.start.getTime() === now.getTime())!; + expect(before.buy).toEqual({ price: 0.5, periodName: "ALL_DAY", seasonName: "Summer" }); + const after = upcoming.find((p) => p.start.getTime() === brisbane(2026, 8, 1, 0, 0).getTime())!; + expect(after.buy).toEqual({ price: 0.2, periodName: "ALL_DAY", seasonName: "Winter" }); + }); +}); + +describe("getTariffPeriods: DST-observing timezone", () => { + const DST_TARIFF: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "DST", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { NIGHT: 0.1, DAY: 0.3 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + NIGHT: { periods: [{ toDayOfWeek: 6, fromHour: 1, toHour: 3 }] }, + DAY: { periods: [{ toDayOfWeek: 6, fromHour: 3, toHour: 1 }] }, + }, + }, + }, + }; + + it("keeps nextChange at the correct wall-clock label across a spring-forward transition", () => { + // America/New_York: clocks jump 02:00 -> 03:00 on 2026-03-08. At 01:30 local (still EST, + // UTC-5), NIGHT (01:00-03:00) should end at 03:00 EDT (UTC-4) - not 90 elapsed UTC minutes later. + const now = new Date("2026-03-08T06:30:00Z"); // 2026-03-08 01:30 EST + const result = getTariffPeriods(DST_TARIFF, now, { timeZone: "America/New_York" }); + expect(result!.buy.periodName).toBe("NIGHT"); + expect(result!.nextChange).toEqual(new Date("2026-03-08T07:00:00Z")); // 2026-03-08 03:00 EDT + }); }); From 7973e5a6bd5413ac7dd07cb291ff25fa036c92a2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 23 Jul 2026 21:40:23 +1000 Subject: [PATCH 4/6] Fix sell-gap nextChange, DST-safe day derivation, and buy/sell currentStart Address three more Codex review findings: - nextChange now folds in the sell grid's next boundary even when sell is currently sitting in a gap (e.g. an export window not open yet), instead of silently falling back to buy's boundary alone. - Forward-looking day derivation (walking the upcoming horizon one day at a time, and resolveAt's tomorrow peek) now goes through pure wall-clock arithmetic instead of adding elapsed real minutes to a UTC instant, which landed on the wrong calendar day across a DST fall-back (25-hour) or spring-forward (23-hour) day. - currentStart is now the later of buy's and sell's own period start, not just buy's, when the two schedules differ. Unifies the point-in-time resolver and the sell-gap lookup around one `scheduleAt` function, since both need "next boundary regardless of whether a period is active right now". --- AGENTS.md | 2 +- src/tariff.ts | 128 +++++++++++++++++++++++++++----------------- test/tariff.test.ts | 85 +++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9bb056e..1bc20ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - **`resp.requestUuid` must never gate acceptance of a `SessionInfo` reply - only cross-check it when the vehicle actually populated it.** Real VCSEC hardware leaves it empty (protocol.md: "due to memory constraints, the `request_uuid` field is typically not populated in replies from VCSEC"); an equality-or-reject guard here silently breaks every VCSEC command (`door_lock`, `door_unlock`, `actuate_truck`, `charge_port_door_open/close`, `remote_start_drive`) against real vehicles even though `test/helpers/fakevehicle.ts` (which always echoes it) can't catch the regression. The `session_info_tag` HMAC - which binds the *sent* uuid as `TAG_CHALLENGE` - is the actual authentication; `FakeVehicle.omitRequestUuidLikeRealVcsec()` reproduces the real-hardware shape in tests. - For `DOMAIN_VEHICLE_SECURITY`, `Commands.dispatch` holds the domain's session lock across the *entire* dispatch (handshake + build + send + retry), not just message-build - VCSEC requires messages to arrive in strict counter order and the spec warns against simultaneous requests to it at all, unlike Infotainment (sliding window), which keeps the narrower build-only lock. - `Commands`'s `#privateKey`/`#publicKey` are native private class fields (not `protected`/TS-only `private`) - the raw signing key must not be reachable off the instance at all (e.g. via `JSON.stringify` or a structured log), not merely inaccessible to outside *code*. Tests that need to observe key derivation do so through what actually goes out on the wire (a captured handshake message), not by reading the field. -- `src/tariff.ts`'s `getTariffPeriods(tariff, now, opts)` is a pure Tariff V2 rate resolver mirroring `python-tesla-fleet-api`'s sibling. The tariff object carries no timezone; the caller must pass `opts.timeZone` (an IANA string, e.g. from `site_info.installation_time_zone`) - the resolver has no other way to get site-local wall-clock parts from a JS `Date`, which is always a UTC instant. It resolves one calendar day at a time (`dayPeriods`), not a multi-day minute-of-week span, because a `tou_periods` entry's `fromDayOfWeek..toDayOfWeek` means "this daily time window recurs on each of these weekdays", not "one span from this day+time to that day+time"; `nextChange`/`upcoming` re-resolve the season fresh on each day so a horizon crossing a season boundary re-prices correctly, and a gap between scheduled periods reports the gap (not the next period found arbitrarily far out). Converting a resolved wall-clock boundary back to a `Date` goes through `wallClockToUtcMillis` (iterative, since the zone's UTC offset at the target instant is what's being solved for) rather than adding elapsed real minutes to `now` - the two diverge across a DST transition. `TariffContentV2` (`src/types/site_info.ts`) types `seasons` as `Record` (an object keyed by season name, not an array) - that mismatch was a real bug fixed alongside the resolver; don't regress it back to an array shape. +- `src/tariff.ts`'s `getTariffPeriods(tariff, now, opts)` is a pure Tariff V2 rate resolver mirroring `python-tesla-fleet-api`'s sibling. The tariff object carries no timezone; the caller must pass `opts.timeZone` (an IANA string, e.g. from `site_info.installation_time_zone`) - the resolver has no other way to get site-local wall-clock parts from a JS `Date`, which is always a UTC instant. It resolves one calendar day at a time (`dayPeriods`), not a multi-day minute-of-week span, because a `tou_periods` entry's `fromDayOfWeek..toDayOfWeek` means "this daily time window recurs on each of these weekdays", not "one span from this day+time to that day+time"; `nextChange`/`upcoming` re-resolve the season fresh on each day so a horizon crossing a season boundary re-prices correctly, and a gap between scheduled periods reports the gap (not the next period found arbitrarily far out). Converting a resolved wall-clock boundary back to a `Date` goes through `wallClockToUtcMillis` (iterative, since the zone's UTC offset at the target instant is what's being solved for) rather than adding elapsed real minutes to `now` - the two diverge across a DST transition. The reverse direction - "what calendar day/weekday is N minutes of wall-clock time from now" (used to walk forward day by day, or peek at tomorrow) - must go through `wallClockAt` (pure calendar arithmetic, no `Intl` round trip), never `now.getTime() + minutes*60000`; the latter silently lands on the wrong calendar day on a DST fall-back day (25 real hours) or spring-forward day (23). Buy and sell resolve independently via `scheduleAt`, which reports a next-boundary even for a grid currently sitting in a gap (e.g. a sell/export window not open yet) - `nextChange` and `currentStart` must fold in the sell side's boundary/start (the earlier next-change, the later start) even when sell has no period active right now, or a differently-scheduled sell tariff gets silently ignored. `TariffContentV2` (`src/types/site_info.ts`) types `seasons` as `Record` (an object keyed by season name, not an array) - that mismatch was a real bug fixed alongside the resolver; don't regress it back to an array shape. ## Maintaining this file diff --git a/src/tariff.ts b/src/tariff.ts index 886195f..3c8e373 100644 --- a/src/tariff.ts +++ b/src/tariff.ts @@ -45,12 +45,15 @@ function formatParts(date: Date, timeZone: string): Record { return out; } +/** Tesla weekday (Mon=0..Sun=6) for a calendar date, via JS Date.getUTCDay() (Sun=0..Sat=6). */ +function dowFromDate(year: number, month: number, day: number): number { + const jsDow = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + return (jsDow + 6) % 7; +} + function getWallClock(now: Date, timeZone: string): WallClock { const { year, month, day, hour, minute } = formatParts(now, timeZone); - // Weekday derived from the site-local date (not name matching): Tesla is Mon=0..Sun=6, JS Date.getUTCDay() is Sun=0..Sat=6. - const jsDow = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); - const dow = (jsDow + 6) % 7; - return { year, month, day, dow, minuteOfDay: hour * 60 + minute }; + return { year, month, day, dow: dowFromDate(year, month, day), minuteOfDay: hour * 60 + minute }; } /** @@ -72,10 +75,19 @@ function wallClockToUtcMillis(year: number, month: number, day: number, hour: nu return guess; } -/** Adds `deltaMinutes` of wall-clock time (not elapsed real time) to a wall-clock reading. */ -function shiftWallClock(wallClock: WallClock, deltaMinutes: number): { year: number; month: number; day: number; hour: number; minute: number } { +/** + * Adds `deltaMinutes` of wall-clock time (not elapsed real time) to a wall-clock reading, staying + * in pure calendar arithmetic throughout - no `timeZone`/`Intl` round trip, so this is safe to use + * for "what day/weekday is N minutes from now" even across a DST transition, where a real-time-ms + * shift and a wall-clock-minutes shift land on different calendar days. + */ +function wallClockAt(wallClock: WallClock, deltaMinutes: number): WallClock { const naive = new Date(Date.UTC(wallClock.year, wallClock.month - 1, wallClock.day, 0, wallClock.minuteOfDay + deltaMinutes)); - return { year: naive.getUTCFullYear(), month: naive.getUTCMonth() + 1, day: naive.getUTCDate(), hour: naive.getUTCHours(), minute: naive.getUTCMinutes() }; + const year = naive.getUTCFullYear(); + const month = naive.getUTCMonth() + 1; + const day = naive.getUTCDate(); + const minuteOfDay = naive.getUTCHours() * 60 + naive.getUTCMinutes(); + return { year, month, day, dow: dowFromDate(year, month, day), minuteOfDay }; } function dowInRange(d: number, fromDow: number, toDow: number): boolean { @@ -146,61 +158,65 @@ function lookupPrice(charges: EnergyCharges | undefined, seasonName: string, per const EMPTY_RATE: TariffRate = { price: null, periodName: null, seasonName: null }; -type PointResolution = { - rate: TariffRate; - currentStartGM: number; - nextChangeGM: number; +type Schedule = { + seasonName: string; + /** The segment active right now, or null if `wallClock` falls in a gap between scheduled periods. */ + current: DaySegment | null; + /** + * The next minute-of-day boundary (GM units relative to `wallClock`) at which this grid's rate + * changes - present whether or not a period is active right now, so a caller can still learn + * when a currently-empty grid (a gap) is about to start a period. Null only when neither today + * nor tomorrow has anything scheduled at all. + */ + nextChangeGM: number | null; }; /** - * Resolves the rate active at `wallClock` (site-local "now"), and the next minute-of-week - * boundary at which it changes - capped at the active period's own end so a sparse tariff - * (a real gap between scheduled periods) reports the gap, not the next period found arbitrarily - * far in the future. Both values are in GM units: wall-clock minutes relative to `wallClock`. + * Resolves one grid's (buy's, or sell's) state at `wallClock`: the active period if any, and the + * next boundary regardless of whether a period is currently active - a sparse tariff's gap still + * has a "next change" (the gap's own end), just like an active period does (its own end). */ -function resolveAt(seasons: Record | undefined, charges: EnergyCharges | undefined, wallClock: WallClock, now: Date, timeZone: string): PointResolution | null { +function scheduleAt(seasons: Record | undefined, wallClock: WallClock): Schedule | null { const matched = findSeason(seasons, wallClock.month, wallClock.day); if (!matched) return null; - const todaySegs = dayPeriods(matched.season.tou_periods!, wallClock.dow); - const current = todaySegs.find((s) => s.startMin <= wallClock.minuteOfDay && wallClock.minuteOfDay < s.endMin); - if (!current) return null; + const todaySegs = dayPeriods(matched.season.tou_periods!, wallClock.dow); + const current = todaySegs.find((s) => s.startMin <= wallClock.minuteOfDay && wallClock.minuteOfDay < s.endMin) ?? null; const laterTodayStarts = todaySegs.filter((s) => s.startMin > wallClock.minuteOfDay).map((s) => s.startMin); - let nextMin = current.endMin; + + let nextMin: number | null = current ? current.endMin : null; if (laterTodayStarts.length > 0) { - nextMin = Math.min(nextMin, Math.min(...laterTodayStarts)); - } else if (current.endMin >= MINUTES_PER_DAY) { - // The active period runs up to midnight with nothing else scheduled today - check whether - // it's the same period continuing tomorrow (a midnight-crossing window) before treating - // midnight itself as the boundary. - const tomorrowDate = new Date(now.getTime() + (MINUTES_PER_DAY - wallClock.minuteOfDay) * 60_000); - const tomorrowWallClock = getWallClock(tomorrowDate, timeZone); + nextMin = nextMin === null ? Math.min(...laterTodayStarts) : Math.min(nextMin, Math.min(...laterTodayStarts)); + } else if (nextMin === null || nextMin >= MINUTES_PER_DAY) { + // Either in a gap with nothing left today, or the active period runs up to midnight - + // check tomorrow before treating midnight itself as the boundary. + const tomorrowWallClock = wallClockAt(wallClock, MINUTES_PER_DAY - wallClock.minuteOfDay); const tomorrowMatched = findSeason(seasons, tomorrowWallClock.month, tomorrowWallClock.day); const tomorrowSegs = tomorrowMatched ? dayPeriods(tomorrowMatched.season.tou_periods!, tomorrowWallClock.dow) : []; - const continuation = tomorrowMatched?.name === matched.name ? tomorrowSegs.find((s) => s.startMin === 0 && s.periodName === current.periodName) : undefined; + const continuation = current && tomorrowMatched?.name === matched.name ? tomorrowSegs.find((s) => s.startMin === 0 && s.periodName === current.periodName) : undefined; if (continuation) { nextMin = MINUTES_PER_DAY + continuation.endMin; } else if (tomorrowSegs.length > 0) { - nextMin = MINUTES_PER_DAY + Math.min(...tomorrowSegs.map((s) => s.startMin)); + const tomorrowFirst = MINUTES_PER_DAY + Math.min(...tomorrowSegs.map((s) => s.startMin)); + nextMin = nextMin === null ? tomorrowFirst : Math.min(nextMin, tomorrowFirst); } } return { - rate: { price: lookupPrice(charges, matched.name, current.periodName), periodName: current.periodName, seasonName: matched.name }, - currentStartGM: current.trueStartMin - wallClock.minuteOfDay, - nextChangeGM: nextMin - wallClock.minuteOfDay, + seasonName: matched.name, + current, + nextChangeGM: nextMin === null ? null : nextMin - wallClock.minuteOfDay, }; } type ScheduleSegment = { startGM: number; endGM: number; periodName: string; seasonName: string }; -/** Walks one calendar day at a time across the horizon, re-resolving the season fresh on each day so a horizon that crosses a season boundary re-prices and re-labels correctly. */ -function collectSegments(seasons: Record | undefined, wallClock: WallClock, now: Date, timeZone: string, horizonMinutes: number): ScheduleSegment[] { +/** Walks one calendar day at a time across the horizon, re-resolving the season fresh on each day (via pure wall-clock arithmetic, not elapsed-ms/DST-sensitive arithmetic) so a horizon crossing a season boundary re-prices and re-labels correctly. */ +function collectSegments(seasons: Record | undefined, wallClock: WallClock, horizonMinutes: number): ScheduleSegment[] { const segments: ScheduleSegment[] = []; let dayStartGM = -wallClock.minuteOfDay; while (dayStartGM < horizonMinutes) { - const dayDate = new Date(now.getTime() + dayStartGM * 60_000); - const dayWallClock = getWallClock(dayDate, timeZone); + const dayWallClock = wallClockAt(wallClock, dayStartGM); const matched = findSeason(seasons, dayWallClock.month, dayWallClock.day); if (matched) { for (const seg of dayPeriods(matched.season.tou_periods!, dayWallClock.dow)) { @@ -232,14 +248,12 @@ function buildUpcoming( sellSeasons: Record | undefined, sellCharges: EnergyCharges | undefined, hasSell: boolean, - now: Date, wallClock: WallClock, - timeZone: string, horizonMinutes: number, toDate: (gm: number) => Date, ): TariffPeriod[] { - const buySegs = collectSegments(buySeasons, wallClock, now, timeZone, horizonMinutes); - const sellSegs = hasSell ? collectSegments(sellSeasons, wallClock, now, timeZone, horizonMinutes) : []; + const buySegs = collectSegments(buySeasons, wallClock, horizonMinutes); + const sellSegs = hasSell ? collectSegments(sellSeasons, wallClock, horizonMinutes) : []; const boundaries = new Set([0, horizonMinutes]); const addBoundary = (gm: number): void => { @@ -283,33 +297,47 @@ export function getTariffPeriods(tariff: TariffContentV2, now: Date, opts?: { ti const timeZone = opts.timeZone; const wallClock = getWallClock(now, timeZone); - const buy = resolveAt(tariff.seasons, tariff.energy_charges, wallClock, now, timeZone); - if (!buy) return null; + const buySchedule = scheduleAt(tariff.seasons, wallClock); + if (!buySchedule?.current) return null; const sellSeasons = tariff.sell_tariff?.seasons ?? tariff.seasons; - const sell = tariff.sell_tariff ? resolveAt(sellSeasons, tariff.sell_tariff.energy_charges, wallClock, now, timeZone) : null; + const sellSchedule = tariff.sell_tariff ? scheduleAt(sellSeasons, wallClock) : null; + + const buyRate: TariffRate = { price: lookupPrice(tariff.energy_charges, buySchedule.seasonName, buySchedule.current.periodName), periodName: buySchedule.current.periodName, seasonName: buySchedule.seasonName }; + const sellRate: TariffRate = sellSchedule?.current + ? { price: lookupPrice(tariff.sell_tariff!.energy_charges, sellSchedule.seasonName, sellSchedule.current.periodName), periodName: sellSchedule.current.periodName, seasonName: sellSchedule.seasonName } + : EMPTY_RATE; // Boundaries are wall-clock minute deltas, so converting one back to an instant means // re-deriving the target wall-clock label and reconverting *that*, not adding elapsed // real minutes to `now` - the two differ across a DST transition. const toDate = (gm: number): Date => { - const target = shiftWallClock(wallClock, gm); - return new Date(wallClockToUtcMillis(target.year, target.month, target.day, target.hour, target.minute, timeZone)); + const target = wallClockAt(wallClock, gm); + const hour = Math.floor(target.minuteOfDay / 60); + const minute = target.minuteOfDay % 60; + return new Date(wallClockToUtcMillis(target.year, target.month, target.day, hour, minute, timeZone)); }; - const nextChangeGM = sell ? Math.min(buy.nextChangeGM, sell.nextChangeGM) : buy.nextChangeGM; + // `scheduleAt` reports a next-boundary GM even for a grid that's currently in a gap (e.g. a + // sell/export window not open yet), so combining via the sell side's own next boundary - not + // skipping it just because sell has no active period right now - catches "sell starts" too. + const nextChangeGM = sellSchedule?.nextChangeGM != null ? Math.min(buySchedule.nextChangeGM!, sellSchedule.nextChangeGM) : buySchedule.nextChangeGM!; + + // When buy and sell schedules differ, the interval the returned pair is valid for only began + // once BOTH sides' current periods had started - the later of the two starts, not just buy's. + const currentStartGM = sellSchedule?.current ? Math.max(buySchedule.current.trueStartMin - wallClock.minuteOfDay, sellSchedule.current.trueStartMin - wallClock.minuteOfDay) : buySchedule.current.trueStartMin - wallClock.minuteOfDay; const resolution: TariffResolution = { - buy: buy.rate, - sell: sell ? sell.rate : EMPTY_RATE, - currentStart: toDate(buy.currentStartGM), + buy: buyRate, + sell: sellRate, + currentStart: toDate(currentStartGM), nextChange: toDate(nextChangeGM), currency: typeof tariff.currency === "string" ? tariff.currency : null, upcoming: null, }; if (opts.horizonHours) { - resolution.upcoming = buildUpcoming(tariff.seasons, tariff.energy_charges, sellSeasons, tariff.sell_tariff?.energy_charges, !!tariff.sell_tariff, now, wallClock, timeZone, opts.horizonHours * 60, toDate); + resolution.upcoming = buildUpcoming(tariff.seasons, tariff.energy_charges, sellSeasons, tariff.sell_tariff?.energy_charges, !!tariff.sell_tariff, wallClock, opts.horizonHours * 60, toDate); } return resolution; diff --git a/test/tariff.test.ts b/test/tariff.test.ts index 87ada7f..de076a0 100644 --- a/test/tariff.test.ts +++ b/test/tariff.test.ts @@ -359,4 +359,89 @@ describe("getTariffPeriods: DST-observing timezone", () => { expect(result!.buy.periodName).toBe("NIGHT"); expect(result!.nextChange).toEqual(new Date("2026-03-08T07:00:00Z")); // 2026-03-08 03:00 EDT }); + + it("derives upcoming's day-2 boundary from wall-clock arithmetic, not a 24h-per-day assumption, across a fall-back transition", () => { + // America/New_York: clocks fall back 02:00 -> 01:00 on 2026-11-01 (a Sunday), so that day + // has 25 real hours. A period keyed only off day-of-week must still flip from Sunday's to + // Monday's at the true local midnight, not 24 elapsed hours after the start of Sunday. + const FALL_BACK_TARIFF: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Fall Back", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { SUNDAY_ONLY: 0.9, OTHER: 0.1 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + SUNDAY_ONLY: { periods: [{ fromDayOfWeek: 6, toDayOfWeek: 6, toHour: 24 }] }, + OTHER: { periods: [{ fromDayOfWeek: 0, toDayOfWeek: 5, toHour: 24 }] }, + }, + }, + }, + }; + const now = new Date("2026-11-01T04:30:00Z"); // 2026-11-01 00:30 EDT, a Sunday + const result = getTariffPeriods(FALL_BACK_TARIFF, now, { timeZone: "America/New_York", horizonHours: 30 }); + expect(result!.buy).toEqual({ price: 0.9, periodName: "SUNDAY_ONLY", seasonName: "ALL" }); + const upcoming = result!.upcoming!; + const monday = upcoming.find((p) => p.buy.periodName === "OTHER"); + expect(monday).toBeDefined(); + expect(monday!.start).toEqual(new Date("2026-11-02T05:00:00Z")); // true Monday 00:00 EST (post-fallback, UTC-5) + expect(upcoming.every((p) => p.buy.periodName === "SUNDAY_ONLY" || p.start.getTime() >= monday!.start.getTime())).toBe(true); + }); +}); + +describe("getTariffPeriods: buy/sell schedules differ", () => { + // All-day buy rate; sell only opens for a 17:00-19:00 export window - a common "feed-in" shape. + const SELL_WINDOW_TARIFF: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Sell Window", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { ALL_DAY: 0.3 } } }, + seasons: { ALL: { fromMonth: 1, fromDay: 1, toMonth: 12, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } } }, + sell_tariff: { + energy_charges: { ALL: { rates: { ALL_DAY: 0, EXPORT: 0.5 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 17 }] }, + EXPORT: { periods: [{ toDayOfWeek: 6, fromHour: 17, toHour: 19 }] }, + // Nothing scheduled 19:00-24:00: sell is in a gap overnight. + }, + }, + }, + }, + }; + + it("tracks the next sell boundary even while sell is currently in a gap", () => { + const now = brisbane(2026, 7, 23, 16, 30); + const result = getTariffPeriods(SELL_WINDOW_TARIFF, now, { timeZone: TZ }); + expect(result!.buy.periodName).toBe("ALL_DAY"); + expect(result!.sell.periodName).toBe("ALL_DAY"); + // Sell's own EXPORT window starts at 17:00, well before buy's midnight boundary. + expect(result!.nextChange).toEqual(brisbane(2026, 7, 23, 17, 0)); + }); + + it("uses the later of buy/sell currentStart when sell started after buy", () => { + const now = brisbane(2026, 7, 23, 18, 0); + const result = getTariffPeriods(SELL_WINDOW_TARIFF, now, { timeZone: TZ }); + expect(result!.sell).toEqual({ price: 0.5, periodName: "EXPORT", seasonName: "ALL" }); + // Buy's own period started at local midnight, but the returned pair is only valid from + // when sell's EXPORT window opened at 17:00. + expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 17, 0)); + }); }); From 4a596ec1750b400b38c9c8810a2bbde2cb027875 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 23 Jul 2026 21:56:18 +1000 Subject: [PATCH 5/6] Fold sell gap starts into currentStart; audit the full buy/sell interaction Address round-4 finding: when sell is currently in a gap after a period ended (e.g. an export window that already closed), currentStart now reports the gap's own start (the previous period's end), not buy's period start - the returned empty sell rate was previously backdated to whenever buy's period began. scheduleAt now computes sinceGM symmetrically with nextChangeGM: both are defined whether or not a period is currently active (bounded to one day of lookback/lookahead), and both are nullable so a grid with no data in that window at all (never resolves a season, or resolves one with zero periods) is excluded from the combination instead of forcing a bogus default. Exhaustively covers the buy/sell interaction with tests: sell started after buy, buy started after sell, sell gap before its first period, sell gap after its last period, buy's own boundary winning the nextChange min, a sell season that doesn't cover today at all, a sell season with zero periods ever, and sell_tariff omitting its own seasons (falling back to buy's geometry). --- AGENTS.md | 2 +- src/tariff.ts | 37 +++++++++- test/tariff.test.ts | 169 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1bc20ca..aa948c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - **`resp.requestUuid` must never gate acceptance of a `SessionInfo` reply - only cross-check it when the vehicle actually populated it.** Real VCSEC hardware leaves it empty (protocol.md: "due to memory constraints, the `request_uuid` field is typically not populated in replies from VCSEC"); an equality-or-reject guard here silently breaks every VCSEC command (`door_lock`, `door_unlock`, `actuate_truck`, `charge_port_door_open/close`, `remote_start_drive`) against real vehicles even though `test/helpers/fakevehicle.ts` (which always echoes it) can't catch the regression. The `session_info_tag` HMAC - which binds the *sent* uuid as `TAG_CHALLENGE` - is the actual authentication; `FakeVehicle.omitRequestUuidLikeRealVcsec()` reproduces the real-hardware shape in tests. - For `DOMAIN_VEHICLE_SECURITY`, `Commands.dispatch` holds the domain's session lock across the *entire* dispatch (handshake + build + send + retry), not just message-build - VCSEC requires messages to arrive in strict counter order and the spec warns against simultaneous requests to it at all, unlike Infotainment (sliding window), which keeps the narrower build-only lock. - `Commands`'s `#privateKey`/`#publicKey` are native private class fields (not `protected`/TS-only `private`) - the raw signing key must not be reachable off the instance at all (e.g. via `JSON.stringify` or a structured log), not merely inaccessible to outside *code*. Tests that need to observe key derivation do so through what actually goes out on the wire (a captured handshake message), not by reading the field. -- `src/tariff.ts`'s `getTariffPeriods(tariff, now, opts)` is a pure Tariff V2 rate resolver mirroring `python-tesla-fleet-api`'s sibling. The tariff object carries no timezone; the caller must pass `opts.timeZone` (an IANA string, e.g. from `site_info.installation_time_zone`) - the resolver has no other way to get site-local wall-clock parts from a JS `Date`, which is always a UTC instant. It resolves one calendar day at a time (`dayPeriods`), not a multi-day minute-of-week span, because a `tou_periods` entry's `fromDayOfWeek..toDayOfWeek` means "this daily time window recurs on each of these weekdays", not "one span from this day+time to that day+time"; `nextChange`/`upcoming` re-resolve the season fresh on each day so a horizon crossing a season boundary re-prices correctly, and a gap between scheduled periods reports the gap (not the next period found arbitrarily far out). Converting a resolved wall-clock boundary back to a `Date` goes through `wallClockToUtcMillis` (iterative, since the zone's UTC offset at the target instant is what's being solved for) rather than adding elapsed real minutes to `now` - the two diverge across a DST transition. The reverse direction - "what calendar day/weekday is N minutes of wall-clock time from now" (used to walk forward day by day, or peek at tomorrow) - must go through `wallClockAt` (pure calendar arithmetic, no `Intl` round trip), never `now.getTime() + minutes*60000`; the latter silently lands on the wrong calendar day on a DST fall-back day (25 real hours) or spring-forward day (23). Buy and sell resolve independently via `scheduleAt`, which reports a next-boundary even for a grid currently sitting in a gap (e.g. a sell/export window not open yet) - `nextChange` and `currentStart` must fold in the sell side's boundary/start (the earlier next-change, the later start) even when sell has no period active right now, or a differently-scheduled sell tariff gets silently ignored. `TariffContentV2` (`src/types/site_info.ts`) types `seasons` as `Record` (an object keyed by season name, not an array) - that mismatch was a real bug fixed alongside the resolver; don't regress it back to an array shape. +- `src/tariff.ts`'s `getTariffPeriods(tariff, now, opts)` is a pure Tariff V2 rate resolver mirroring `python-tesla-fleet-api`'s sibling. The tariff object carries no timezone; the caller must pass `opts.timeZone` (an IANA string, e.g. from `site_info.installation_time_zone`) - the resolver has no other way to get site-local wall-clock parts from a JS `Date`, which is always a UTC instant. It resolves one calendar day at a time (`dayPeriods`), not a multi-day minute-of-week span, because a `tou_periods` entry's `fromDayOfWeek..toDayOfWeek` means "this daily time window recurs on each of these weekdays", not "one span from this day+time to that day+time"; `nextChange`/`upcoming` re-resolve the season fresh on each day so a horizon crossing a season boundary re-prices correctly, and a gap between scheduled periods reports the gap (not the next period found arbitrarily far out). Converting a resolved wall-clock boundary back to a `Date` goes through `wallClockToUtcMillis` (iterative, since the zone's UTC offset at the target instant is what's being solved for) rather than adding elapsed real minutes to `now` - the two diverge across a DST transition. The reverse direction - "what calendar day/weekday is N minutes of wall-clock time from now" (used to walk forward day by day, or peek at tomorrow) - must go through `wallClockAt` (pure calendar arithmetic, no `Intl` round trip), never `now.getTime() + minutes*60000`; the latter silently lands on the wrong calendar day on a DST fall-back day (25 real hours) or spring-forward day (23). Buy and sell resolve independently via `scheduleAt`, which reports both a `nextChangeGM` and a `sinceGM` even for a grid currently sitting in a gap (e.g. a sell/export window not open yet, or already closed) - `nextChange` (earlier of the two) and `currentStart` (later of the two) must fold in the sell side even when sell has no period active right now, or a differently-scheduled sell tariff gets silently ignored or backdated. Both are nullable (bounded to one day of lookahead/lookback, mirroring each other) and must be excluded from the combination via `!= null`, not treated as `0`, when a grid has nothing scheduled in that window at all. `TariffContentV2` (`src/types/site_info.ts`) types `seasons` as `Record` (an object keyed by season name, not an array) - that mismatch was a real bug fixed alongside the resolver; don't regress it back to an array shape. ## Maintaining this file diff --git a/src/tariff.ts b/src/tariff.ts index 3c8e373..c878510 100644 --- a/src/tariff.ts +++ b/src/tariff.ts @@ -169,12 +169,22 @@ type Schedule = { * nor tomorrow has anything scheduled at all. */ nextChangeGM: number | null; + /** + * When the *current state* began (GM units relative to `wallClock`) - the active period's true + * start if one is active, or the moment the current gap began (the previous period's end) + * otherwise, so a currently-empty grid still reports an accurate "valid since". Bounded to at + * most one day of lookback (mirrors `nextChangeGM`'s one-day lookahead bound): null only when + * neither today nor yesterday has anything scheduled at all (this grid never constrains the + * combined `currentStart`). + */ + sinceGM: number | null; }; /** * Resolves one grid's (buy's, or sell's) state at `wallClock`: the active period if any, and the * next boundary regardless of whether a period is currently active - a sparse tariff's gap still - * has a "next change" (the gap's own end), just like an active period does (its own end). + * has a "next change" (the gap's own end) and a "since" (the gap's own start), just like an active + * period does (its own end and start). */ function scheduleAt(seasons: Record | undefined, wallClock: WallClock): Schedule | null { const matched = findSeason(seasons, wallClock.month, wallClock.day); @@ -202,10 +212,28 @@ function scheduleAt(seasons: Record | undefined, wallClock: Wall } } + let sinceMin: number | null; + if (current) { + sinceMin = current.trueStartMin; + } else { + const earlierTodayEnds = todaySegs.filter((s) => s.endMin <= wallClock.minuteOfDay).map((s) => s.endMin); + if (earlierTodayEnds.length > 0) { + sinceMin = Math.max(...earlierTodayEnds); + } else { + // Nothing has ended yet today either - the gap may have started yesterday (or earlier). + // Peek one day back, same one-day bound as the forward "tomorrow" check above. + const yesterdayWallClock = wallClockAt(wallClock, -MINUTES_PER_DAY); + const yesterdayMatched = findSeason(seasons, yesterdayWallClock.month, yesterdayWallClock.day); + const yesterdaySegs = yesterdayMatched ? dayPeriods(yesterdayMatched.season.tou_periods!, yesterdayWallClock.dow) : []; + sinceMin = yesterdaySegs.length > 0 ? Math.max(...yesterdaySegs.map((s) => s.endMin)) - MINUTES_PER_DAY : null; + } + } + return { seasonName: matched.name, current, nextChangeGM: nextMin === null ? null : nextMin - wallClock.minuteOfDay, + sinceGM: sinceMin === null ? null : sinceMin - wallClock.minuteOfDay, }; } @@ -324,8 +352,11 @@ export function getTariffPeriods(tariff: TariffContentV2, now: Date, opts?: { ti const nextChangeGM = sellSchedule?.nextChangeGM != null ? Math.min(buySchedule.nextChangeGM!, sellSchedule.nextChangeGM) : buySchedule.nextChangeGM!; // When buy and sell schedules differ, the interval the returned pair is valid for only began - // once BOTH sides' current periods had started - the later of the two starts, not just buy's. - const currentStartGM = sellSchedule?.current ? Math.max(buySchedule.current.trueStartMin - wallClock.minuteOfDay, sellSchedule.current.trueStartMin - wallClock.minuteOfDay) : buySchedule.current.trueStartMin - wallClock.minuteOfDay; + // once BOTH sides' current *states* began - the later of the two, not just buy's. `sinceGM` + // covers sell being in a gap too (e.g. an export window that already closed), not only sell + // having its own active period. `buySchedule.sinceGM` is always non-null: buy is guaranteed to + // have an active `current` period at this point (checked above), and that branch always sets it. + const currentStartGM = sellSchedule?.sinceGM != null ? Math.max(buySchedule.sinceGM!, sellSchedule.sinceGM) : buySchedule.sinceGM!; const resolution: TariffResolution = { buy: buyRate, diff --git a/test/tariff.test.ts b/test/tariff.test.ts index de076a0..69c4c26 100644 --- a/test/tariff.test.ts +++ b/test/tariff.test.ts @@ -444,4 +444,173 @@ describe("getTariffPeriods: buy/sell schedules differ", () => { // when sell's EXPORT window opened at 17:00. expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 17, 0)); }); + + it("folds the sell gap's own start into currentStart after a sell period has already ended", () => { + const now = brisbane(2026, 7, 23, 20, 0); // after EXPORT (17:00-19:00) closed + const result = getTariffPeriods(SELL_WINDOW_TARIFF, now, { timeZone: TZ }); + expect(result!.sell).toEqual({ price: null, periodName: null, seasonName: null }); + // The empty sell rate has only been valid since EXPORT closed at 19:00, not since buy's + // own midnight start. + expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 19, 0)); + // Sell's own gap ends when tomorrow's ALL_DAY reopens at midnight - sooner than buy's. + expect(result!.nextChange).toEqual(brisbane(2026, 7, 24, 0, 0)); + }); + + it("uses buy's later start when buy started after sell (the reverse direction)", () => { + const tariff: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Buy Later", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { NIGHT: 0.1, DAY: 0.2 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + NIGHT: { periods: [{ toDayOfWeek: 6, toHour: 12 }] }, + DAY: { periods: [{ toDayOfWeek: 6, fromHour: 12, toHour: 24 }] }, + }, + }, + }, + sell_tariff: { + energy_charges: { ALL: { rates: { ALL_DAY: 0.5 } } }, + seasons: { ALL: { fromMonth: 1, fromDay: 1, toMonth: 12, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } } }, + }, + }; + const now = brisbane(2026, 7, 23, 13, 0); // buy's DAY started at 12:00; sell's ALL_DAY started at midnight + const result = getTariffPeriods(tariff, now, { timeZone: TZ }); + expect(result!.buy.periodName).toBe("DAY"); + expect(result!.sell.periodName).toBe("ALL_DAY"); + expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 12, 0)); + }); + + it("lets buy's own boundary win the nextChange min when it's sooner than sell's", () => { + const tariff: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Two Sided", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { MORNING: 0.2, EVENING: 0.4 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + MORNING: { periods: [{ toDayOfWeek: 6, toHour: 12 }] }, + EVENING: { periods: [{ toDayOfWeek: 6, fromHour: 12, toHour: 24 }] }, + }, + }, + }, + sell_tariff: { + energy_charges: { ALL: { rates: { DAY: 0.5, NIGHT: 0 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + DAY: { periods: [{ toDayOfWeek: 6, toHour: 18 }] }, + NIGHT: { periods: [{ toDayOfWeek: 6, fromHour: 18, toHour: 24 }] }, + }, + }, + }, + }, + }; + const now = brisbane(2026, 7, 23, 10, 0); + const result = getTariffPeriods(tariff, now, { timeZone: TZ }); + // buy's next change (EVENING at 12:00) is sooner than sell's (NIGHT at 18:00). + expect(result!.nextChange).toEqual(brisbane(2026, 7, 23, 12, 0)); + }); + + it("ignores a sell schedule whose season doesn't cover today at all", () => { + const tariff: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Sell Season Mismatch", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { ALL_DAY: 0.3 } } }, + seasons: { ALL: { fromMonth: 1, fromDay: 1, toMonth: 12, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } } }, + sell_tariff: { + energy_charges: { JanOnly: { rates: { ALL_DAY: 0.6 } } }, + // Sell's own seasons only cover January - "now" (July) matches no sell season at all. + seasons: { JanOnly: { fromMonth: 1, fromDay: 1, toMonth: 1, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } } }, + }, + }; + const now = brisbane(2026, 7, 23, 12, 0); + const result = getTariffPeriods(tariff, now, { timeZone: TZ }); + expect(result!.buy).toEqual({ price: 0.3, periodName: "ALL_DAY", seasonName: "ALL" }); + expect(result!.sell).toEqual({ price: null, periodName: null, seasonName: null }); + // Sell contributes nothing (no matching season at all) - driven purely by buy. + expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 0, 0)); + }); + + it("ignores a sell schedule with no periods scheduled at all (present season, empty tou_periods)", () => { + const tariff: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Sell Never Scheduled", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { MORNING: 0.2, EVENING: 0.4 } } }, + seasons: { + ALL: { + fromMonth: 1, + fromDay: 1, + toMonth: 12, + toDay: 31, + tou_periods: { + MORNING: { periods: [{ toDayOfWeek: 6, toHour: 12 }] }, + EVENING: { periods: [{ toDayOfWeek: 6, fromHour: 12, toHour: 24 }] }, + }, + }, + }, + sell_tariff: { + energy_charges: {}, + // Season matches every day, but defines no periods at all - sell never has a rate. + seasons: { ALL: { fromMonth: 1, fromDay: 1, toMonth: 12, toDay: 31, tou_periods: {} } }, + }, + }; + const now = brisbane(2026, 7, 23, 10, 0); + const result = getTariffPeriods(tariff, now, { timeZone: TZ }); + expect(result!.sell).toEqual({ price: null, periodName: null, seasonName: null }); + // Sell has no data at all (today or yesterday) to bound currentStart/nextChange with, so + // both are driven purely by buy: MORNING started at midnight, EVENING starts at noon. + expect(result!.currentStart).toEqual(brisbane(2026, 7, 23, 0, 0)); + expect(result!.nextChange).toEqual(brisbane(2026, 7, 23, 12, 0)); + }); + + it("falls back to the buy seasons/geometry when sell_tariff omits its own seasons", () => { + const tariff: TariffContentV2 = { + version: 1, + utility: "Test", + code: "TEST", + name: "Sell Reuses Buy Geometry", + currency: "USD", + daily_charges: [], + demand_charges: {}, + energy_charges: { ALL: { rates: { ALL_DAY: 0.3 } } }, + seasons: { ALL: { fromMonth: 1, fromDay: 1, toMonth: 12, toDay: 31, tou_periods: { ALL_DAY: { periods: [{ toDayOfWeek: 6, toHour: 24 }] } } } }, + sell_tariff: { energy_charges: { ALL: { rates: { ALL_DAY: 0.6 } } } }, + }; + const now = brisbane(2026, 7, 23, 12, 0); + const result = getTariffPeriods(tariff, now, { timeZone: TZ }); + expect(result!.sell).toEqual({ price: 0.6, periodName: "ALL_DAY", seasonName: "ALL" }); + }); }); From 97682e3057528e70b9dd0914d93ec3e5ec3bab98 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 23 Jul 2026 22:05:08 +1000 Subject: [PATCH 6/6] Mark SellTariff.seasons optional to match the runtime fallback The resolver already falls back to the buy tariff's own seasons/geometry when sell_tariff omits seasons (tariff.sell_tariff?.seasons ?? tariff.seasons), and a test exercises exactly that shape - but the exported type still required seasons, so a valid payload failed typechecking for consumers. --- src/types/site_info.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/site_info.ts b/src/types/site_info.ts index 056a072..84842af 100644 --- a/src/types/site_info.ts +++ b/src/types/site_info.ts @@ -137,7 +137,7 @@ export type SellTariff = { daily_charges?: Record[]; demand_charges?: Record; energy_charges: EnergyCharges; - seasons: Record; + seasons?: Record; }; export type TariffContentV2 = {