Skip to content

Add getTariffPeriods: pure Tariff V2 buy/sell rate resolver - #7

Merged
Bre77 merged 6 commits into
mainfrom
fm/tariff-helper-impl-node
Jul 23, 2026
Merged

Add getTariffPeriods: pure Tariff V2 buy/sell rate resolver#7
Bre77 merged 6 commits into
mainfrom
fm/tariff-helper-impl-node

Conversation

@Bre77

@Bre77 Bre77 commented Jul 23, 2026

Copy link
Copy Markdown
Member

What

  • Add src/tariff.ts: getTariffPeriods(tariff, now, opts?), a pure, offline resolver for the Tariff V2 time-of-use rate schedule (tariff_content_v2 from site_info/get_energy_tariff). Returns the current buy/sell rate, currentStart, nextChange, and currency in one call - mirrors the shape and edge cases of the python-tesla-fleet-api sibling of the same name.
    • tariff carries no timezone, so the caller passes opts.timeZone (an IANA string, e.g. site_info.installation_time_zone); wall-clock parts come from Intl.DateTimeFormat(...).formatToParts(now) since JS has no tz-aware Date.
    • Handles: season year-cross (Oct->Mar style), day-of-week wrap (Fri->Mon), midnight-crossing periods, toHour: 24 / toMinute: 60 normalization, buy/sell season -> "ALL" and period -> "ALL" fallback, and key-presence price lookup so a real 0.0 rate isn't dropped as falsy.
    • Optional opts.horizonHours also returns an upcoming list of forward periods.
  • Fix TariffContentV2 (src/types/site_info.ts): seasons was typed as an array but the real payload is an object keyed by season name; added Season/TouPeriods/EnergyCharges/SellTariff interfaces so buy and sell lookups are typed instead of any.
  • Re-export getTariffPeriods and the TariffRate/TariffPeriod/TariffResolution types from src/index.ts.
  • Tests (test/tariff.test.ts): a live Tariff V2 capture (site name/id anonymized) (test/fixtures/sample-tariff.ts) plus synthetic fixtures covering each edge case above, including the toHour: 24 regression and missing-timezone/missing-rate/no-season-match/absent-sell-tariff cases.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8dbab2bd60

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tariff.ts Outdated
if (instances.length === 0) return null;
const current = findContaining(instances, nowAbs);
if (!current) return null;
const nextChangeOffset = findNextStart(instances, nowAbs) ?? current.end;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge End sparse periods at their actual end

For tariffs that do not cover every minute, this picks the next future period start even when the current interval ends earlier. For example, with a 10:00-11:00 period and the next period at 17:00, a lookup at 10:30 reports nextChange as 17:00, so callers keep using the 10:00 rate through a gap where findContaining would return no rate. Consider taking the earlier of current.end and the next start so gaps transition to the empty/null rate at the correct time.

Useful? React with 👍 / 👎.

Comment thread src/tariff.ts Outdated
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-resolve seasons when building upcoming periods

When horizonHours crosses a season boundary, the upcoming schedule still uses the buyGrid that was resolved for now, and each future segment is priced with buyGrid.seasonName. A tariff with Summer ending July 31 and Winter starting August 1 will therefore label and price August 1 segments as Summer if the caller asks for a horizon from July 31, which makes the returned forecast wrong for seasonal tariffs. The builder needs to include season boundaries and resolve the applicable season for each segment.

Useful? React with 👍 / 👎.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 52170d0595

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tariff.ts Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Convert tariff boundaries in the site timezone

For sites in DST-observing zones when a resolved boundary crosses the transition, this treats a wall-clock minute delta as elapsed UTC minutes. For example, in America/New_York on March 8, 2026 at 01:30, a 01:00-03:00 local period should end at 03:00 EDT, but adding 90 minutes to the UTC instant returns 04:00 local, so nextChange/upcoming switch rates an hour late; construct boundary dates in opts.timeZone instead.

Useful? React with 👍 / 👎.

Comment thread src/tariff.ts Outdated
Comment on lines +125 to +126
const seasonRates = (Object.prototype.hasOwnProperty.call(charges, seasonName) ? charges[seasonName] : charges["ALL"])?.rates;
if (!seasonRates) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back to ALL when season rates are empty

When the matching season key exists but its value has no rates (the added live fixture includes empty seasonal stubs like Winter: {} alongside ALL defaults), this branch chooses that empty object and the next line returns null without ever consulting charges.ALL. Tariffs that rely on generic ALL pricing for an otherwise matched season will report price: null for the whole season; fall back to ALL whenever the season entry lacks rates, not only when the key is absent.

Useful? React with 👍 / 👎.

… 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.
@Bre77

Bre77 commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Addressed all four - thanks, these were real:

  1. Sparse-period nextChange - fixed. The point-in-time resolver now caps nextChange at the active period's own end, so a gap between scheduled periods reports the gap instead of the next period found arbitrarily far out. Test: sparse schedule (gap between periods).
  2. Season re-resolution for upcoming - fixed. The forward-looking builder now walks one calendar day at a time and re-resolves the season fresh on each day, so a horizon that crosses a season boundary re-prices and re-labels correctly instead of reusing now's season throughout. Test: upcoming re-resolves seasons across a boundary.
  3. Timezone-correct boundary conversion - fixed. Boundaries are wall-clock minute deltas, so converting one to a Date now reconstructs the target wall-clock label and converts that (iteratively solving for the zone's UTC offset at that instant), instead of adding elapsed real minutes to now. The old approach silently agreed with the new one in the non-DST fixture (Australia/Brisbane) but was wrong in a DST-observing zone. Test: DST-observing timezone.
  4. ALL fallback when season rates are entirely absent - fixed. lookupPrice now falls back to the ALL bucket whenever the matched season key has no rates at all (e.g. an empty "Winter": {} stub), not only when the season key itself is missing. Test: falls back to ALL when the matched season key has no rates at all.

While rewriting the point-in-time resolver to fix #2, I unified it with the upcoming builder around one day-by-day model (dayPeriods) rather than patching the old minute-of-week/instances approach in place, since #1 and #2 both stemmed from that model only handling a single resolved season/grid.

All of these apply to the python-tesla-fleet-api sibling too (same design) - flagging so it gets mirrored there.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e38b07bc7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tariff.ts Outdated
return new Date(wallClockToUtcMillis(target.year, target.month, target.day, target.hour, target.minute, timeZone));
};

const nextChangeGM = sell ? Math.min(buy.nextChangeGM, sell.nextChangeGM) : buy.nextChangeGM;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Consider sell gaps when choosing nextChange

When a sell_tariff exists but its schedule is currently in a gap, resolveAt returns null, so this falls back to buy.nextChangeGM only. For a buy all-day tariff with a sell/export window from 17:00-19:00, a lookup at 16:30 returns an empty sell rate but a nextChange at the buy boundary, often midnight, so callers can cache the empty sell rate past 17:00 instead of observing the sell price start; track the next sell boundary even when no sell period currently contains now.

AGENTS.md reference: AGENTS.md:L14-L14

Useful? React with 👍 / 👎.

Comment thread src/tariff.ts Outdated
const segments: ScheduleSegment[] = [];
let dayStartGM = -wallClock.minuteOfDay;
while (dayStartGM < horizonMinutes) {
const dayDate = new Date(now.getTime() + dayStartGM * 60_000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive upcoming days in wall-clock time

The new evidence is that collectSegments still computes future local midnights by adding wall-clock minutes as elapsed UTC milliseconds. On a fall-back day such as America/New_York on 2026-11-01 at 00:30, the second loop iteration uses dayStartGM = 1410, but now + 1410m formats as 2026-11-01 23:00 Sunday, so the segment later rendered by toDate(1410) as Monday 00:00 is populated with Sunday's season/weekday rates; derive the day label via wall-clock shifting/conversion instead.

AGENTS.md reference: AGENTS.md:L14-L14

Useful? React with 👍 / 👎.

Comment thread src/tariff.ts Outdated
const resolution: TariffResolution = {
buy: buy.rate,
sell: sell ? sell.rate : EMPTY_RATE,
currentStart: toDate(buy.currentStartGM),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Start current windows after the sell boundary

When the sell tariff has a different schedule from the buy tariff and both are active, this reports the buy period's start even if the sell period started later. For example, an all-day buy rate with a 17:00-19:00 sell peak queried at 18:00 returns the correct sell price but currentStart at 00:00, so consumers treating currentStart/nextChange as the valid interval for the returned buy+sell pair overstate the sell price's interval; use the later of buy.currentStartGM and sell.currentStartGM.

Useful? React with 👍 / 👎.

…tStart

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".
@Bre77

Bre77 commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Third round addressed - all three were real:

  1. Sell gaps ignored in nextChange - fixed. scheduleAt (replacing the old resolveAt) now reports a next-boundary GM for a grid even when it's currently sitting in a gap (no active period), so a sell/export window that hasn't opened yet still contributes its start time to the overall nextChange instead of being skipped entirely. Test: tracks the next sell boundary even while sell is currently in a gap.
  2. collectSegments (and resolveAt's tomorrow-peek) derived future days via elapsed-ms arithmetic - fixed. Both now go through a new wallClockAt helper that adds wall-clock minutes using pure calendar arithmetic (no Intl round-trip), instead of now.getTime() + minutes*60000, which silently lands on the wrong calendar day on a 25-hour fall-back day or 23-hour spring-forward day. Test: derives upcoming's day-2 boundary from wall-clock arithmetic ... across a fall-back transition (America/New_York, 2026-11-01).
  3. currentStart only reported buy's start - fixed. When sell has a different, currently-active schedule, currentStart is now the later of buy's and sell's own period start, since that's when the returned pair became valid. Test: uses the later of buy/sell currentStart when sell started after buy.

While fixing #1 I merged the old resolveAt and the new sell-gap lookup into one scheduleAt function, since both needed "the next boundary regardless of whether a period is active right now" - that also let the DST-safe day derivation from #2 apply to resolveAt's own tomorrow-peek at the same time, not just the upcoming builder.

All three apply to the python-tesla-fleet-api sibling too - listing clearly for that port:

  • Track the next sell boundary even when now doesn't fall inside any current sell period (a sparse/gapped sell schedule), and fold it into the overall next-change candidate - don't just fall back to buy's boundary when sell resolves to nothing.
  • Any "what calendar day/weekday is N minutes from now" computation used while walking a forward schedule (or peeking at "tomorrow" to resolve a midnight-crossing period) must use wall-clock arithmetic in the site's zone, not elapsed real time added to a tz-aware instant - the two diverge by an hour on the day of a DST transition. (Python's datetime/ZoneInfo supports this natively via normal wall-clock field arithmetic + re-localization; needs the same audit whether it's done today.)
  • currentStart for the combined buy+sell result should be the later of the two sides' own period starts when their schedules differ, not just buy's - otherwise the reported "valid since" window is wrong for a shorter/later-starting sell (or buy) period.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7973e5a6bd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/tariff.ts Outdated

// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fold sell gap starts into currentStart

When a sell tariff is currently in a gap after a sell period ended (for example, buy is all-day, sell/export is 17:00-19:00, and the caller queries at 20:00), this branch falls back to the buy period start because sellSchedule.current is null. The returned sell rate is empty, but that buy+sell pair only became valid at 19:00, so consumers using currentStart/nextChange as the interval will backdate the empty sell rate to midnight; track the sell gap's previous boundary and take the later start even when no sell period is active.

AGENTS.md reference: AGENTS.md:L14-L14

Useful? React with 👍 / 👎.

…action

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).
@Bre77

Bre77 commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Fixed, and per the request I did a full exhaustive audit of the buy/sell current/next interaction rather than a point patch, to break the review loop.

The round-4 finding: confirmed real. When sell is in a gap after a period ended (buy all-day, sell/export 17:00-19:00, queried at 20:00), currentStart was falling back to buy's midnight start instead of 19:00 (when the sell gap actually began), backdating the empty sell rate.

The fix: scheduleAt now computes a sinceGM symmetrically with the existing nextChangeGM - both are defined whether or not a period is currently active (a gap has its own start just like it has its own end), bounded to one day of lookback/lookahead, and both nullable so a grid with nothing scheduled in that window is cleanly excluded from the combination (!= null, not treated as 0) rather than forcing a bogus fallback.

Exhaustive audit of the buy/sell interaction - added tests for every combination I could enumerate:

  • sell started after buy (existing) / buy started after buy (new, the reverse direction)
  • sell gap before its first period today (existing) / sell gap after its last period today (new - this round's fix)
  • buy's own nextChange boundary winning the Math.min (new - prior tests only exercised sell being the sooner side)
  • a sell schedule whose season doesn't cover today at all (scheduleAt returns null entirely) - contributes nothing
  • a sell schedule whose season matches but has zero periods ever defined - sinceGM/nextChangeGM both null, contributes nothing (this is also what caught a bug in my own first draft of this fix, where the "no data at all" fallback double-subtracted wallClock.minuteOfDay instead of excluding the side entirely - fixed before it shipped)
  • sell_tariff omitting its own seasons and falling back to buy's geometry (a real code path, previously untested)

32 tariff tests now, all green, plus the existing 56 from the rest of the suite (88 total). Build gate (pnpm install running tsc) and npx tsc --noEmit both clean.

Mirrors to python-tesla-fleet-api:

  • currentStart needs a "since" value for the sell (and buy) grid that's defined even when that grid is currently in a gap - the gap's own start (previous period's end), not just an active period's start - so a buy/sell pair whose sides have diverged states doesn't backdate the gap side to the other side's period start.
  • That "since" (like the existing "next boundary") should have a bounded one-day lookback and be nullable/excluded (not defaulted to some sentinel) when there's truly no scheduled period in that window - don't let "no data" get treated as "since always" or "since now" implicitly.
  • Worth adding the same test matrix: both start-order directions, gaps on both sides of an active period (before AND after), a sell season that doesn't cover today, a sell season with zero periods, and a sell tariff that omits its own season/geometry and falls back to buy's.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a596ec175

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/types/site_info.ts Outdated
daily_charges?: Record<string, any>[];
demand_charges?: Record<string, any>;
energy_charges: EnergyCharges;
seasons: Record<string, Season>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow sell tariffs without seasons

When a tariff's sell_tariff relies on the buy tariff's season geometry, the resolver explicitly supports that by falling back to tariff.seasons, and the new tests exercise a sell_tariff object with only energy_charges. Keeping seasons required in the exported SellTariff type makes that valid payload fail TypeScript checking for consumers unless they cast through any, so the type should mark seasons optional to match the runtime/API shape.

Useful? React with 👍 / 👎.

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.
@Bre77

Bre77 commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Fixed - SellTariff.seasons is now seasons?: Record<string, Season> in src/types/site_info.ts, matching the runtime fallback (tariff.sell_tariff?.seasons ?? tariff.seasons) that the "falls back to the buy seasons/geometry when sell_tariff omits its own seasons" test already exercises.

Worth noting for anyone reading this thread later: this repo's tsconfig.json scopes include to src/**.ts only (test files are excluded from the library's own typecheck/build gate - see AGENTS.md), so npx tsc --noEmit never actually caught this in my own test fixture. I confirmed the gap and the fix directly by typechecking the test file explicitly against both the old and new type - real consumer code (not excluded like our tests) would have hit this.

Mirrors to python-tesla-fleet-api: if the sibling has an equivalent typed/dataclass model for sell_tariff (or a TypedDict/pydantic model, given Python's more common validation-at-the-boundary style), its seasons field should be optional there too, for the same reason - a sell_tariff that omits seasons and relies on the buy tariff's geometry is a valid payload shape the resolver already supports.

That should be everything - thanks for the thorough pass across all five rounds.

@Bre77
Bre77 merged commit 31c98d3 into main Jul 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant