← all branches

feat/int-base

needs attentionviewing older commit
e5674dc · incrementalPR #268reviewed 2026-07-09 19:29 UTC3H · 7M · 6L · 2I
The branch
Purpose
Land every shared seam the 12 device-integration ports depend on — manifests, catalog seeds, ARN-seed registry, CDK path entries, and legacy-registry/credential migration scripts — so each per-brand branch only touches its own vendor directories.
Goal
All 12 brand integration manifests registered, seeded (Makes/Integrations/MetricTypes), CDK paths provisioned, and migration tooling (D2 credential copy + D3 registry migration) validated on preview DB. SG-7 adds `1M` calendar-month granularity end-to-end. SG-8 canonicalizes the multi-gran worker contract in INTEGRATION_STANDARDS.md §9.
Sub-goals
  • SG-1: 12 manifests + registration (DONE)
  • SG-2: Catalog seed rows — Makes, Integrations, MetricTypes incl. battery/load (DONE)
  • SG-3: ARN-seed REGISTRY entries + missing-Lambda tolerance (DONE)
  • SG-4: CDK paths.ts handler entries (DONE)
  • SG-5: D3 registry-migration script (DONE)
  • SG-6: D2 credential-copy script (DONE)
  • SG-N: Framework improvements — retire stale manifest co-location guidance (DONE)
  • SG-7: 1M calendar-month granularity end to end (DONE)
  • SG-8: Canonicalize multi-gran worker contract docs (DONE)
The changes (whole branch)
What
Two commits in this incremental window: (1) `a902ce33` — `Granularity` type gains `'1M'`; `monthSlotSeconds`/`energyKWhCalendarMonths`/`expectedCalendarMonths` pure functions added; `InputSeries` gains optional `calendarMonthly`; shell handles identity `5m` pipe read for monthly streams with timezone fallback; TOU breakdown excludes `1M` series; SQL case adds `'1M'→2592000`; Tinybird fixture + pipe test for identity read; tests for all new transforms + compute paths; (2) `e5674dce` — docs-only: INTEGRATION_STANDARDS.md §9 (canonical multi-gran worker contract), CLAUDE.md porting checklist, metrics-pipeline.md cross-refs, site-metrics-seam.md `1M` section.
Why
Vendors serving deep history (Growatt, several of the 12 brand ports) provide only monthly aggregates for historical periods. Without first-class `1M` support in the type system, Tinybird pipe, read coordinator, and integral, those sources cannot be ingested or used in savings analysis. The 12 brand branches need the contract (§9) before they can implement resilient multi-gran workers.
Areas
packages/integration-manifests+56015packages/api+103packages/database+22012domains/metrics+35010domains/cross-domain+909infra/cdk/src/lib/paths.ts+1850infra/tinybird+110services/metrics+5020scripts/metrics+8000.claude/rules+802
Blast
55 files, +4206/−144 cumulative. Core impact: `@batu/integration-manifests` (Granularity type — all workers/schemas import this), `domains/metrics` (pure transforms + compute, read coordinator), `domains/cross-domain` (site-energy-metrics shell). Migration scripts are standalone CLIs (no runtime blast radius). CDK paths.ts is type-only until a brand stack references a path.
1M splice invariant enforced by data/provisioning only — no runtime guard if finer source overlaps monthly coverage (silent ~8640× energy error) max_demand_15min from a 1M source is a lower bound on true peak — GDMTH billing consumers must use a finer source for demand charges
ci· No CI checks found on this PRcoderabbit· No .coderabbit.yaml present

Findings · 17

correctness2

medium

`max15min` degeneration for `1M` series is a silent lower-bound, no test contract

domains/metrics/src/site-metrics/compute.ts:93

For a `calendarMonthly` series `slotSeconds = 2_592_000`, so `rolling15minMeansW` computes `windowSamples = max(1, round(900/2592000)) = 1`. Each monthly point becomes its own 1-sample window — `max_demand_15min` returns the max monthly mean W/1000, a LOWER bound on the real peak. Downstream callers (GDMTH billing) that consume `max_demand_15min` from a `1M` source would systematically under-bill demand charges. The comment calls this out but no test pins the contract.

info

Splice invariant (1M disjoint from finer sources) has no runtime enforcement

domains/cross-domain/src/site-energy-streams.queries.ts:169

If a stream has both a `1M` source and a `5m` source with overlapping coverage, `min(grainCase)` resolves to `300`. The coordinator then sets `isMonthly = false` and integrates the `1M` point at `slotSeconds=300` — a ~8640× energy error. The seam doc designates this a data/provisioning responsibility, but no warning is emitted when the invariant is violated.

security3

low

Timezone from DB not re-validated at read time — invalid tz throws uncaught exception

domains/cross-domain/src/site-energy-metrics.shells.ts:205

`tz = streamsRes.value.timezone ?? 'UTC'` is passed directly to `monthSlotSeconds` / `energyKWhCalendarMonths` / `expectedCalendarMonths` → `date-fns-tz`. An invalid IANA string (direct DB write, seed error) throws an uncaught exception, bypassing the `Result` pattern. Defense-in-depth: wrap in try/catch and convert to a typed `SiteEnergyMetricsDatabaseError`.

low

No upper-bound on `[fromUtc, toUtc)` window in `expectedCalendarMonths`

domains/metrics/src/site-metrics/transforms.ts:198

The while-loop iterates month-by-month with no cap. A very large window (e.g. multi-decade query) would spin tens of thousands of iterations synchronously on the Lambda thread (CPU exhaustion / timeout). A `count > MAX_MONTHS` guard or enforcing a max window at the handler layer would add defense-in-depth.

info

`GranularitySchema` regex permits `0m`, `0s`, `0d` (zero-width granularities)

packages/api/src/schemas/metrics-common.schemas.ts:33

The regex `/^(\d+(s|m|h|d)|1M)$/` accepts `0m`, `0s` because `\d+` matches `0`. These map to `granularitySeconds = 0 → null` in the SQL CASE, producing silent no-data rather than a validation error. A `.refine(g => g === '1M' || parseInt(g) > 0)` would close this.

conventions4

medium

New `console.warn` calls in shell expand a pre-existing convention gap

domains/cross-domain/src/site-energy-metrics.shells.ts:181

The 1M branch introduces two new `console.warn` calls (lines ~181, ~208) for missing-timezone and unsupported-granularity conditions. `domain-patterns.md` mandates `createShellLogger(domain, operation, actor)` in shells. The calls are suppressed by eslint-disable comments — an acknowledged workaround. Not a new violation but the PR expands the footprint without relocating to structured logging.

low

`GranularitySchema` missing `satisfies z.ZodType<Granularity>` alignment check

packages/api/src/schemas/metrics-common.schemas.ts:32

`Granularity` type now includes `'1M'` and `GranularitySchema` was updated to match. Adding `satisfies z.ZodType<Granularity>` after the schema would catch any future drift between the TypeScript type union and the runtime regex at compile time — the canonical-form pattern for schema alignment.

low

`window` parameter in `computeOutputs` shadows the JS global

domains/metrics/src/site-metrics/compute.ts:67

The optional param `window?: { fromUtc, toUtc }` shadows the browser `window` global. In Node/Lambda this is harmless, but it is a readability trap and may produce linter warnings with DOM types. `readWindow` or `bounds` avoids the ambiguity with zero functional cost.

low

`metricsForPeriod` mutates `Record` after `computeOutputs` returns it

domains/cross-domain/src/site-energy-metrics.shells.ts:337

`metricsForPeriod` assigns `computeOutputs(...).metrics` to `const metrics`, then mutates it (`metrics['max_demand_15min'] = ...`), then returns it as `Readonly<Record<string,number>>`. TypeScript allows this (mutable assignable to Readonly alias) but it violates the immutable-returns convention. Prefer `return { ...metrics, max_demand_15min: band15minKW[period] }` for clarity.

tests7

high

Shell `isMonthly` path has no unit or integration test

domains/cross-domain/src/site-energy-metrics.shells.ts:173

The new block that detects `granularitySeconds === MONTH_GRANULARITY_NOMINAL_SECONDS`, selects `MONTHLY_IDENTITY_INTERVAL`, and tags the series `calendarMonthly: { timezone }` is exercised by no test. A bug in the detection (off-by-one on the sentinel, wrong interval choice, or timezone fallback logic) would be invisible until a live `1M` stream is queried.

high

UTC timezone fallback path for site-with-no-location untested

domains/cross-domain/src/site-energy-metrics.shells.ts:204

When `streamsRes.value.timezone` is `null`, the code falls back to `'UTC'` for the calendar-month integral. No test verifies (a) the fallback fires without throwing, (b) the computation proceeds, or (c) the result uses UTC month widths. A site without a `site_locations` row would silently integrate with wrong widths.

high

`max_demand_15min` degeneration for `1M` series unverified

domains/metrics/src/site-metrics/__tests__/compute.test.ts:64

`compute.ts` calls `max15minDemandKW(points, slotSeconds=2_592_000)` for calendar-month inputs, which degenerates to `max(monthly mean W)/1000` (documented as a lower bound). No test pins this value or asserts the key is present/absent. A future guard that drops `max_demand_15min` for monthly series would be invisible.

medium

Year-boundary rollover not tested in `expectedCalendarMonths`

domains/metrics/src/site-metrics/__tests__/transforms.test.ts:159

All three `expectedCalendarMonths` test cases use intra-year windows (Jan–Apr 2026). The `advance()` branch for `month === 12` (year increment, month reset to 1) is never exercised. A window like `[2026-11-01, 2027-02-01)` should return 3 but the year-rollover path is untested. Same gap exists for `monthSlotSeconds` on a December point.

medium

No test of stream resolution with `granularitySeconds = 2_592_000` (1M nominal)

domains/cross-domain/src/site-energy-streams.queries.ts:169

The SQL `when '1M' then 2592000` mapping is new. No test verifies that a `1M` stream arrives at the coordinator with `granularitySeconds === 2592000`. Existing `projectSiteEnergyStreams` tests only use `300` and `900` — a typo in the sentinel constant or the SQL case would go undetected.

medium

Tinybird pipe test covers CDMX only; Tijuana DST month-start not exercised

infra/tinybird/tests/resolve_stream_window.yaml:39

The `monthly_1M_identity_read_at_300s` fixture uses CDMX UTC-6 (month-start at `06:00Z`). Tijuana is UTC-8 in winter (`08:00Z`), UTC-7 in summer (`07:00Z`). The claim '300 s flooring is lossless for any IANA offset' is mathematically sound but a Tijuana case in the fixture would make it proof-complete, since the seam doc specifically calls out Tijuana DST handling.

low

`energyKWhCalendarMonths` gap-month-zero test only checks empty array

domains/metrics/src/site-metrics/__tests__/transforms.test.ts:154

The 'missing months contribute 0' test is `energyKWhCalendarMonths([], CDMX) === 0`. The contract that non-contiguous points (e.g. Feb + May with Mar/Apr missing) contribute only for present months is never stressed — all multi-point tests have contiguous months.

improvement1

medium

`expectedCalendarMonths` recomputes `localToUtc(monthStartLocalStr(...))` twice per iteration

domains/metrics/src/site-metrics/transforms.ts:194

The initial alignment check and the while-loop condition both call `localToUtc(monthStartLocalStr(year, month), timezone)` for the same (year, month) before each `advance()`. Caching the anchor — `let anchor = localToUtc(...); while (anchor.getTime() < ...) { count++; advance(); anchor = localToUtc(...); }` — halves the calls and removes the structural divergence risk.

History · 11 commits

  1. f7b9554safeincremental0H · 0M · 0L2026-07-13 20:18
  2. 9ca23adneeds attentionincremental0H · 3M · 3L2026-07-13 19:17
  3. 595484fneeds attentionincremental1H · 3M · 7L2026-07-13 04:29
  4. 2ed62f4safeincremental0H · 0M · 0L2026-07-13 04:13
  5. 2812f54needs attentionincremental1H · 3M · 10L2026-07-11 00:19
  6. 1794b23needs attentionincremental2H · 2M · 5L2026-07-10 21:59
  7. e5674dcneeds attentionincremental3H · 7M · 6L2026-07-09 19:29current
  8. ca45a96needs attentionincremental0H · 3M · 3L2026-07-09 18:56
  9. 5d19484safeincremental0H · 0M · 1L2026-07-08 02:04
  10. dd403feneeds attentionincremental12H · 22M · 12L2026-07-07 20:02
  11. e663ae9needs attentionincremental2H · 7M · 7L2026-07-07 19:02