diff --git a/AGENTS.md b/AGENTS.md index 0d23b0d..aa948c0 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. 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/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..c878510 --- /dev/null +++ b/src/tariff.ts @@ -0,0 +1,375 @@ +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; + +type WallClock = { year: number; month: number; day: number; dow: number; minuteOfDay: 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 formatParts(date: Date, timeZone: string): Record { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const out: Record = {}; + for (const p of parts) out[p.type] = Number(p.value); + 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); + return { year, month, day, dow: dowFromDate(year, month, day), 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, 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)); + 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 { + 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; +} + +/** + * 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 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 segments; +} + +function lookupPrice(charges: EnergyCharges | undefined, seasonName: string, periodName: string): number | null { + if (!charges) return null; + // 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; +} + +const EMPTY_RATE: TariffRate = { price: null, periodName: null, seasonName: null }; + +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; + /** + * 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) 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); + 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) ?? null; + const laterTodayStarts = todaySegs.filter((s) => s.startMin > wallClock.minuteOfDay).map((s) => s.startMin); + + let nextMin: number | null = current ? current.endMin : null; + if (laterTodayStarts.length > 0) { + 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 = 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) { + const tomorrowFirst = MINUTES_PER_DAY + Math.min(...tomorrowSegs.map((s) => s.startMin)); + nextMin = nextMin === null ? tomorrowFirst : Math.min(nextMin, tomorrowFirst); + } + } + + 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, + }; +} + +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 (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 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)) { + 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; + } + 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, + wallClock: WallClock, + horizonMinutes: number, + toDate: (gm: number) => Date, +): TariffPeriod[] { + const buySegs = collectSegments(buySeasons, wallClock, horizonMinutes); + const sellSegs = hasSell ? collectSegments(sellSeasons, wallClock, 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 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: 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; +} + +/** + * 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 timeZone = opts.timeZone; + + const wallClock = getWallClock(now, timeZone); + const buySchedule = scheduleAt(tariff.seasons, wallClock); + if (!buySchedule?.current) return null; + + const sellSeasons = tariff.sell_tariff?.seasons ?? tariff.seasons; + 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 = 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)); + }; + + // `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 *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, + 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, wallClock, opts.horizonHours * 60, toDate); + } + + return resolution; +} diff --git a/src/types/site_info.ts b/src/types/site_info.ts index 88f02a8..84842af 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/sample-tariff.ts b/test/fixtures/sample-tariff.ts new file mode 100644 index 0000000..7a8b77b --- /dev/null +++ b/test/fixtures/sample-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 "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", + 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..69c4c26 --- /dev/null +++ b/test/tariff.test.ts @@ -0,0 +1,616 @@ +import { describe, expect, it } from "vitest"; +import { getTariffPeriods } from "../src/tariff.js"; +import { TariffContentV2 } from "../src/types/site_info.js"; +import { SAMPLE_TARIFF } from "./fixtures/sample-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 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(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)); + 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(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(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(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)); + }); + + it("wraps forward past midnight into PERIOD_00_00", () => { + const now = brisbane(2026, 7, 24, 0, 10); + 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)); + }); + + it("builds an upcoming schedule across the requested horizon", () => { + const now = brisbane(2026, 7, 23, 7, 15); + 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); + 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(SAMPLE_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(); + }); + + 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 + }); + + 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)); + }); + + 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" }); + }); +});