← all branches

feat/powerradar

needs attentionviewing older commit
563252b · incrementalpre-PRreviewed 2026-07-21 18:32 UTC4H · 9M · 8L · 4I
The branch
Purpose
Add automatic API-based PowerRadar (Centrica / Panoramic Power) metrics integration, replacing manual-CSV path with native 5/15/60-min collection via headless OAuth + /eigw chart-data pull
Goal
Production-ready powerradar-api worker fitting the existing Site Collection SFN model: manifest registered, CDK deployed, DB seeded, live-proven end-to-end
Sub-goals
  • SG-1: Auth + transport engine + live probe (OAuth broker cracked; chart-data contract locked; browser headers defeat WAF; retention ≥12mo)
  • SG-2: Manifest + catalog + provider registration (powerradar-api manifest, seed rows, provider enum)
  • SG-3: Pure translation + unit tests (chart-to-batu.ts, 13 tests)
  • SG-4: Metrics handler + handler tests (§9 multi-gran, 18 tests; exploration deferred — metrics-only like solark)
  • SG-5: CDK stack + wiring (lambda.stack, iam, paths, main.ts dependency, coordinator invoke grant)
  • SG-6: Live worker e2e (191 demand points @ 15m; solar skipped on load-only device; full path proven headless)
  • SG-7: Framework fold-back (CLAUDE.md PowerRadar-API section)
The changes (whole branch)
What
This incremental window (from beced587) adds: pure translation layer (chart-to-batu.ts), metrics handler (metrics.lambda.ts), all 31 unit tests, CDK lambda+IAM stack, coordinator invoke grant, and integrations CLAUDE.md docs
Why
Gives Energía Real's 31 PowerRadar devices native 5/15-min granularity without manual export — prerequisite for accurate real-time CFE-basis max-demand billing; CSV worker stays as deep-history fallback
Areas
services/metrics/integrations/powerradar-api+10030infra/cdk/src/stacks/services/metrics/integrations/powerradar-api+1570infra/cdk/src (app/main.ts + lib/paths.ts + engine/coordinator)+290packages/integration-manifests/src/manifests+1000packages/database/src (schema + seeds)+170services/metrics/integrations/CLAUDE.md+420
Blast
~1350 net additions. No existing code paths modified beyond coordinator SSM read + addDependency; CSV powerradar worker untouched.
live-proven-e2e metrics-only-no-explore second-access-path-same-brand
ci· no PR — pre-PR branch; no CI rollup availablecoderabbit· no .coderabbit.yaml in repolive-e2e· SG-6: real handler via tsx against device 268165/site 168296 — 191 demand points @ 15m, 71 @ 1h; solar correctly skipped (load-only)unit-tests· 31 unit tests green (13 translation + 18 handler); typecheck + lint clean on all touched packages

Findings · 23

correctness2

medium

Fetch error masks grain-mismatch diagnosis in multi-group total-failure path

services/metrics/integrations/powerradar-api/src/handlers/metrics.lambda.ts:219

When `payloads.length === 0`, error returned is `fetchErrors[0] ?? translationFailed(details)`. If a grain-mismatch stored in `grainMismatch` plus a fetch failure in `fetchErrors` coexist, the operator sees UpstreamAuthFailed and fixes auth only to hit the non-retryable grain-mismatch on retry. The `details` variable is computed but discarded when fetchErrors[0] exists.

low

Single-point window bypasses grain guard

services/metrics/integrations/powerradar-api/src/translation/chart-to-batu.ts:165

`dominantSpacingSeconds` returns null for <2 points. The grain guard `spacing !== null && spacing !== slot` never fires for a 1-point window — a wrong-cadence single point can be stored. Known gap, belt-and-suspenders only fires at 2+ points.

security3

low

IAM centrica/* wildcard broader than necessary

infra/cdk/src/stacks/services/metrics/integrations/powerradar-api/iam.ts:44

Grants `secretsmanager:GetSecretValue` on `{env}/centrica/*` to reuse the legacy `{env}/centrica/portal/oid={oid}` secrets. Any future secret under `centrica/` gains ambient read access. Narrow to `{env}/centrica/portal/*`.

low

Spring OAuth error_description reflected into structured logs

services/metrics/integrations/powerradar-api/src/engine/eigw-client.ts

Auth failure embeds `body?.error_description` into the error details string, which the handler logs. Standard Spring value is 'Bad credentials' — harmless — but this is a vendor contract, not a code guarantee. Log only `body?.error` (the short code) and drop error_description.

info

OAuth client constant hardcoded — correctly a public SPA client

services/metrics/integrations/powerradar-api/src/engine/eigw-client.ts

OAUTH_CLIENT_ID ('webapi') / OAUTH_CLIENT_SECRET ('webapisecret') are code constants from the browser bundle — observable by any browser session. Per-org credential is {username,password} in Secrets Manager. No action needed.

conventions7

high

dynamicVariables: false missing from manifest object

packages/integration-manifests/src/manifests/powerradar-api.ts:49

The manifest comment documents `dynamicVariables: false` but the field is ABSENT from the exported object. The provisioning wizard defaults via `manifest.dynamicVariables ?? false` so routing is correct today — but the adjacent CSV `powerradar` manifest explicitly declares `dynamicVariables: true`, making the implicit false a maintenance trap. The missing assertion in device-integrations.test.ts is what let this slip through. Add `dynamicVariables: false` to the manifest object.

medium

grainMismatch variable name misleads — captures unit-unknown failures too

services/metrics/integrations/powerradar-api/src/handlers/metrics.lambda.ts:143

Variable is set for both `grain-mismatch` AND `unit-unknown` (lines 203-205). Used as primary error detail at line 217. A unit error surfaced under a grain-mismatch name is confusing to debug. Rename to `vendorDataError` or `hardChannelFailure`. Same pattern in solark's handler — fix both.

medium

No manifest test block for powerradar-api in device-integrations.test.ts

packages/integration-manifests/src/__tests__/device-integrations.test.ts

Every other manifest has a dedicated `it` block in this file. The absence is the proximate reason `dynamicVariables: false` was not caught. Add a block asserting: `dynamicVariables: false`, `requiresAuth: true`, `assetConfigSchema.required` includes `powerradarSiteId`, both variables declare `granularities: ['5m','15m','1h']`.

medium

ProjectionResult deviates from @batu/result — justification not documented

services/metrics/integrations/powerradar-api/src/translation/chart-to-batu.ts:99

ADR-016 mandates `Result<T,E>` from `@batu/result`. The custom `ProjectionResult` discriminated union is a consistent pattern across vendor worker translation layers (solark, enphase, hoymiles) — valid for worker-internal pure functions that never cross the domain boundary. But the deviation is undocumented. Add a comment explaining the boundary (translated to InvocationError by the handler).

info

§9 resilient semantics fully correct

services/metrics/integrations/powerradar-api/src/handlers/metrics.lambda.ts:154

Group-by-gran → ONE fetch per group → channel projection → zero-point drop → fail-only-if-nothing all implemented correctly. Real transport tag surfaces on total failure.

info

units: W label always correct after kW/MW scaling

services/metrics/integrations/powerradar-api/src/translation/chart-to-batu.ts:179

powerUnitFactor scales kW/MW to W; payload labeled 'W'. Matches site-metrics-seam rule. Confirmed correct.

info

addDependency CDK wiring complete

infra/cdk/src/app/main.ts:571

coordinatorLambdaStack.addDependency(powerradarApiLambdaStack) present; no additional edges needed. Infra checklist satisfied.

tests9

high

Missing test: partial success when one granularity group fails but another succeeds

services/metrics/integrations/powerradar-api/src/__tests__/metrics-handler.test.ts

§9: a failing group skips its channels while a succeeding group still lands in `payloads`. The existing mixed-15m+1h test asserts BOTH succeed. No test exercises: 15m fetch → UpstreamRateLimited, 1h fetch → ok → status: 'success' with only the 1h payload. Without this, a bug where fetchErrors[0] contaminates a partial success is undetected.

high

Missing test: unit-unknown at handler level (grainMismatch sentinel unexercised for unit errors)

services/metrics/integrations/powerradar-api/src/__tests__/metrics-handler.test.ts

`grainMismatch` is set for both `grain-mismatch` AND `unit-unknown` (lines 203-205), but unit-unknown is never triggered at the handler test level. The code path setting grainMismatch for a unit error and using it as the preferred failure detail at line 217 is untested. A test where the vendor returns `unitSymbol: 'Wh'` (energy, not power) for the only channel would cover this.

high

Missing test: empty dataRecord array (not null) → ok:true with zero points → handler drops it

services/metrics/integrations/powerradar-api/src/__tests__/chart-to-batu.test.ts

The channel-not-found guard checks `records == null` (falsy for null, but NOT for `[]`). With `dataRecord: []`, the function returns `ok: true` with `points: []`; the handler drops zero-point payloads. This correct behavior is untested — a future regression changing `== null` to `=== null` would silently break empty-window handling.

medium

Missing test: dominantSpacingSeconds tie-breaking (equal count → smaller spacing wins)

services/metrics/integrations/powerradar-api/src/__tests__/chart-to-batu.test.ts

The tie-break branch `count === bestCount && delta < best` is untested. A test with equal-count 300s and 3600s deltas should return 300. Without it a Map iteration order change could silently flip the grain-guard decision.

medium

Missing test: MW unit scaling (×1_000_000 branch in powerUnitFactor)

services/metrics/integrations/powerradar-api/src/__tests__/chart-to-batu.test.ts

Only W and kW are tested. The MW branch is dead code from the test's perspective — a typo in the multiplier (1_000 instead of 1_000_000) would go undetected.

medium

Missing test: 5m granularity happy path in the handler

services/metrics/integrations/powerradar-api/src/__tests__/metrics-handler.test.ts

The window-cap test exercises 5m but the fetch is mocked to never be called. A happy-path test with `granularity: '5m'` checking `resolution: 'FIVE_MIN'` is sent would verify the SLOT_SECONDS['5m'] and GRANULARITY_RESOLUTION['5m'] wiring.

low

Missing test: catch branch (uncaught exception → UpstreamUnavailable 502)

services/metrics/integrations/powerradar-api/src/__tests__/metrics-handler.test.ts

No test forces loadCredentialFromArn or fetchChartDataWithRetry to throw (vs return a failure object). If the failure() call inside catch were replaced with throw, the SFN would receive an unstructured Lambda error undetected.

low

Missing test: dedupe last-wins with 3+ duplicates

services/metrics/integrations/powerradar-api/src/__tests__/chart-to-batu.test.ts

Existing dedupe test uses exactly 2 records. A first-wins regression would pass the 2-duplicate test if records were in the right order. A 3-entry test (values 10, 11, 12 at same ts expecting 12) would be definitive.

low

Missing test: non-finite timestamp records (NaN, Infinity) silently dropped

services/metrics/integrations/powerradar-api/src/__tests__/chart-to-batu.test.ts

Line 155 guards `!Number.isFinite(rec.timestamp)`. A refactor dropping the isFinite check would go undetected. Covers realistic API schema drift.

improvement2

medium

dominantSpacingSeconds duplicated from solark — candidate for @batu/metrics-engine

services/metrics/integrations/powerradar-api/src/translation/chart-to-batu.ts:80

Byte-for-byte copy of `services/metrics/integrations/solark/src/translation/points-to-batu.ts`. Will repeat for every new integration needing a cadence guard. Belongs in `@batu/metrics-engine` alongside `groupChannelsByGranularity`.

low

CDK stack may be missing batu:env and batu:owner tags

infra/cdk/src/stacks/services/metrics/integrations/powerradar-api/lambda.stack.ts:49

Sets 5 tags (domain/subdomain/provider/service/dataClass) but not batu:env or batu:owner. Matches egauge/solark pattern — may be covered by an aspect, but worth verifying against the infra tagging standard for cost allocation.

History · 15 commits

  1. f34e4ceneeds attentionincremental0H · 1M · 3L2026-07-23 00:32
  2. 1bc8aedneeds attentionincremental0H · 2M · 3L2026-07-22 21:34
  3. 31e63bbneeds attentionincremental0H · 2M · 5L2026-07-22 20:53
  4. 482cb88safeincremental0H · 0M · 1L2026-07-22 17:56
  5. 9a73b79safeincremental0H · 0M · 0L2026-07-22 17:34
  6. 87f8298needs attentionincremental0H · 1M · 3L2026-07-22 16:59
  7. 1e80096safeincremental0H · 0M · 1L2026-07-22 16:42
  8. 6ed65b4safeincremental0H · 1M · 3L2026-07-22 00:00
  9. 74a1131needs attentionfull0H · 7M · 12L2026-07-21 19:06
  10. 563252bneeds attentionincremental4H · 9M · 8L2026-07-21 18:32current
  11. beced58needs attentionincremental0H · 3M · 3L2026-07-21 01:13
  12. 7fa8684needs attentionincremental4H · 9M · 7L2026-07-20 22:56
  13. 230784fneeds attentionfull2H · 11M · 14L2026-07-10 00:15
  14. b876b54needs attentionincremental2H · 2M · 5L2026-07-08 04:44
  15. ec4847fneeds attentionfull2H · 11M · 8L2026-07-08 03:32