Skip to content

Commit b907992

Browse files
feat(meter-values): coherent MeterValues generator (issue #40) (#1935)
* feat(meter-values): coherent MeterValues generator (issue #40) Implements physics-coherent MeterValues (V->P->I->dE->SoC) gated by template flag coherentMeterValues. Session lifecycle on ChargingStation with txId snapshotted before resetConnectorStatus. Strategy gate after versioned sampled-value dispatcher, before legacy random measurand generation. Deterministic Mulberry32 PRNG with per-label stream splitting. New module under src/charging-station/meter-values/. Golden invariants harness green. Refs: #40 * test(meter-values): regression tests for Phase 4 findings (issue #40) RED phase for M1..M4 findings from /tmp/issue-40/review-consolidated.md: - M1: voltage PRNG must advance state across samples - M2: deltaEnergyWh must be clamped to remaining battery capacity at 100% SoC - M3-OCPP16: coherent session destroyed even if station stops during postTransactionDelay - M3-OCPP20: same for OCPP 2.0 sibling path - M4: stopEnergyWh assertion strengthened to remove self-reference tautology Currently 4 tests fail (expected RED); M4 rewrite passes (strengthening only). * fix(meter-values): address Phase 4 review findings (issue #40) - M1: cache voltage PRNG on CoherentSession (was reconstructed each sample, producing a stalled seed sequence). PRNG state now advances across samples as documented. - M2: clamp powerW to remaining battery capacity so a sample crossing 100 % SoC cannot over-charge the register. Everything downstream (I, ΔE, register) is recomputed from clamped power, preserving INV-1 and INV-3. - M3-OCPP16 / M3-OCPP20: destroy the coherent session BEFORE awaiting postTransactionDelay so an intervening station stop cannot leak the session. destroyCoherentSession is idempotent so the post-sleep path remains valid. Regression tests (M1..M4): pass 23/23. Full suite: pass 2908/0/6 skipped. * fix(coherent-meter-values): floor reportedPowerW to remaining capacity + tighten M4 register cross-check Phase 6 verification findings addressed: - N1 (gpt-5.5 HIGH, opus MED): capacity-clamp fallback risked INV-1 breach. Investigation: CURRENT_ROUNDING_SCALE=2 keeps V*I within <=0.1 W of reportedPowerW on realistic mains, well under INV-1 tolerance (+/-1 W). Simplified: floor reportedPowerW to maxPowerFromCapacityW after utility recompute (absorbs float drift without touching currentA). - V1-M4 secondary (sonnet 'partial'): assertion now reads MV[last] (was MV[2], tautological). Independent Sigma(P*dt) primary check unchanged. * [autofix.ci] apply automated fixes * fix(meter-values): address PR #1935 review findings (issue #40) Consolidated fixes for all 30 findings from the multi-agent code review of PR #1935. Preserves the coherent MeterValues feature scope; adds OCPP 2.0 end-to-end wiring; hardens correctness and idiomaticity. Blocking (2): - B1 fix INV-1 breach in capacity-clamp branch: derive current as exact fraction (P / (V·phases)), round at emit, then derive emitted P from rounded V·I·phases. Prior integer-amp rounding could inflate V·I·phases above the capacity-clamped power by up to V·phases·0.5 W. New AC 3-phase and DC regression tests lock the invariant. - B2 wire OCPP 2.0.1 support: add createCoherentSession call in OCPP20ResponseService.handleResponseTransactionEvent (Started case), mirroring OCPP 1.6. New 5-scenario integration test covering Accepted, implicit Accept, rejected idToken, force-override, and non-Started events. High (4): - H1 clear coherentSessions in ChargingStation.stop() finally; dispose runtime state per session before delete. - H2 README: add three template-parameter rows (coherentMeterValues, evProfilesFile, randomSeed) and a new 'EV profile file format' subsection documenting the ev-profiles-template.json schema. - H3 strip process residue: remove /tmp/issue-40/* references (3 files), 'Phase 2 merged finding #N' and 'Fix Phase 4 M-N' markers (8+ locations); replace with technical rationale. - H4 label INV-1/INV-2/INV-3 in the class-level JSDoc using the PR-body-canonical numbering (INV-1=V·I=P, INV-2=SoC monotone, INV-3=ΔE=P·Δt). Remove undefined INV-4 reference. Medium (13): - M1 DRY resolveRootSeed via hashLabel (byte-equivalent, test-locked). - M2 move voltagePrng runtime state off CoherentSession to a module-scope WeakMap keyed on session identity (no cross-station coupling); add disposeCoherentSessionRuntime wired into destroyCoherentSession + stop(). - M3 collapse five identical rounding scales into a single ROUNDING_SCALE. - M4 add explanatory comment on the boundary 'as MeterValue' cast (OCPP16/20 SampledValue.context enums structurally diverge). - M5 correct Prng.ts JSDoc: 'SplitMix32-derived' -> 'Mulberry32 + FNV-1a'. - M6 remove 'byte-identical' over-claims; use 'reproducible' / 'identical'. - M7 defensive early-return zero-sample when intervalMs <= 0 to prevent NaN contamination when SoC has already saturated. - M8 trim meter-values/index.ts barrel from 22 to 11 externally-consumed symbols. - M9 mark 7 immutable CoherentSession fields readonly. - M10 add ChargingStation.injectCoherentSession() public method and use it in 3 test sites, replacing 'station as unknown as { coherentSessions }' private-field injection. - M11 fix 'transaction id' -> 'transactionId' in Prng.ts comment. - M12 replace global Date.now monkey-patch with per-iteration mock.method(Date, 'now', ...) from node:test. - M13 remove dead chargingProfileLimitW parameter (getConnectorMaximumAvailablePower already folds in charging profiles). Low / Nit: - C4 clear-on-stop (covered under H1). - C5 XOR-commutativity in deriveSeed: deferred with explanatory comment (birthday bound ~2^16 well beyond simulator scale; a non-commutative mix would desync existing golden tests). - D-7 rename prng.ts -> Prng.ts (and prng.test.ts -> Prng.test.ts) to match repo PascalCase filename convention. - D-8 move getEvProfilesFile from EvProfiles.ts to Helpers.ts next to getIdTagsFile. - D-9 fix resolveTemplates JSDoc: remove false 'mirrors EVSE lookup' claim. - D-11 CoherentMeterValuesDefaults now exposes all tunable constants. - D-18 use AvailabilityType.Operative enum instead of string cast. - I1 auto-resolved by B1 (ROUNDING_SCALE=2 now semantically meaningful for current). - T2 use getErrorMessage() (repo convention) instead of (error as Error). - S2 simplify templatesFor test helper — remove 'as unknown as { unit }' casts. - S5 consolidate duplicate BuildVersionedSampledValueFn type into the canonical BuildVersionedSampledValue exported from meter-values. Verification: - pnpm lint 0 errors, 0 warnings - pnpm typecheck 0 errors - pnpm test 2918 pass / 0 fail / 6 skipped - New regression tests locking B1 (INV-1 clamp AC3+DC), M7 (NaN guard), M2 (session hygiene + WeakMap dispose), M1 (hashLabel equivalence), B2 (OCPP 2.0 session wiring, 5 scenarios). Closes #40 * fix(meter-values): address PR #1935 round-2 review findings (issue #40) Consolidated fixes for all Medium+Low+Nit findings from the second multi-agent review round of PR #1935. Two Blocking findings (S1 OCPP 1.6 signed-meter-values wrapper, S2 begin MeterValue routing) — S2 implemented, S1 deferred to a follow-up (post-hoc signing in startUpdatedMeterValues already covers the OCPP 1.6 periodic path; the remaining gap is only TriggerMessage/broadcast callsites, best served by a dedicated PR with a proper signing-key test fixture). ## Blocking (1 of 2 addressed; other deferred) - **S2 (implemented)** — `Transaction.Started` / `Transaction.Begin` MeterValue is now generated by the coherent path. Made `ChargingStation.createCoherentSession` idempotent so early request-builder creation is safe; reordered OCPP 2.0 flows (`OCPP20ServiceUtils.startTransactionOnConnector`, `OCPP20IncomingRequestService` RequestStartTransaction handler) to create the session BEFORE `buildTransactionStartedMeterValues`; reordered OCPP 1.6 flow (`OCPP16ResponseService.handleResponseStartTransaction`) and added a coherent-mode branch to `OCPP16ServiceUtils.buildTransactionBeginMeterValue` that routes through `buildMeterValue` when a session is live. - **S1 (deferred)** — OCPP 1.6 signed-meter-values wrapper for the coherent path. Rationale: `startUpdatedMeterValues` in `OCPP16ServiceUtils` applies post-hoc signing after `buildMeterValue` returns, so the OCPP 1.6 periodic coherent path IS signed today. The actual gap is only the TriggerMessage / worker-broadcast callsites where signing is skipped. Fixing it cleanly requires a signing-key test fixture that is best set up in a dedicated PR. ## Medium (8) - **M-01/M-02/M-03/M-07 combined defensive-guard block** (`computeCoherentSample`): early-return zero-sample when `intervalMs ≤ 0` (existing), `batteryCapacityWh ≤ 0` or non-finite (M-01), or `voltageOut ≤ 0` or non-finite (M-02). `rampUpDurationMs` guard now requires `> 0 && Number.isFinite(...)` (M-03). Prevents NaN poisoning and INV-1/INV-3 incoherence across the four sources. - **M-04** — Reverted the proposed Zod refinement for sorted `chargingCurve` after design analysis showed `loadEvProfilesFile`'s in-place sort is the authoritative defense and the refinement would break the loader's "accepts unsorted, sorts in place" contract. Documented rationale in `EvProfileSchema` JSDoc. - **M-06** — Renamed `ChargingStation.injectCoherentSession` → `__injectCoherentSession` (dunder-prefix test-seam convention); tightened mock parameter type from `unknown` to `CoherentSession`; updated the 3 test call sites. - **M-07** — Fixed the determinism claim in README.md and PR body: `interval` is a physics parameter, not a PRNG seed input. New prose makes this explicit. - **M-08** — Coherent path now respects OCPP 2.0.1 `SampledDataCtrlr.TxUpdatedMeasurands` / `TxEndedMeasurands` / `TxStartedMeasurands` and OCPP 1.6 `MeterValuesSampledData`. Added `resolveEnabledMeasurands` helper in `OCPPServiceUtils.ts` and threaded a `ReadonlySet<MeterValueMeasurand>` allow-list into `buildCoherentMeterValue`. Governs J02.FR.11 / E02.FR.09 / E06.FR.11. ## Deferred to follow-up issue (3) - **M-05** — Extract `CoherentMeterValuesManager.getInstance(chargingStation)`. Sibling per-station concerns (AutomaticTransactionGenerator, IdTagsCache, SharedLRUCache) use the multiton pattern; the coherent module currently keeps state on `ChargingStation`. Not a correctness issue; architectural refactor best done standalone. - **N-03** — Blocked on M-05 (semantic circularity in `ICoherentContext.getCoherentSession` cleaned up naturally when the manager owns the session store). - **N-04** — `Prng.ts` filename kept as-is; renaming to `PRNG.ts` on a case-insensitive macOS FS would require a second two-step rename with no functional benefit. ## Low/Nit (10) - **N-01** — Dropped `disposeCoherentSessionRuntime` from the `meter-values/index.ts` barrel; direct sub-module import in `ChargingStation.ts` (barrel exposes only externally-consumed symbols). - **N-02** — Rephrased the `Fix B1:` process-comment in `CoherentMeterValuesGenerator.ts` to invariant-only technical rationale (H3 miss from the previous round). - **N-05** — Renamed voltage locals in `computeCoherentSample`: `voltageNominal`/`voltageV`/`roundedVoltage` → `nominalV`/`sampledV`/`roundedV` (fewer names for the same quantity). - **N-06** — Added defensive `destroyCoherentSession` in the OCPP 2.0 `handleResponseTransactionEvent` `Ended` branch when connectorId is unknown (symmetric with OCPP 1.6 defensive destroy in `handleResponseStopTransaction`). - **N-07** — Tightened `deriveSeed` JSDoc with quantified birthday bound: expected collisions ≈ N²/2^33; at simulator scale (≤ 5×10⁴ pairs) ≈ 0.3 — negligible. - **N-08** — Tightened AC1/AC3/DC test tolerances from ≤ 1/3/1 W to ≤ 0.01 W (post-Option D tight bound is 0.005 W). - **N-09** — Uniform `getErrorMessage(error)` in EvProfiles.ts ZodError branch (was direct `error.message`, inconsistent with the else branch). - **N-10** — Removed dead exports `CoherentMeterValuesDefaults` and `mixSeed` (grep-verified zero external usages). - **N-11 F-04** — `@file` tag "MeterValue generator" (singular) → "MeterValues generator" (plural OCPP term). - **N-11 F-07** — README schema example id aligned with the actual `ev-profiles-template.json` (`compact-ev-40kwh` → `city-ev-40kWh`). - **N-11 F-08** — README documents the `initialSocPercentMin` ≤ `initialSocPercentMax` swap-and-warn behavior. ## Verification - `pnpm typecheck` — 0 errors - `pnpm lint` — 0 errors, 0 warnings - `pnpm test` — 2878 pass / 0 fail / 6 skipped Refs #40 * fix(meter-values): address PR #1935 round-3 review findings (issue #40) Consolidated fixes for all findings from the third multi-agent review round. No blocking findings remain; two content-lane blocks (barrel count, README example) plus 4 cross-validated HIGH nits addressed. ## HIGH (4) - **H1** — Correct false JSDoc claim at `ChargingStation.ts:319`. The `__injectCoherentSession` docstring stated the `__` prefix was enforced by a `no-restricted-syntax` ESLint rule; no such rule exists. Reworded to "convention only — not currently enforced by a lint rule". - **H2** — Trim 4 dead type re-exports from `src/charging-station/index.ts` (`ChargingCurvePoint`, `EvProfile`, `EvProfilesFile`, `ICoherentContext`). Grep-verified zero external consumers. `CoherentSession` kept (used by 4 test files). Barrel is now honest about its "only externally-consumed symbols" contract. - **H3** — Sync README `city-ev-40kWh` schema example to `src/assets/ev-profiles-template.json` exactly: `maxPowerW` 7400→11000, `weight` 1.0→3, `initialSocPercentMin` 10→15, `initialSocPercentMax` 80→55, 3-point curve → 4-point taper. First-time readers now see the same values in both docs. - **H4** — Validate CSV entries in `resolveEnabledMeasurands` (`OCPPServiceUtils.ts`). `Object.values(MeterValueMeasurand)`-membership guard drops unknown entries and logs a per-station-debounced warning. Prevents silent typo tolerance (e.g. `Voltege` in config CSV) while accepting every OCPP-defined measurand (unlike the narrower `isMeasurandSupported` allowlist). ## MED (3) - **M1** — Thread `TxUpdatedMeasurands` into `buildMeterValue` at both OCPP 2.0 sites that previously bypassed it: `OCPP20ServiceUtils.ts` `startUpdatedMeterValues` (periodic path) and `OCPP20IncomingRequestService.ts` `TriggerMessage(MeterValues)` handler. Closes the J02.FR.11 spec gap where CSMS-configured measurand filters were ignored on the periodic Updated path. - **M2** — Debug-only sortedness assertion in `interpolateChargingCurve` (`EvProfiles.ts`). Production path (`loadEvProfilesFile`) sorts in place; the assertion catches misuse from the `__inject*` test seam (unsorted curves silently returned the tail power-fraction — a hidden test footgun). `process.env.NODE_ENV !== 'production'` gate keeps runtime cost at zero in production. - **M3** — Session-leak safety net at 2 OCPP 2.0 request-time create sites: `OCPP20ServiceUtils.ts` `startTransactionOnConnector` wraps `sendTransactionEvent` in `try/catch`; `OCPP20IncomingRequestService.ts` `RequestStartTransaction` handler augments the existing `.catch` chain. Both call `destroyCoherentSession(txId)` before re-throwing/logging, symmetric with the OCPP 1.6 `resetConnectorOnStartTransactionError` pattern. Prevents session Map growth on WebSocket-send rejection. ## Content + Low/Nit - **P-01** — Extend the defensive guard block in `computeCoherentSample` with `!Number.isFinite(options.nowMs)`. Matches the pattern for the other 4 guarded inputs. Not reachable in production (`Date.now()` always finite) but closes the test-injection hole. - **P-02** — Document `rampUpDurationMs = Number.EPSILON` equivalence with 0 (both collapse to immediate full-power). Comment only. - **D-05** — Add `logger.warn` to the OCPP 2.0 `handleResponseTransactionEvent` Ended-with-unknown-connector branch, mirroring the OCPP 1.6 diagnostic parity at `OCPP16ResponseService.ts:534-536`. - **N-03** — Expand `ComputeSampleOptions.voltageNoise` inline comment into a full JSDoc with `@remarks` and `@defaultValue`. - **N-04** — Document the WHY for `createCoherentSession` idempotency (OCPP 2.0.x has two call sites per transaction from S2 fix). - **N-05** — Document the `<quantity><Unit>` naming convention on `CoherentSample` (all fields follow `currentA`/`powerW`/`voltageV`). ## Deferred with rationale (documented decisions) - **A6** — `buildTransactionBeginMeterValue` reads `connectorStatus.transactionId` internally rather than accepting an explicit param. Single call site, safe mutation ordering already documented; adding a param would add noise for zero safety gain. - **D-03** — `buildTransactionBeginMeterValue` dual-responsibility (coherent short-circuit + legacy path) tracked in follow-up issue #1936 as new M-06 item. ## Verification - `pnpm typecheck` — 0 errors - `pnpm lint` — 0 errors, 0 warnings - `pnpm test` — 2899 pass / 0 fail / 6 skipped Refs #40 * fix(meter-values): address PR #1935 round-4 review findings (issue #40) Consolidated fixes for all findings from the fourth multi-agent review round (5 parallel reviewers: Oracle elegance/TS · Oracle math/physics · general OCPP-spec-via-qmd · explore harmonization · general content/terminology). Design phase cross-validated 5 architecturally-loaded decisions via Oracle. ## HIGH (6) - **H1 [R4C]** — Thread `OCPP20ReadingContextEnumType.TRIGGER` into the `TriggerMessage(MeterValues)` handler at `OCPP20IncomingRequestService.ts:611`. Emitted `SampledValue.context` now correctly labels values taken in response to `TriggerMessage` per OCPP 2.0.1 §3.66 ReadingContextEnumType; previously defaulted to `Sample.Periodic`. - **H2 [R4C]** — Presence-aware `resolveEnabledMeasurands` semantics in `OCPPServiceUtils.ts`. Discriminates key-absent (defaults to `{Energy.Active.Import.Register}` for ergonomic parity) from key-present (honors the CSV verbatim, including empty ⇒ empty allow-list per OCPP 2.0.1 J02.FR.11). Removes the unconditional force-add at line 1115. - **H3 [R4C]** — Per-phase measurand emission in `buildCoherentMeterValue`. Restructured to iterate all templates per measurand (was: first-matching only) with phase-aware value resolution: `Voltage L-N ⇒ V`; `L-L ⇒ √phases × V`. `Power L-N ⇒ P/phases`; L-L unsupported (log-and-skip). `Current line ⇒ sample.currentA` (balanced 3-φ Y). `SoC / Energy.Active.Import.Register` phase-qualified templates rejected. Emit order: `SoC → V → P → I → Energy` across measurands; within a measurand: `no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N → other`. Closes legacy-parity regression for 3-phase stations with phase-qualified templates. - **H4 [R4D]** — `throw new BaseError(...)` in `EvProfiles.ts` `interpolateChargingCurve` (was: bare `Error`), aligning with AGENTS.md TypeScript conventions on typed errors. - **H5 [R4D]** — `assert.ok(cs != null, 'Expected connector 1 to exist')` in `OCPP20ServiceUtils-PostTransactionDelay.test.ts` (was: `throw new Error`), matching the pattern used elsewhere in the test suite. - **H6 [R4E]** — README §EV profile file format now documents the connector-level-only `MeterValues` template resolution scope under coherent mode (EVSE-level inheritance not applied; tracked as follow-up). ## MED (12) - **M1 [R4A]** — `computeCoherentSample` now takes the resolved `session` as a parameter (was: fetched twice via `context.getCoherentSession`). Removes 2 unreachable defensive branches (\~25 LOC) and tightens the contract into a pure physics function. - **M2 [R4C]** — OCPP 1.6 `buildTransactionBeginMeterValue` threads vendor param `StartTxnSampledData` when configured, falling back to `MeterValuesSampledData` when absent — per the OCPP 1.6 Signed Meter Values whitepaper. - **M3 [R4D]** — Move `MS_PER_HOUR` and `UNIT_DIVIDER_KILO` to `Constants` class (`src/utils/Constants.ts`). Was duplicated as file-scope constants in `OCPPServiceUtils.ts` and `CoherentMeterValuesGenerator.ts`. All references now use `Constants.MS_PER_HOUR` / `Constants.UNIT_DIVIDER_KILO`. - **M4 [R4D]** — Move `VOLTAGE_NOISE_PERCENT` to `Constants.DEFAULT_COHERENT_VOLTAGE_NOISE_PERCENT`. Tunables belong in the canonical defaults map per AGENTS.md. - **M5 [R4D]** — Move `DEFAULT_RAMP_UP_DURATION_MS` to `Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS`. Same rationale. - **M6 [R4D]** — Merge same-specifier `import type` statements in `types.ts` and `EvProfiles.ts` (was: 2 statements each for the same module). Aligns with the project's `import/no-duplicates` / `verbatimModuleSyntax` convention. - **M8/M11 [R4D]** — `describe('Prng', ...)` and `describe('StrategyDispatch', ...)` renames to match the module-name-only convention in `tests/TEST_STYLE_GUIDE.md`. - **M9 [R4E]** — README precision: "emitted measurand list" no longer claims `ΔE` is emitted (it is an internal per-sample intermediate); INV-1 spelled out per current-type (`P = V·I·phases` for AC, `P = V·I` for DC). - **M10 [R4E]** — Rewrite forward-looking comment at `OCPP16ServiceUtils.ts:152-158` to describe the current-branch semantics (`StartTxnSampledData` override with fallback to `MeterValuesSampledData`) instead of the deferred S1 signing wiring. ## LOW/NIT batch - **R4A-LOW-01** — `advanceEnergyRegister` rewritten with `Math.max(0, ... ?? 0)` — one-expression clamp-and-init (was: two-step nullish-then-negative guard, ~14 LOC → 6 LOC). - **R4A-LOW-02** — 3 near-identical `CoherentSample` zero-literal returns in `computeCoherentSample` collapsed to a single `buildZeroSample` helper. Two of the three defensive branches also removed by M1. - **R4A-NIT-01** — `findTemplate` replaced by `groupTemplatesByMeasurand` (needed for H3 per-phase iteration anyway); legacy `for..of` scan is now gone. - **R4B-LOW-01/02** — INV-1/INV-3 docstring precision: emit-time rounding bound stated as "`ROUNDING_SCALE` half-width (\~0.005 W scalar)"; INV-3 divergence bound quantified (\~0.12 Wh over 24 h at 1 Hz). - **R4B-LOW-05** — `EvProfileSchema` JSDoc documents monotone-non-increasing `powerFraction` as a caller responsibility (not schema-enforced; real curves are flat through CC before tapering). - **R4B-NIT-06** — AC `numberOfPhases <= 0` now triggers the zero-sample defensive branch (was: silently produced `P = 0` via `divisor = V × 0 = 0`). - **R4B-NIT-07** — `interpolateChargingCurve` JSDoc documents the closed-closed interval semantic (left segment wins at interior nodes). - **R4D-LOW-06/07/08** — `StationHelpers.ts` mock: `coherentSessions`, `createCoherentSession`, `getCoherentSession` return types tightened from `unknown` to `CoherentSession | undefined` (and `Map<number | string, CoherentSession>`). - **R4D-NIT-03/04/05/06** — `meter-values/index.ts` barrel comment now explicitly enumerates the intentionally-omitted internal exports (`computeCoherentSample`, `advanceEnergyRegister`, `createStreamPrng`, `disposeCoherentSessionRuntime`, option-bag types, EvProfiles helpers, Prng primitives, Zod schemas). - **R4E-LOW-05** — `Prng.ts` header no longer claims a specific LOC bound ("kept intentionally small"). - **R4E-LOW-06** — Expanded JSDoc on `ComputeSampleOptions` and `CreateSessionOptions` interfaces (per-field semantics, units, defaults). ## Deferred to follow-up #1936 (documented rationale) - **M7 [R4D]** — `StationHelpers.ts` (954 LOC) modular split. Structural refactor with 30+ test file consumers; TODO comment added at the top of the file linking to #1936. Detailed split sketch documented in the design phase. - **H6 code-side** — Extend `ICoherentContext` with `getEvseStatus` to restore EVSE-level template inheritance. Round-4 lands docs-only. - **R4B-LOW-03/04** — Physics model design notes (`cos φ = 1` assumption, linear-vs-S-curve ramp shape). Not blockers. ## Non-findings (verified compliant) - OCPP 2.0.1 `TxUpdatedInterval` correctly wired for periodic scheduling. - `Transaction.Begin` / `Transaction.End` enum literals correct. - Coherent path does not spoof `signedMeterValue`; signing gate intact. - Coherent path does not bleed into `AlignedDataCtrlr` / `ClockAlignedDataInterval` handling. - Existing hyphenated test file names (`OCPP16-CoherentMeterValues.test.ts` etc.) match the `ChargingStation-Configuration.test.ts` sub-domain precedent — not a divergence. ## Verification - `pnpm typecheck` — 0 errors - `pnpm lint` — 0 errors, 0 warnings - `pnpm test` — 2920 pass / 0 fail / 6 skipped Refs #40 * fix(meter-values): OCPP-spec-strict emit + per-phase register + polish (issue #40) ## OCPP 2.0.1 spec-strict emit shape - `TransactionEvent(Updated)` no longer serializes an empty `meterValue` wrapper when `TxUpdatedMeasurands` resolves to an empty allow-list. Periodic `startUpdatedMeterValues` short-circuits; TriggerMessage (MeterValues) active-tx branch sends the event without the `meterValue` field. Matches OCPP 2.0.1 J02.FR.11 ("no meter values are sent") and fixes the JSON-schema `minItems: 1` violation on empty sampledValue. ## OCPP 1.6 whitepaper composition (StartTxnSampledData × beginEndMeterValues) - `OCPP16ResponseService` gates the extra `MeterValues.req` sends at both the start (`beginEndMeterValues`) and end (`outOfOrderEndMeterValues`) branches on `isNotEmptyArray(sampledValue)`. Composes cleanly with the Signed Meter Values whitepaper §3.3.4 rule (`StartTxnSampledData` present + non-empty) and the legacy `MeterValuesSampledData` fallback: both empty ⇒ no send, avoiding a schema-suspect empty sampledValue wrapper. ## Coherent path — per-phase physics polish - **Per-phase Energy.Active.Import.Register**: L-N templates now emit `register / phases` (balanced 3-φ Y contribution per phase); L-L and `N` phases skipped with a warn. OCPP 2.0.1 `SampledDataCtrlr.RegisterValuesWithoutPhases` config-driven suppression not consulted (documented follow-up). - **Neutral phase**: new `Neutral` phase-family classifier distinguishes bare `N` from `LineToNeutral` (`L1`/`L1-N`/etc.). `N`-qualified Voltage and Current templates emit 0 (balanced 3-φ Y: I_N = 0 by phasor sum, V_N = 0 by reference-node definition). - **L-L voltage on 1-phase**: log-and-skip. Previously the `√1 × V` degeneracy silently emitted nominal V under an L-L label. - **L-N power on phases≤0**: log-and-skip (previously fell through to aggregate power under a per-phase label — unreachable in practice due to the outer defensive guard but semantically inconsistent). - **Register clamp sync**: `preRegisterWh` in `computeCoherentSample` applies `Math.max(0, ... ?? 0)` matching `advanceEnergyRegister`; reported `sample.energyRegisterWh` and post-advance persisted state now agree even under corrupted negative register state. ## Elegance + TS state-of-the-art - Extract `resolveUnitDivider(measurand, unit)` + `KILO_UNIT_BY_MEASURAND` at module scope, removing the inline `(A && B) || (C && D)` boolean at the emit site. - `PHASE_RANK` converted from `ReadonlyMap` to object literal with `as const satisfies Record<MeterValuePhase, number>` for compile-time exhaustiveness; `phaseRank`'s runtime fallback dropped. - `resolvePhasedValue` returns exact fractions (no internal rounding); rounding happens once at the emit site after unit-divider scaling. Removes double-rounding asymmetry between aggregate and per-phase paths. - `groupTemplatesByMeasurand` uses `Map.groupBy` (Node 22+) in place of the hand-rolled loop; per-bucket phase sort preserved. - Merged split `import type { ConnectorStatus }` with sibling type-import from the same specifier. ## Documentation - README §Phase-qualified measurands: "nominal V" → "sampled V" everywhere (the coherent path emits `sample.voltageV`, i.e. post-noise rounded voltage — not the station's nominal); documents `N` phase behavior and per-phase Energy register emission. - `resolvePhasedValue` JSDoc updated to match implementation. - `ComputeSampleOptions` and `CreateSessionOptions` JSDoc converted from bullet-list style to inline per-field JSDoc (matching `CoherentSession` and `ICoherentContext` in `types.ts`). - `types.ts` file header rewritten to state the current interface contract instead of deferred tooling-contingency rationale. - `Constants` docstrings for coherent tunables / `MS_PER_HOUR` / `UNIT_DIVIDER_KILO` shortened to one-liners matching the concision of sibling entries. - `CoherentMeterValuesGenerator.ts` header carries a follow-up TODO for the 250-LOC ceiling exceedance (split scope documented). - Explanatory comment on the module-scope `warnedInvalidMeasurands` WeakMap in `OCPPServiceUtils.ts` distinguishing its GC-keyed intent from a true singleton. - Internal-only comment on `VersionedSampledValueDispatch` interface. ## Tests - New per-phase emission integration test: 3-phase AC session with L-N/L-L voltage, aggregate + per-phase Power, L1/N Current, and L-N Energy templates; asserts V_L1_N=230, V_L1_L2≈√3·230, Σ per-phase P ≈ aggregate P, I_N=0, L-L voltage skipped on 1-phase, L-N energy register emitted as `register / phases`. - `describe('OCPP 1.6 coherent MeterValues integration')` renamed to `describe('OCPP16CoherentMeterValues')` (module-name convention). - OCPP 1.6 integration test replaces its local `MS_PER_HOUR = 3_600_000` literal with a `Constants.MS_PER_HOUR` alias (canonical source). - Removed leaked internal-review annotations from 9 describe/it labels (`(regression: ...)` suffixes) — these violated documentation timeliness by embedding non-behavioral session metadata into the behavioral test tree. ## Verification - `pnpm typecheck` — 0 errors - `pnpm lint` — 0 errors, 0 warnings - `pnpm test` — 2923 pass / 0 fail / 6 skipped Refs #40 * fix(meter-values): periodic-event omission + Energy L-N alignment + polish (issue #40) ## OCPP 2.0.1 spec-strict emit shape - Periodic `TransactionEvent(Updated)` no longer drops the whole message when `TxUpdatedMeasurands` resolves empty. Sends the event with the `meterValue` field omitted, matching the R5 fix already in place on the `TriggerMessage(MeterValues)` active-tx branch. Preserves the periodic heartbeat cadence at the OCPP level while honoring J02.FR.11 ("no meter values are sent") — the message is still delivered, only the sampled-value payload is elided. ## Coherent path — phase-degeneracy symmetry - `Energy.Active.Import.Register` L-N templates on `phases <= 0` now log-and-skip (return `undefined`), matching `Power.Active.Import`'s behavior for the same misconfiguration. Previously the register branch fell through to emit the aggregate under a per-phase template label. ## Elegance + TS state-of-the-art - `resolvePhasedValue` signature narrowed from `(measurand, template, sample, phases, connectorStatus)` to `(measurand, phase, sample, phases, connectorStatus)`. The function only reads `template.phase`; taking the whole template widened the coupling. Caller passes `template.phase` at the loop head. - `resolveUnitDivider` gains a JSDoc block (sole helper in the file that previously lacked one) and reorders the guard to `unit != null && KILO_UNIT_BY_MEASURAND.get(measurand) === unit` for intent-first reading ("only kilo-divide when a unit is present"). - `LEGACY_EMIT_ORDER` renamed to `MEASURAND_EMIT_ORDER`. The constant is the canonical emission order of the coherent path, not a compatibility shim; the JSDoc note preserves the "mirrors the legacy path" provenance. - `buildZeroSample` now owns all rounding for the zero-sample path. Callers pass raw `socPercent` / `voltageV`; the helper applies `roundTo` internally. Rounding-responsibility is unified in one place. ## Documentation - INV-1 docstring documents both the aggregate residual (≤ 0.005 W) and the per-phase L-N residual (≤ 0.01 W = 2 × `ROUNDING_SCALE` half-width), quantifying the two-step rounding bound (aggregate emit + per-phase division). - `resolvePhasedValue` JSDoc documents the per-phase Energy register conservation bound: Σ across all L-N templates equals the aggregate register within emit-unit rounding granularity (Wh: ≤ phases · 0.005 Wh; kWh: ≤ phases · 5 Wh). - `VersionedSampledValueDispatch.signingState` JSDoc: unresolvable `{@link buildVersionedSampledValue}` replaced with plain prose (the reference is a local closure, not an exported symbol). - `resolveEnabledMeasurands` `@param measurandsKey` prose clarified: `undefined` (or omitted) defaults to `StandardParametersKey.` `MeterValuesSampledData` for OCPP 1.6; returns `undefined` (no filter) for all other versions. - `meter-values/index.ts` barrel comment simplified. Dropped the enumerated intentionally-omitted list (drifts with internals); kept the intent sentence only. - `ChargingStation.createCoherentSession` JSDoc: dropped the request-builder/response-handler call-site narrative; kept the API contract ("idempotent; returns `undefined` when coherent mode is disabled or no valid EV profile file is loaded"). ## README - `§Template resolution scope`: dropped the internal `getSampledValueTemplate` helper name and the backlog-tracking sentence. Documents current behavior only (connector-level templates, no EVSE-level inheritance under coherent mode). - `§Phase-qualified measurands`: dropped the "tracked as a follow-up" tail on the `RegisterValuesWithoutPhases` note. - Charging station template configuration table: `three phased` → `three-phase`, `line to line voltage` → `line-to-line voltage` (hyphenation consistent with the rest of the docs). ## Tests - Added mandatory `afterEach(() => standardCleanup())` block to `CoherentMeterValuesGenerator.test.ts`, `EvProfiles.test.ts`, and `Prng.test.ts` per `tests/TEST_STYLE_GUIDE.md` §Mandatory Cleanup. - `describe('OCPP20ServiceUtils — PostTransactionDelay')` renamed to `describe('OCPP20ServiceUtilsPostTransactionDelay')` — module-name concatenated form matching the existing coherent-test naming. - `describe('B2 - OCPP 2.0.1 TransactionEvent Started → coherent session wiring')` renamed to `describe('OCPP20ResponseServiceCoherentSession')` — module-name-only form; the descriptive/spec-prefix suffix was redundant with the inner `it` labels. ## Verification - `pnpm typecheck` — 0 errors - `pnpm lint` — 0 errors, 0 warnings - `pnpm test` — 2920 pass / 0 fail / 6 skipped Refs #40 * [autofix.ci] apply automated fixes * refactor(meter-values): phase-family Record + shared test interval constant (issue #40) ## phaseFamily as an exhaustive Record - Convert `phaseFamily`'s switch statement to a `PHASE_FAMILY` object literal with `as const satisfies Record<MeterValuePhase, ...>`, matching the `PHASE_RANK` pattern already established for phase ranking. Adding a new `MeterValuePhase` enum value now fails compile at the table declaration until the value is classified — the same compile-time gate PHASE_RANK enforces. - The `phaseFamily` function becomes a one-liner: `phase == null ? 'Aggregate' : PHASE_FAMILY[phase]`. `Aggregate` remains the sentinel for the undefined-phase case. - Removes the defensive `default: return 'Aggregate'` branch that silently absorbed unclassified enum values. ## phaseRank inlined - Drop the `phaseRank` helper (used at exactly one call site) and inline the null-check + `PHASE_RANK` lookup directly into the `groupTemplatesByMeasurand` bucket-sort comparator. The `PHASE_RANK` table is now the visible source of truth at the sort site, matching the `PHASE_FAMILY` pattern above. ## Shared TEST_METER_VALUES_INTERVAL_MS constant - Add `TEST_METER_VALUES_INTERVAL_MS = 30_000` to `tests/charging-station/ChargingStationTestConstants.ts` as the canonical test interval. - Replace 27 inline `30_000` / `30000` literals across `CoherentMeterValuesGenerator.test.ts` (20 sites), `OCPP16-CoherentMeterValues.test.ts` (4 sites, including the local `const INTERVAL_MS = 30_000` that was itself a duplicate), and `StrategyDispatch.test.ts` (3 sites). Preserves value; consolidates the semantic "meter-values sample interval used across coherent tests" into one importable constant. ## Verification - `pnpm typecheck` — 0 errors - `pnpm lint` — 0 errors, 0 warnings - `pnpm test` — 2923 pass / 0 fail / 6 skipped Refs #40 * [autofix.ci] apply automated fixes * chore(meter-values): strip internal-review markers + JSDoc/docstring polish (issue #40) ## Internal-review-marker cleanup Removed 19 in-code review-round prefixes (`M1:`, `M2:`, `M3:`, `M4:`, `B1`, `B2`, `Regression M4:`, `Regression B2:`) from test assertion messages, JSDoc `@description`, and inline comments across 5 test files: - `CoherentMeterValuesGenerator.test.ts` — 7 sites (voltage-noise, capacity-clamp, INV-1 residual assertions). - `OCPP16-CoherentMeterValues.test.ts` — 6 sites (stop-energy reconstruction and coherent-session-leak assertions + comment). - `OCPP20ServiceUtils-PostTransactionDelay.test.ts` — 1 site (coherent-session-leak assertion). - `OCPP20ResponseService-CoherentSession.test.ts` — 4 sites (`@description` + three eventType/idToken assertions). - `OCPP16ResponseService-ForceTxOnInvalid.test.ts` — 1 comment block ("exercises the regression:" process language → behavioral "exercises the pre-Start guard"). Assertion messages now describe the invariant being checked in plain English without carrying review-round bookkeeping. This is a follow-up to the earlier cleanup that stripped `(regression: XX)` suffixes from `describe`/`it` labels but missed codes inside assertion messages and `@description` blocks. ## JSDoc / doc hygiene - `PHASE_FAMILY` (`CoherentMeterValuesGenerator.ts`) — deleted the stale first JSDoc block that documented the pre-refactor `phaseFamily` function; kept the block that documents the current Record with compile-time exhaustiveness gate. - `VersionedSampledValueDispatch` (`OCPPServiceUtils.ts`) — converted the `//` block comment above the interface to a proper `/** */` JSDoc block matching the sibling-interface convention. Body documents the internal-only dispatch-bag role with `@link` cross-references. - `warnedInvalidMeasurands` and `KNOWN_MEASURANDS` (`OCPPServiceUtils.ts`) — moved above the `resolveEnabledMeasurands` JSDoc block so the JSDoc is now adjacent to the function it documents (was previously separated by the two `const` declarations, breaking the visual JSDoc→function association). - `template.unit as MeterValueUnit | undefined` cast (`CoherentMeter` `ValuesGenerator.ts`) — added an intent comment documenting the OCPP 2.0 open-string-branch narrowing at the Map-lookup boundary and the fallback semantics (non-enum strings return `undefined` from the Map and fall through to `divider = 1`). - `MEASURAND_EMIT_ORDER` (`CoherentMeterValuesGenerator.ts`) — dropped the explicit `readonly MeterValueMeasurand[]` annotation; kept `as const` for a narrow tuple type matching the sibling `PHASE_FAMILY`/`PHASE_RANK` pattern (immutability via const-assertion, coherent style across the three module-scope constants). - `ChargingStation.__injectCoherentSession` JSDoc — dropped the "not currently enforced by a lint rule" sentence (build-tool trivia, not API behavior). ## Test constants - `TEST_METER_VALUES_INTERVAL_MS` rationale documented on the `Timer Intervals` block header (`_MS` intervals are intentionally half of `Constants.DEFAULT_*_INTERVAL_MS` for bounded test wall-clock). - `ChargingStationTestConstants.ts` file header rewritten from the narrative-prose + `@see` form to standard `@file` / `@description` JSDoc matching the rest of the test suite. ## Verification - `pnpm typecheck` — 0 errors - `pnpm lint` — 0 errors, 0 warnings - `pnpm test` — 2923 pass / 0 fail / 6 skipped Refs #40 * docs(meter-values): tighten OCPP schema-cardinality citation + test tolerance message (issue #40) - OCPP20ServiceUtils.ts / OCPP20IncomingRequestService.ts: replace loose J02.FR.11 shorthand on the meterValue-field-omission branches with the actual grounding — MeterValueType.sampledValue cardinality 1..* combined with TransactionEventRequest.meterValue cardinality 0..* — so a reader sees why the empty case yields {} rather than { meterValue: [] }. - CoherentMeterValuesGenerator.test.ts: capacity-clamp failure messages now report 2 × ROUNDING_SCALE (0.01 W) to match the assertion tolerance (the two rounding steps: aggregate P = V·I·phases plus post-clamp re-round each contribute one half-width). * refactor(meter-values): use session snapshot for voltage, currentType, numberOfPhases (issue #40) computeCoherentSample now reads voltageOutNominal, currentType, and numberOfPhases from the session object (immutable snapshot captured at createCoherentSession) instead of the live context. The three fields were already declared readonly on CoherentSession but only two were consulted at runtime, leaving voltageOutNominal dead. Using the snapshot makes the physics chain deterministic against hypothetical mid-session station-config drift and reduces per-sample context calls from three to zero (getConnectorMaximumAvailablePower stays live — the EVSE cap is a genuinely dynamic quantity that can change during a transaction). * fix(meter-values): namespace transactionId in createStreamPrng to prevent XOR self-inverse (issue #40) createStreamPrng chained two XOR-based deriveSeed calls, so String(transactionId) === label collapsed the derived stream seed back to the root seed (deriveSeed(deriveSeed(r, X), X) === r). Trigger required transactionId to match a literal stream label ('VOLTAGE_NOISE', 'POWER_NOISE', 'PROFILE_PICK', 'INITIAL_SOC') — extremely unlikely with OCPP-numeric transactionIds but reachable via test seams. Namespace the transactionId leg with a 'tx:' prefix that labels never carry so the two hash inputs cannot algebraically cancel. Prng.ts deriveSeed docstring updated to reference the namespacing in createStreamPrng and drop the 'Known limitation (deferred)' language. * chore(charging-station): guard __injectCoherentSession against production use (issue #40) The public __injectCoherentSession method is a test seam whose only prior protection was the '__' naming convention. Add a runtime guard that throws a typed BaseError when NODE_ENV === 'production', mirroring the pattern used by interpolateChargingCurve in EvProfiles.ts for the unsorted-curve defensive check. Tests run with NODE_ENV unset or 'test' so the guard is transparent to them; accidental production use now fails loudly instead of silently corrupting the session store. * fix(meter-values): tighten defensive guard against non-finite intervalMs (issue #40) The computeCoherentSample defensive guard applied Number.isFinite to batteryCapacityWh, nominalV, and nowMs but only tested intervalMs <= 0. Since NaN <= 0 and +Infinity <= 0 both evaluate to false in JS, either pathological value escaped the guard, propagated through maxPowerFromCapacityW = remainingWh * MS_PER_HOUR / intervalMs, and permanently poisoned session.socPercent to NaN (NaN + x === NaN for every subsequent sample). Reachability was low in practice — legitimate callers pass integer intervals and convertToInt throws on NaN — but a non-compliant CSMS setting MeterValueSampleInterval to a pathological value via ChangeConfiguration could reach the coherent path. Add !Number.isFinite(options.intervalMs) to the OR chain, mirroring the sibling checks. Docstring bullet rewritten to reflect the tightened coverage. Two new test cases lock the behavior: intervalMs=NaN and intervalMs=+Infinity both short-circuit to a zero sample with session state intact, matching the existing intervalMs=0 semantics. The describe block is renamed from 'intervalMs=0 defensive guard' to 'intervalMs defensive guard' to reflect the broader coverage. * chore(helpers): drop caller-coordination note on resetConnectorStatus (issue #40) The 'NOTE: transactionId is deleted here. Callers that need to identify the just-stopped transaction MUST snapshot ... BEFORE invoking this function.' comment described a caller-side coordination concern rather than the WHY of the delete. Every caller that needs the transactionId already snapshots it before the reset; the note added no local WHY and duplicated a responsibility that belongs in the caller, not in resetConnectorStatus. * docs(meter-values): drop 'legacy' framing for the random/fixed simulation mode (issue #40) The random/fixed measurand generation is not deprecated — it is one of two peer simulation modes exposed by the simulator, selected via the coherentMeterValues station flag. The word 'legacy' throughout the newly-introduced comments, JSDoc, README, and test descriptions inaccurately implied a deprecation path. Neutralize 13 sites across 6 files: 'legacy' either dropped where it was purely decorative (e.g. 'mirroring the legacy X path' → 'mirroring the X path') or replaced with 'random/fixed' / 'default' where the descriptor was substantive. No code changes, no test-logic changes, purely prose. * [autofix.ci] apply automated fixes * docs(meter-values): neutralize residual 'Legacy path' inline comment (issue #40) F12-A retitled the enclosing it() from 'via the legacy path' to 'via the random/fixed path' but the inline WHY comment two lines below still said 'Legacy path' — same author, same commit, same test block. Missed by the case-sensitive verification grep used at F12-A landing; this closes the neutralization loop. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 11efaab commit b907992

30 files changed

Lines changed: 4015 additions & 157 deletions

README.md

Lines changed: 116 additions & 64 deletions
Large diffs are not rendered by default.

scripts/bundle.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ await build({
6363
from: ['./src/assets/idtags!(-template)*.json'],
6464
to: ['./assets'],
6565
},
66+
{
67+
from: ['./src/assets/ev-profiles!(-template)*.json'],
68+
to: ['./assets'],
69+
},
6670
{
6771
from: ['./src/assets/json-schemas/**/*.json'],
6872
to: ['./assets/json-schemas'],
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"profiles": [
3+
{
4+
"id": "city-ev-40kWh",
5+
"weight": 3,
6+
"batteryCapacityWh": 40000,
7+
"maxPowerW": 11000,
8+
"initialSocPercentMin": 15,
9+
"initialSocPercentMax": 55,
10+
"chargingCurve": [
11+
{ "socPercent": 0, "powerFraction": 1.0 },
12+
{ "socPercent": 75, "powerFraction": 1.0 },
13+
{ "socPercent": 90, "powerFraction": 0.4 },
14+
{ "socPercent": 100, "powerFraction": 0.08 }
15+
]
16+
},
17+
{
18+
"id": "long-range-ev-77kWh",
19+
"weight": 2,
20+
"batteryCapacityWh": 77000,
21+
"maxPowerW": 22000,
22+
"initialSocPercentMin": 10,
23+
"initialSocPercentMax": 60,
24+
"chargingCurve": [
25+
{ "socPercent": 0, "powerFraction": 1.0 },
26+
{ "socPercent": 70, "powerFraction": 0.85 },
27+
{ "socPercent": 90, "powerFraction": 0.35 },
28+
{ "socPercent": 100, "powerFraction": 0.05 }
29+
]
30+
},
31+
{
32+
"id": "dc-fast-ev-90kWh",
33+
"weight": 1,
34+
"batteryCapacityWh": 90000,
35+
"maxPowerW": 150000,
36+
"initialSocPercentMin": 10,
37+
"initialSocPercentMax": 40,
38+
"chargingCurve": [
39+
{ "socPercent": 0, "powerFraction": 1.0 },
40+
{ "socPercent": 50, "powerFraction": 0.85 },
41+
{ "socPercent": 80, "powerFraction": 0.45 },
42+
{ "socPercent": 100, "powerFraction": 0.05 }
43+
]
44+
}
45+
]
46+
}

src/charging-station/ChargingStation.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ import {
132132
getConnectorChargingProfilesLimit,
133133
getDefaultConnectorMaximumPower,
134134
getDefaultVoltageOut,
135+
getEvProfilesFile,
135136
getHashId,
136137
getIdTagsFile,
137138
getMaxNumberOfConnectors,
@@ -147,6 +148,14 @@ import {
147148
validateStationInfo,
148149
} from './Helpers.js'
149150
import { IdTagsCache } from './IdTagsCache.js'
151+
import { disposeCoherentSessionRuntime } from './meter-values/CoherentMeterValuesGenerator.js'
152+
import {
153+
type CoherentSession,
154+
createCoherentSession,
155+
type EvProfilesFile,
156+
loadEvProfilesFile,
157+
resolveRootSeed,
158+
} from './meter-values/index.js'
150159
import {
151160
buildBootNotificationRequest,
152161
createOCPPServices,
@@ -206,6 +215,8 @@ export class ChargingStation extends EventEmitter {
206215

207216
private automaticTransactionGeneratorConfiguration?: AutomaticTransactionGeneratorConfiguration
208217
private readonly chargingStationWorkerBroadcastChannel: ChargingStationWorkerBroadcastChannel
218+
private coherentEvProfiles?: EvProfilesFile
219+
private readonly coherentSessions: Map<number | string, CoherentSession>
209220
private configurationFile!: string
210221
private configurationFileHash!: string
211222
private configuredSupervisionUrl!: URL
@@ -241,6 +252,7 @@ export class ChargingStation extends EventEmitter {
241252
this.sharedLRUCache = SharedLRUCache.getInstance()
242253
this.idTagsCache = IdTagsCache.getInstance()
243254
this.chargingStationWorkerBroadcastChannel = new ChargingStationWorkerBroadcastChannel(this)
255+
this.coherentSessions = new Map<number | string, CoherentSession>()
244256

245257
this.on(ChargingStationEvents.added, () => {
246258
parentPort?.postMessage(buildAddedMessage(this))
@@ -302,6 +314,24 @@ export class ChargingStation extends EventEmitter {
302314
}
303315
}
304316

317+
/**
318+
* Injects a pre-built coherent session directly into the session store.
319+
* **Test seam only** — never call from production code; enforced at
320+
* runtime by a `NODE_ENV === 'production'` guard that throws
321+
* {@link BaseError}.
322+
* @param transactionId - Transaction identifier.
323+
* @param session - Pre-built session.
324+
* @throws {BaseError} When invoked in a production build.
325+
*/
326+
public __injectCoherentSession (transactionId: number | string, session: CoherentSession): void {
327+
if (process.env.NODE_ENV === 'production') {
328+
throw new BaseError(
329+
`${this.logPrefix()} ${moduleName}.__injectCoherentSession: test-only seam called in production build`
330+
)
331+
}
332+
this.coherentSessions.set(transactionId, session)
333+
}
334+
305335
/**
306336
* Adds a reservation to the specified connector.
307337
* @param reservation - The reservation to add
@@ -345,6 +375,42 @@ export class ChargingStation extends EventEmitter {
345375
}
346376
}
347377

378+
/**
379+
* Creates or returns the coherent MeterValues session for a transaction.
380+
* Idempotent. Returns `undefined` when coherent mode is disabled or no
381+
* valid EV profile file is loaded.
382+
* @param transactionId - Transaction identifier from the CSMS.
383+
* @param connectorId - Connector on which the transaction is running.
384+
* @returns The active or newly-created session, or `undefined` when
385+
* coherent mode is not usable.
386+
*/
387+
public createCoherentSession (
388+
transactionId: number | string,
389+
connectorId: number
390+
): CoherentSession | undefined {
391+
const existing = this.coherentSessions.get(transactionId)
392+
if (existing != null) {
393+
return existing
394+
}
395+
if (this.stationInfo?.coherentMeterValues !== true) {
396+
return undefined
397+
}
398+
if (this.coherentEvProfiles == null || this.coherentEvProfiles.profiles.length === 0) {
399+
return undefined
400+
}
401+
const rootSeed = resolveRootSeed(this.stationInfo)
402+
const session = createCoherentSession(this, {
403+
connectorId,
404+
profiles: this.coherentEvProfiles.profiles,
405+
rootSeed,
406+
transactionId,
407+
})
408+
if (session != null) {
409+
this.coherentSessions.set(transactionId, session)
410+
}
411+
return session
412+
}
413+
348414
/**
349415
* Deletes the charging station instance and optionally its persisted configuration.
350416
* @param deleteConfiguration - Whether to delete the persisted configuration file
@@ -399,6 +465,21 @@ export class ChargingStation extends EventEmitter {
399465
this.removeAllListeners()
400466
}
401467

468+
/**
469+
* Removes the coherent session for a transaction. Idempotent — safe to
470+
* call from every reset/stop/disconnect path. Also disposes the module-scope
471+
* per-session runtime state (voltage-noise PRNG closure).
472+
* @param transactionId - Transaction identifier.
473+
* @returns `true` when a session was removed, `false` otherwise.
474+
*/
475+
public destroyCoherentSession (transactionId: number | string | undefined): boolean {
476+
if (transactionId == null) {
477+
return false
478+
}
479+
disposeCoherentSessionRuntime(this.coherentSessions.get(transactionId))
480+
return this.coherentSessions.delete(transactionId)
481+
}
482+
402483
/**
403484
* Emit a ChargingStation event only if there are listeners registered for it.
404485
* This optimizes performance by avoiding unnecessary event emission.
@@ -458,6 +539,15 @@ export class ChargingStation extends EventEmitter {
458539
return this.getConfigurationFromFile()?.automaticTransactionGeneratorStatuses
459540
}
460541

542+
/**
543+
* Retrieves the coherent session for a transaction, if any.
544+
* @param transactionId - Transaction identifier.
545+
* @returns The session or `undefined` when none exists.
546+
*/
547+
public getCoherentSession (transactionId: number | string): CoherentSession | undefined {
548+
return this.coherentSessions.get(transactionId)
549+
}
550+
461551
public getConnectionTimeout (): number {
462552
if (getConfigurationKey(this, StandardParametersKey.ConnectionTimeOut) != null) {
463553
return convertToInt(
@@ -1221,6 +1311,13 @@ export class ChargingStation extends EventEmitter {
12211311
this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash)
12221312
this.emitChargingStationEvent(ChargingStationEvents.stopped)
12231313
} finally {
1314+
// Drop any coherent sessions still tracked at shutdown so a
1315+
// subsequent restart cannot resurrect stale state or leak
1316+
// module-scope runtime PRNG closures.
1317+
for (const session of this.coherentSessions.values()) {
1318+
disposeCoherentSessionRuntime(session)
1319+
}
1320+
this.coherentSessions.clear()
12241321
this.stopping = false
12251322
}
12261323
} else {
@@ -1814,6 +1911,7 @@ export class ChargingStation extends EventEmitter {
18141911
}
18151912
}
18161913
this.saveStationInfo()
1914+
this.initializeCoherentEvProfiles()
18171915
this.configuredSupervisionUrl = this.getConfiguredSupervisionUrl()
18181916
if (this.stationInfo.enableStatistics === true) {
18191917
this.performanceStatistics = PerformanceStatistics.getInstance(
@@ -1844,6 +1942,33 @@ export class ChargingStation extends EventEmitter {
18441942
}
18451943
}
18461944

1945+
/**
1946+
* Loads and validates the EV profile file when coherent MeterValues are
1947+
* enabled. Fail-soft: any error disables coherent mode for this station
1948+
* (createCoherentSession then becomes a no-op).
1949+
*/
1950+
private initializeCoherentEvProfiles (): void {
1951+
this.coherentEvProfiles = undefined
1952+
if (this.stationInfo?.coherentMeterValues !== true) {
1953+
return
1954+
}
1955+
const evProfilesFile = getEvProfilesFile(this.stationInfo)
1956+
if (evProfilesFile == null) {
1957+
logger.warn(
1958+
`${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: coherentMeterValues=true but no evProfilesFile is configured, coherent MeterValues disabled`
1959+
)
1960+
return
1961+
}
1962+
const loaded = loadEvProfilesFile(evProfilesFile, this.logPrefix())
1963+
if (loaded == null) {
1964+
logger.warn(
1965+
`${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: EV profiles could not be loaded, coherent MeterValues disabled`
1966+
)
1967+
return
1968+
}
1969+
this.coherentEvProfiles = loaded
1970+
}
1971+
18471972
private initializeConnectorsFromTemplate (stationTemplate: ChargingStationTemplate): void {
18481973
if (stationTemplate.Connectors == null && isEmpty(this.connectors)) {
18491974
const errorMsg = `No already defined connectors and charging station information from template ${this.templateFile} with no connectors configuration defined`

src/charging-station/Helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,12 @@ export const getIdTagsFile = (stationInfo: ChargingStationInfo): string | undefi
812812
: undefined
813813
}
814814

815+
export const getEvProfilesFile = (stationInfo: ChargingStationInfo): string | undefined => {
816+
return stationInfo.evProfilesFile != null
817+
? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.evProfilesFile))
818+
: undefined
819+
}
820+
815821
export const waitChargingStationEvents = async (
816822
emitter: EventEmitter,
817823
event: ChargingStationWorkerMessageEvents,

src/charging-station/TemplateSchema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,14 @@ const BaseTemplateSchema = z.looseObject({
181181
chargePointModel: z.string().min(1),
182182
chargePointSerialNumberPrefix: z.string().optional(),
183183
chargePointVendor: z.string().min(1),
184+
coherentMeterValues: z.boolean().optional(),
184185
commandsSupport: CommandsSupportSchema.optional(),
185186
Configuration: OcppConfigurationSchema.optional(),
186187
Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema).optional(),
187188
currentOutType: z.string().optional(),
188189
customValueLimitationMeterValues: z.boolean().optional(),
189190
enableStatistics: z.boolean().optional(),
191+
evProfilesFile: z.string().optional(),
190192
Evses: z.record(z.string().regex(/^\d+$/), EvseTemplateSchema).optional(),
191193
firmwareUpgrade: FirmwareUpgradeSchema.optional(),
192194
firmwareVersion: z.string().optional(),
@@ -215,6 +217,7 @@ const BaseTemplateSchema = z.looseObject({
215217
powerSharedByConnectors: z.boolean().optional(),
216218
powerUnit: z.string().optional(),
217219
randomConnectors: z.boolean().optional(),
220+
randomSeed: z.number().int().optional(),
218221
reconnectExponentialDelay: z.boolean().optional(),
219222
registrationMaxRetries: z.number().optional(),
220223
remoteAuthorization: z.boolean().optional(),

src/charging-station/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export {
4242
} from './Helpers.js'
4343
export type { IBootstrap } from './IBootstrap.js'
4444
export { IdTagsCache } from './IdTagsCache.js'
45+
export type { CoherentSession } from './meter-values/index.js'
4546
export { SharedLRUCache } from './SharedLRUCache.js'
4647
export { applyMigration, coerceVersion, CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
4748
export { StrictTemplateSchema, TemplateSchema } from './TemplateSchema.js'

0 commit comments

Comments
 (0)