From b90799280c014eb89e09e9a97103fd362ba00131 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 2 Jul 2026 19:26:49 +0200 Subject: [PATCH] feat(meter-values): coherent MeterValues generator (issue #40) (#1935) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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` 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 `` 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`). - **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` 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`, 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> --- README.md | 180 +-- scripts/bundle.js | 4 + src/assets/ev-profiles-template.json | 46 + src/charging-station/ChargingStation.ts | 125 +++ src/charging-station/Helpers.ts | 6 + src/charging-station/TemplateSchema.ts | 3 + src/charging-station/index.ts | 1 + .../CoherentMeterValuesGenerator.ts | 820 ++++++++++++++ .../meter-values/EvProfiles.ts | 140 +++ src/charging-station/meter-values/Prng.ts | 66 ++ src/charging-station/meter-values/index.ts | 28 + src/charging-station/meter-values/types.ts | 124 ++ .../ocpp/1.6/OCPP16ResponseService.ts | 84 +- .../ocpp/1.6/OCPP16ServiceUtils.ts | 25 + .../ocpp/2.0/OCPP20IncomingRequestService.ts | 30 +- .../ocpp/2.0/OCPP20ResponseService.ts | 16 + .../ocpp/2.0/OCPP20ServiceUtils.ts | 62 +- src/charging-station/ocpp/OCPPServiceUtils.ts | 200 +++- src/types/ChargingStationTemplate.ts | 23 + src/utils/Constants.ts | 12 + .../ChargingStationTestConstants.ts | 19 +- .../helpers/StationHelpers.ts | 36 +- .../CoherentMeterValuesGenerator.test.ts | 1000 +++++++++++++++++ .../meter-values/EvProfiles.test.ts | 207 ++++ .../meter-values/Prng.test.ts | 107 ++ .../meter-values/StrategyDispatch.test.ts | 197 ++++ .../1.6/OCPP16-CoherentMeterValues.test.ts | 399 +++++++ ...16ResponseService-ForceTxOnInvalid.test.ts | 6 +- ...P20ResponseService-CoherentSession.test.ts | 150 +++ ...0ServiceUtils-PostTransactionDelay.test.ts | 56 +- 30 files changed, 4015 insertions(+), 157 deletions(-) create mode 100644 src/assets/ev-profiles-template.json create mode 100644 src/charging-station/meter-values/CoherentMeterValuesGenerator.ts create mode 100644 src/charging-station/meter-values/EvProfiles.ts create mode 100644 src/charging-station/meter-values/Prng.ts create mode 100644 src/charging-station/meter-values/index.ts create mode 100644 src/charging-station/meter-values/types.ts create mode 100644 tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts create mode 100644 tests/charging-station/meter-values/EvProfiles.test.ts create mode 100644 tests/charging-station/meter-values/Prng.test.ts create mode 100644 tests/charging-station/meter-values/StrategyDispatch.test.ts create mode 100644 tests/charging-station/ocpp/1.6/OCPP16-CoherentMeterValues.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20ResponseService-CoherentSession.test.ts diff --git a/README.md b/README.md index f103406e..ad7804b4 100644 --- a/README.md +++ b/README.md @@ -200,70 +200,73 @@ But the modifications to test have to be done to the files in the build target d **src/assets/station-templates/\.json**: -| Key | Value(s) | Default Value | Value type | Description | -| ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| supervisionUrls | | [] | string \| string[] | string or strings array containing connection URIs to OCPP-J servers | -| supervisionUser | | undefined | string | basic HTTP authentication user to OCPP-J server | -| supervisionPassword | | undefined | string | basic HTTP authentication password to OCPP-J server | -| supervisionUrlOcppConfiguration | true/false | false | boolean | enable supervision URL configuration via a vendor OCPP parameter key | -| supervisionUrlOcppKey | | 'ConnectionUrl' | string | the vendor string that will be used as a vendor OCPP parameter key to set the supervision URL | -| autoStart | true/false | true | boolean | enable automatic start of added charging station from template | -| ocppVersion | 1.6/2.0/2.0.1 | 1.6 | string | OCPP version | -| ocppProtocol | json | json | string | OCPP protocol | -| ocppStrictCompliance | true/false | true | boolean | enable strict adherence to the OCPP version and protocol specifications with OCPP commands PDU validation against [OCA](https://www.openchargealliance.org/) JSON schemas | -| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | -| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | -| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | -| wsOptions | | {} | ClientOptions & ClientRequestArgs | [ws](https://github.com/websockets/ws) and node.js [http](https://nodejs.org/api/http.html) clients options intersection | -| idTagsFile | | undefined | string | RFID tags list file relative to [src/assets](./src/assets) path | -| iccid | | undefined | string | SIM card ICCID | -| imsi | | undefined | string | SIM card IMSI | -| baseName | | undefined | string | base name to build charging stations id | -| nameSuffix | | undefined | string | name suffix to build charging stations id | -| fixedName | true/false | false | boolean | use the 'baseName' as the charging stations unique name | -| chargePointModel | | undefined | string | charging stations model | -| chargePointVendor | | undefined | string | charging stations vendor | -| chargePointSerialNumberPrefix | | undefined | string | charge point serial number prefix | -| chargeBoxSerialNumberPrefix | | undefined | string | charge box serial number prefix (deprecated since OCPP 1.6) | -| meterSerialNumberPrefix | | undefined | string | meter serial number prefix | -| meterType | | undefined | string | meter type | -| firmwareVersionPattern | | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string | charging stations firmware version pattern | -| firmwareVersion | | undefined | string | charging stations firmware version | -| power | | | float \| float[] | charging stations maximum power value(s) | -| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors. When true, any single connector can draw up to the full station power; when false, each connector is allocated an equal share | -| powerUnit | W/kW | W | string | charging stations power unit | -| currentOutType | AC/DC | AC | string | charging stations current out type | -| voltageOut | | AC:230/DC:400 | integer | charging stations voltage out | -| numberOfPhases | 0/1/3 | AC:3/DC:0 | integer | charging stations number of phase(s) | -| numberOfConnectors | | | integer \| integer[] | charging stations number of connector(s) | -| useConnectorId0 | true/false | true | boolean | use connector id 0 definition from the charging station configuration template | -| randomConnectors | true/false | false | boolean | randomize runtime connector id affectation from the connector id definition in charging station configuration template | -| resetTime | | 60 | integer | seconds to wait before the charging stations come back at reset | -| autoRegister | true/false | false | boolean | set charging stations as registered at boot notification for testing purpose | -| autoReconnectMaxRetries | | -1 (unlimited) | integer | connection retries to the OCPP-J server | -| reconnectExponentialDelay | true/false | false | boolean | connection delay retry to the OCPP-J server | -| registrationMaxRetries | | -1 (unlimited) | integer | charging stations boot notification retries | -| amperageLimitationOcppKey | | undefined | string | charging stations OCPP parameter key used to set the amperage limit, per phase for each connector on AC and global for DC | -| amperageLimitationUnit | A/cA/dA/mA | A | string | charging stations amperage limit unit | -| enableStatistics | true/false | false | boolean | enable charging stations statistics | -| remoteAuthorization | true/false | true | boolean | enable RFID tags remote authorization | -| forceTransactionOnInvalidIdToken | true/false | false | boolean | continue station-initiated transactions when CSMS rejects the IdToken (`idTagInfo.status` ≠ Accepted in 1.6; `idTokenInfo.status` ≠ Accepted on `eventType=Started` in 2.0.1; mid-tx revocation on `Updated`/`Ended` still tears down). Non-spec-compliant when true (violates OCPP 2.0.1 E05.FR.09 / E05.FR.10 / E06.FR.04); independent of `ocppStrictCompliance`; distinct from OCPP variables `StopTransactionOnInvalidId` / `StopTxOnInvalidId` (mid-tx stop control) | -| beginEndMeterValues | true/false | false | boolean | enable Transaction.{Begin,End} MeterValues | -| outOfOrderEndMeterValues | true/false | false | boolean | send Transaction.End MeterValues out of order. Need to relax OCPP specifications strict compliance ('ocppStrictCompliance' parameter) | -| meteringPerTransaction | true/false | true | boolean | enable metering history on a per transaction basis | -| transactionDataMeterValues | true/false | false | boolean | enable transaction data MeterValues at stop transaction | -| stopTransactionsOnStopped | true/false | true | boolean | enable stop transactions on charging station stop | -| postTransactionDelay | ≥ 0 | 0 | integer | seconds to wait after transaction stop before transitioning connector to Available. Simulates cable-unplug delay. In OCPP 1.6 the connector stays in Finishing state; in OCPP 2.0.x it stays Occupied. 0 = immediate Available (default behavior) | -| mainVoltageMeterValues | true/false | true | boolean | include charging stations main voltage MeterValues on three phased charging stations | -| phaseLineToLineVoltageMeterValues | true/false | false | boolean | include charging stations line to line voltage MeterValues on three phased charging stations | -| customValueLimitationMeterValues | true/false | true | boolean | enable limitation on custom fluctuated value in MeterValues | -| firmwareUpgrade | | {
"versionUpgrade": {
"step": 1
},
"reset": true
} | {
versionUpgrade?: {
patternGroup?: number;
step?: number;
};
reset?: boolean;
failureStatus?: 'DownloadFailed' \| 'InstallationFailed';
} | Configuration section for simulating firmware upgrade support. | -| commandsSupport | | {
"incomingCommands": {},
"outgoingCommands": {}
} | {
incomingCommands: Record;
outgoingCommands?: Record;
} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported | -| messageTriggerSupport | | {} | Record | Configuration section for OCPP commands trigger support. Empty section means all implemented OCPP trigger commands are supported | -| Configuration | | | ChargingStationOcppConfiguration | charging stations OCPP parameters configuration section | -| AutomaticTransactionGenerator | | | AutomaticTransactionGeneratorConfiguration | charging stations ATG configuration section | -| Connectors | | | Record | charging stations connectors configuration section | -| Evses | | | Record | charging stations EVSEs configuration section | +| Key | Value(s) | Default Value | Value type | Description | +| ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| supervisionUrls | | [] | string \| string[] | string or strings array containing connection URIs to OCPP-J servers | +| supervisionUser | | undefined | string | basic HTTP authentication user to OCPP-J server | +| supervisionPassword | | undefined | string | basic HTTP authentication password to OCPP-J server | +| supervisionUrlOcppConfiguration | true/false | false | boolean | enable supervision URL configuration via a vendor OCPP parameter key | +| supervisionUrlOcppKey | | 'ConnectionUrl' | string | the vendor string that will be used as a vendor OCPP parameter key to set the supervision URL | +| autoStart | true/false | true | boolean | enable automatic start of added charging station from template | +| ocppVersion | 1.6/2.0/2.0.1 | 1.6 | string | OCPP version | +| ocppProtocol | json | json | string | OCPP protocol | +| ocppStrictCompliance | true/false | true | boolean | enable strict adherence to the OCPP version and protocol specifications with OCPP commands PDU validation against [OCA](https://www.openchargealliance.org/) JSON schemas | +| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | +| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | +| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | +| wsOptions | | {} | ClientOptions & ClientRequestArgs | [ws](https://github.com/websockets/ws) and node.js [http](https://nodejs.org/api/http.html) clients options intersection | +| idTagsFile | | undefined | string | RFID tags list file relative to [src/assets](./src/assets) path | +| iccid | | undefined | string | SIM card ICCID | +| imsi | | undefined | string | SIM card IMSI | +| baseName | | undefined | string | base name to build charging stations id | +| nameSuffix | | undefined | string | name suffix to build charging stations id | +| fixedName | true/false | false | boolean | use the 'baseName' as the charging stations unique name | +| chargePointModel | | undefined | string | charging stations model | +| chargePointVendor | | undefined | string | charging stations vendor | +| chargePointSerialNumberPrefix | | undefined | string | charge point serial number prefix | +| chargeBoxSerialNumberPrefix | | undefined | string | charge box serial number prefix (deprecated since OCPP 1.6) | +| meterSerialNumberPrefix | | undefined | string | meter serial number prefix | +| meterType | | undefined | string | meter type | +| firmwareVersionPattern | | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string | charging stations firmware version pattern | +| firmwareVersion | | undefined | string | charging stations firmware version | +| power | | | float \| float[] | charging stations maximum power value(s) | +| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors. When true, any single connector can draw up to the full station power; when false, each connector is allocated an equal share | +| powerUnit | W/kW | W | string | charging stations power unit | +| currentOutType | AC/DC | AC | string | charging stations current out type | +| voltageOut | | AC:230/DC:400 | integer | charging stations voltage out | +| numberOfPhases | 0/1/3 | AC:3/DC:0 | integer | charging stations number of phase(s) | +| numberOfConnectors | | | integer \| integer[] | charging stations number of connector(s) | +| useConnectorId0 | true/false | true | boolean | use connector id 0 definition from the charging station configuration template | +| randomConnectors | true/false | false | boolean | randomize runtime connector id affectation from the connector id definition in charging station configuration template | +| resetTime | | 60 | integer | seconds to wait before the charging stations come back at reset | +| autoRegister | true/false | false | boolean | set charging stations as registered at boot notification for testing purpose | +| autoReconnectMaxRetries | | -1 (unlimited) | integer | connection retries to the OCPP-J server | +| reconnectExponentialDelay | true/false | false | boolean | connection delay retry to the OCPP-J server | +| registrationMaxRetries | | -1 (unlimited) | integer | charging stations boot notification retries | +| amperageLimitationOcppKey | | undefined | string | charging stations OCPP parameter key used to set the amperage limit, per phase for each connector on AC and global for DC | +| amperageLimitationUnit | A/cA/dA/mA | A | string | charging stations amperage limit unit | +| enableStatistics | true/false | false | boolean | enable charging stations statistics | +| remoteAuthorization | true/false | true | boolean | enable RFID tags remote authorization | +| forceTransactionOnInvalidIdToken | true/false | false | boolean | continue station-initiated transactions when CSMS rejects the IdToken (`idTagInfo.status` ≠ Accepted in 1.6; `idTokenInfo.status` ≠ Accepted on `eventType=Started` in 2.0.1; mid-tx revocation on `Updated`/`Ended` still tears down). Non-spec-compliant when true (violates OCPP 2.0.1 E05.FR.09 / E05.FR.10 / E06.FR.04); independent of `ocppStrictCompliance`; distinct from OCPP variables `StopTransactionOnInvalidId` / `StopTxOnInvalidId` (mid-tx stop control) | +| beginEndMeterValues | true/false | false | boolean | enable Transaction.{Begin,End} MeterValues | +| outOfOrderEndMeterValues | true/false | false | boolean | send Transaction.End MeterValues out of order. Need to relax OCPP specifications strict compliance ('ocppStrictCompliance' parameter) | +| meteringPerTransaction | true/false | true | boolean | enable metering history on a per transaction basis | +| transactionDataMeterValues | true/false | false | boolean | enable transaction data MeterValues at stop transaction | +| stopTransactionsOnStopped | true/false | true | boolean | enable stop transactions on charging station stop | +| postTransactionDelay | ≥ 0 | 0 | integer | seconds to wait after transaction stop before transitioning connector to Available. Simulates cable-unplug delay. In OCPP 1.6 the connector stays in Finishing state; in OCPP 2.0.x it stays Occupied. 0 = immediate Available (default behavior) | +| mainVoltageMeterValues | true/false | true | boolean | include charging stations main voltage MeterValues on three-phase charging stations | +| phaseLineToLineVoltageMeterValues | true/false | false | boolean | include charging stations line-to-line voltage MeterValues on three-phase charging stations | +| customValueLimitationMeterValues | true/false | true | boolean | enable limitation on custom fluctuated value in MeterValues | +| coherentMeterValues | true/false | false | boolean | enable physics-based coherent MeterValues generation. When `true`, emitted voltage, current, power, imported-energy register, and SoC are derived from a single physics chain. INV-1 is `P = V·I·phases` for AC and `P = V·I` for DC; INV-2 is monotone non-decreasing SoC; INV-3 is `ΔE = P·Δt/3.6e6` with a non-decreasing energy register. `ΔE` is an internal per-sample intermediate, not an emitted measurand. Requires `evProfilesFile` to be set; falls back to the default random/fixed generation with a warning if the file is absent or malformed | +| evProfilesFile | | undefined | string | EV profile file relative to [src/assets](./src/assets) path. Consumed only when `coherentMeterValues` is `true`. See [EV profile file format](#ev-profile-file-format) | +| randomSeed | | undefined (derived from `hashId` via FNV-1a) | integer | optional 32-bit unsigned integer seed for the coherent MeterValues PRNG. When set, identical `(randomSeed, transactionId)` inputs produce identical PRNG streams (and therefore identical per-measurand noise sequences); emitted energy and SoC additionally depend on `intervalMs` and elapsed session time. When absent, the seed is derived deterministically from the station `hashId`. Ignored when `coherentMeterValues` is `false` or absent | +| firmwareUpgrade | | {
"versionUpgrade": {
"step": 1
},
"reset": true
} | {
versionUpgrade?: {
patternGroup?: number;
step?: number;
};
reset?: boolean;
failureStatus?: 'DownloadFailed' \| 'InstallationFailed';
} | Configuration section for simulating firmware upgrade support. | +| commandsSupport | | {
"incomingCommands": {},
"outgoingCommands": {}
} | {
incomingCommands: Record;
outgoingCommands?: Record;
} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported | +| messageTriggerSupport | | {} | Record | Configuration section for OCPP commands trigger support. Empty section means all implemented OCPP trigger commands are supported | +| Configuration | | | ChargingStationOcppConfiguration | charging stations OCPP parameters configuration section | +| AutomaticTransactionGenerator | | | AutomaticTransactionGeneratorConfiguration | charging stations ATG configuration section | +| Connectors | | | Record | charging stations connectors configuration section | +| Evses | | | Record | charging stations EVSEs configuration section | #### Configuration section syntax example @@ -444,6 +447,55 @@ type AutomaticTransactionGeneratorConfiguration = { }, ``` +#### EV profile file format + +The EV profile file is a JSON file referenced by the `evProfilesFile` template field. It defines a pool of EV charging profiles from which one is selected per transaction (weighted random, seeded deterministically). Consumed only when `coherentMeterValues` is `true`. + +**Schema** (`src/assets/.json`): + +```json +{ + "profiles": [ + { + "id": "city-ev-40kWh", + "weight": 3, + "batteryCapacityWh": 40000, + "maxPowerW": 11000, + "initialSocPercentMin": 15, + "initialSocPercentMax": 55, + "chargingCurve": [ + { "socPercent": 0, "powerFraction": 1.0 }, + { "socPercent": 75, "powerFraction": 1.0 }, + { "socPercent": 90, "powerFraction": 0.4 }, + { "socPercent": 100, "powerFraction": 0.08 } + ] + } + ] +} +``` + +| Field | Type | Constraints | Description | +| ---------------------- | --------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | non-empty | Unique profile identifier (used in logs) | +| `batteryCapacityWh` | number | > 0 | Battery capacity in Wh. Bounds ΔSoC per ΔE sample | +| `maxPowerW` | number | > 0 | Maximum EV acceptance power in W at SoC = 0 | +| `weight` | number | ≥ 0 | Relative selection weight. Higher weight increases the probability this profile is chosen for a transaction. A weight of 0 disables the profile | +| `initialSocPercentMin` | number | [0, 100] | Minimum initial SoC (%) at transaction start. If greater than `initialSocPercentMax`, the two values are swapped and a warning is logged | +| `initialSocPercentMax` | number | [0, 100] | Maximum initial SoC (%) at transaction start. See `initialSocPercentMin` for swap-and-warn behavior when bounds are inverted | +| `chargingCurve` | `{ socPercent, powerFraction }[]` | non-empty, sorted | Piecewise-linear taper of EV acceptance power as a fraction of `maxPowerW`. Must be sorted non-decreasing by `socPercent`. `powerFraction` in [0, 1] | + +A template file is available at [src/assets/ev-profiles-template.json](./src/assets/ev-profiles-template.json). + +**Template resolution scope.** When `coherentMeterValues` is `true`, the coherent generator reads `MeterValues` templates from the connector-level definition only. EVSE-level `MeterValues` inheritance is not applied, so define per-connector `MeterValues` inside each connector entry. + +**Phase-qualified measurands.** When a connector template carries a `phase` field, the coherent generator emits one `SampledValue` per matching template with phase-aware values: + +- `Voltage`: `L1-N`/`L2-N`/`L3-N` emit the sampled phase voltage; `L1-L2`/`L2-L3`/`L3-L1` emit `sqrt(phases) × sampled phase voltage` (unsupported when `phases < 2`); `N` emits 0. +- `Power.Active.Import`: no phase emits total power; `L1-N`/`L2-N`/`L3-N` emit `P / phases`; line-to-line and `N` phases are unsupported and the template is skipped with a warning. +- `Current.Import`: any line phase (`L1`/`L2`/`L3` or `L1-N`/`L2-N`/`L3-N`) emits `sample.currentA` (balanced 3-phase Y assumption); `N` emits 0; line-to-line phase is unsupported. +- `Energy.Active.Import.Register`: no phase emits the aggregate register; `L1-N`/`L2-N`/`L3-N` emit `register / phases` (balanced 3-phase Y contribution per phase); line-to-line and `N` phases are unsupported. OCPP 2.0.1 `SampledDataCtrlr.RegisterValuesWithoutPhases` is not consulted; per-phase emission is driven by the connector template's phase qualifier. +- `SoC`: aggregate scalar; phase-qualified templates are skipped with a warning. + ### Charging station configuration **dist/assets/configurations/\.json**: diff --git a/scripts/bundle.js b/scripts/bundle.js index 70af7e55..6f51a104 100644 --- a/scripts/bundle.js +++ b/scripts/bundle.js @@ -63,6 +63,10 @@ await build({ from: ['./src/assets/idtags!(-template)*.json'], to: ['./assets'], }, + { + from: ['./src/assets/ev-profiles!(-template)*.json'], + to: ['./assets'], + }, { from: ['./src/assets/json-schemas/**/*.json'], to: ['./assets/json-schemas'], diff --git a/src/assets/ev-profiles-template.json b/src/assets/ev-profiles-template.json new file mode 100644 index 00000000..ab0c2aa1 --- /dev/null +++ b/src/assets/ev-profiles-template.json @@ -0,0 +1,46 @@ +{ + "profiles": [ + { + "id": "city-ev-40kWh", + "weight": 3, + "batteryCapacityWh": 40000, + "maxPowerW": 11000, + "initialSocPercentMin": 15, + "initialSocPercentMax": 55, + "chargingCurve": [ + { "socPercent": 0, "powerFraction": 1.0 }, + { "socPercent": 75, "powerFraction": 1.0 }, + { "socPercent": 90, "powerFraction": 0.4 }, + { "socPercent": 100, "powerFraction": 0.08 } + ] + }, + { + "id": "long-range-ev-77kWh", + "weight": 2, + "batteryCapacityWh": 77000, + "maxPowerW": 22000, + "initialSocPercentMin": 10, + "initialSocPercentMax": 60, + "chargingCurve": [ + { "socPercent": 0, "powerFraction": 1.0 }, + { "socPercent": 70, "powerFraction": 0.85 }, + { "socPercent": 90, "powerFraction": 0.35 }, + { "socPercent": 100, "powerFraction": 0.05 } + ] + }, + { + "id": "dc-fast-ev-90kWh", + "weight": 1, + "batteryCapacityWh": 90000, + "maxPowerW": 150000, + "initialSocPercentMin": 10, + "initialSocPercentMax": 40, + "chargingCurve": [ + { "socPercent": 0, "powerFraction": 1.0 }, + { "socPercent": 50, "powerFraction": 0.85 }, + { "socPercent": 80, "powerFraction": 0.45 }, + { "socPercent": 100, "powerFraction": 0.05 } + ] + } + ] +} diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 4752ee4f..44d250e2 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -132,6 +132,7 @@ import { getConnectorChargingProfilesLimit, getDefaultConnectorMaximumPower, getDefaultVoltageOut, + getEvProfilesFile, getHashId, getIdTagsFile, getMaxNumberOfConnectors, @@ -147,6 +148,14 @@ import { validateStationInfo, } from './Helpers.js' import { IdTagsCache } from './IdTagsCache.js' +import { disposeCoherentSessionRuntime } from './meter-values/CoherentMeterValuesGenerator.js' +import { + type CoherentSession, + createCoherentSession, + type EvProfilesFile, + loadEvProfilesFile, + resolveRootSeed, +} from './meter-values/index.js' import { buildBootNotificationRequest, createOCPPServices, @@ -206,6 +215,8 @@ export class ChargingStation extends EventEmitter { private automaticTransactionGeneratorConfiguration?: AutomaticTransactionGeneratorConfiguration private readonly chargingStationWorkerBroadcastChannel: ChargingStationWorkerBroadcastChannel + private coherentEvProfiles?: EvProfilesFile + private readonly coherentSessions: Map private configurationFile!: string private configurationFileHash!: string private configuredSupervisionUrl!: URL @@ -241,6 +252,7 @@ export class ChargingStation extends EventEmitter { this.sharedLRUCache = SharedLRUCache.getInstance() this.idTagsCache = IdTagsCache.getInstance() this.chargingStationWorkerBroadcastChannel = new ChargingStationWorkerBroadcastChannel(this) + this.coherentSessions = new Map() this.on(ChargingStationEvents.added, () => { parentPort?.postMessage(buildAddedMessage(this)) @@ -302,6 +314,24 @@ export class ChargingStation extends EventEmitter { } } + /** + * Injects a pre-built coherent session directly into the session store. + * **Test seam only** — never call from production code; enforced at + * runtime by a `NODE_ENV === 'production'` guard that throws + * {@link BaseError}. + * @param transactionId - Transaction identifier. + * @param session - Pre-built session. + * @throws {BaseError} When invoked in a production build. + */ + public __injectCoherentSession (transactionId: number | string, session: CoherentSession): void { + if (process.env.NODE_ENV === 'production') { + throw new BaseError( + `${this.logPrefix()} ${moduleName}.__injectCoherentSession: test-only seam called in production build` + ) + } + this.coherentSessions.set(transactionId, session) + } + /** * Adds a reservation to the specified connector. * @param reservation - The reservation to add @@ -345,6 +375,42 @@ export class ChargingStation extends EventEmitter { } } + /** + * Creates or returns the coherent MeterValues session for a transaction. + * Idempotent. Returns `undefined` when coherent mode is disabled or no + * valid EV profile file is loaded. + * @param transactionId - Transaction identifier from the CSMS. + * @param connectorId - Connector on which the transaction is running. + * @returns The active or newly-created session, or `undefined` when + * coherent mode is not usable. + */ + public createCoherentSession ( + transactionId: number | string, + connectorId: number + ): CoherentSession | undefined { + const existing = this.coherentSessions.get(transactionId) + if (existing != null) { + return existing + } + if (this.stationInfo?.coherentMeterValues !== true) { + return undefined + } + if (this.coherentEvProfiles == null || this.coherentEvProfiles.profiles.length === 0) { + return undefined + } + const rootSeed = resolveRootSeed(this.stationInfo) + const session = createCoherentSession(this, { + connectorId, + profiles: this.coherentEvProfiles.profiles, + rootSeed, + transactionId, + }) + if (session != null) { + this.coherentSessions.set(transactionId, session) + } + return session + } + /** * Deletes the charging station instance and optionally its persisted configuration. * @param deleteConfiguration - Whether to delete the persisted configuration file @@ -399,6 +465,21 @@ export class ChargingStation extends EventEmitter { this.removeAllListeners() } + /** + * Removes the coherent session for a transaction. Idempotent — safe to + * call from every reset/stop/disconnect path. Also disposes the module-scope + * per-session runtime state (voltage-noise PRNG closure). + * @param transactionId - Transaction identifier. + * @returns `true` when a session was removed, `false` otherwise. + */ + public destroyCoherentSession (transactionId: number | string | undefined): boolean { + if (transactionId == null) { + return false + } + disposeCoherentSessionRuntime(this.coherentSessions.get(transactionId)) + return this.coherentSessions.delete(transactionId) + } + /** * Emit a ChargingStation event only if there are listeners registered for it. * This optimizes performance by avoiding unnecessary event emission. @@ -458,6 +539,15 @@ export class ChargingStation extends EventEmitter { return this.getConfigurationFromFile()?.automaticTransactionGeneratorStatuses } + /** + * Retrieves the coherent session for a transaction, if any. + * @param transactionId - Transaction identifier. + * @returns The session or `undefined` when none exists. + */ + public getCoherentSession (transactionId: number | string): CoherentSession | undefined { + return this.coherentSessions.get(transactionId) + } + public getConnectionTimeout (): number { if (getConfigurationKey(this, StandardParametersKey.ConnectionTimeOut) != null) { return convertToInt( @@ -1221,6 +1311,13 @@ export class ChargingStation extends EventEmitter { this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash) this.emitChargingStationEvent(ChargingStationEvents.stopped) } finally { + // Drop any coherent sessions still tracked at shutdown so a + // subsequent restart cannot resurrect stale state or leak + // module-scope runtime PRNG closures. + for (const session of this.coherentSessions.values()) { + disposeCoherentSessionRuntime(session) + } + this.coherentSessions.clear() this.stopping = false } } else { @@ -1814,6 +1911,7 @@ export class ChargingStation extends EventEmitter { } } this.saveStationInfo() + this.initializeCoherentEvProfiles() this.configuredSupervisionUrl = this.getConfiguredSupervisionUrl() if (this.stationInfo.enableStatistics === true) { this.performanceStatistics = PerformanceStatistics.getInstance( @@ -1844,6 +1942,33 @@ export class ChargingStation extends EventEmitter { } } + /** + * Loads and validates the EV profile file when coherent MeterValues are + * enabled. Fail-soft: any error disables coherent mode for this station + * (createCoherentSession then becomes a no-op). + */ + private initializeCoherentEvProfiles (): void { + this.coherentEvProfiles = undefined + if (this.stationInfo?.coherentMeterValues !== true) { + return + } + const evProfilesFile = getEvProfilesFile(this.stationInfo) + if (evProfilesFile == null) { + logger.warn( + `${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: coherentMeterValues=true but no evProfilesFile is configured, coherent MeterValues disabled` + ) + return + } + const loaded = loadEvProfilesFile(evProfilesFile, this.logPrefix()) + if (loaded == null) { + logger.warn( + `${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: EV profiles could not be loaded, coherent MeterValues disabled` + ) + return + } + this.coherentEvProfiles = loaded + } + private initializeConnectorsFromTemplate (stationTemplate: ChargingStationTemplate): void { if (stationTemplate.Connectors == null && isEmpty(this.connectors)) { const errorMsg = `No already defined connectors and charging station information from template ${this.templateFile} with no connectors configuration defined` diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index c98239ce..57d4ab0d 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -812,6 +812,12 @@ export const getIdTagsFile = (stationInfo: ChargingStationInfo): string | undefi : undefined } +export const getEvProfilesFile = (stationInfo: ChargingStationInfo): string | undefined => { + return stationInfo.evProfilesFile != null + ? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.evProfilesFile)) + : undefined +} + export const waitChargingStationEvents = async ( emitter: EventEmitter, event: ChargingStationWorkerMessageEvents, diff --git a/src/charging-station/TemplateSchema.ts b/src/charging-station/TemplateSchema.ts index 749f7b7e..53f50b39 100644 --- a/src/charging-station/TemplateSchema.ts +++ b/src/charging-station/TemplateSchema.ts @@ -181,12 +181,14 @@ const BaseTemplateSchema = z.looseObject({ chargePointModel: z.string().min(1), chargePointSerialNumberPrefix: z.string().optional(), chargePointVendor: z.string().min(1), + coherentMeterValues: z.boolean().optional(), commandsSupport: CommandsSupportSchema.optional(), Configuration: OcppConfigurationSchema.optional(), Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema).optional(), currentOutType: z.string().optional(), customValueLimitationMeterValues: z.boolean().optional(), enableStatistics: z.boolean().optional(), + evProfilesFile: z.string().optional(), Evses: z.record(z.string().regex(/^\d+$/), EvseTemplateSchema).optional(), firmwareUpgrade: FirmwareUpgradeSchema.optional(), firmwareVersion: z.string().optional(), @@ -215,6 +217,7 @@ const BaseTemplateSchema = z.looseObject({ powerSharedByConnectors: z.boolean().optional(), powerUnit: z.string().optional(), randomConnectors: z.boolean().optional(), + randomSeed: z.number().int().optional(), reconnectExponentialDelay: z.boolean().optional(), registrationMaxRetries: z.number().optional(), remoteAuthorization: z.boolean().optional(), diff --git a/src/charging-station/index.ts b/src/charging-station/index.ts index 95e0e029..0c7fc720 100644 --- a/src/charging-station/index.ts +++ b/src/charging-station/index.ts @@ -42,6 +42,7 @@ export { } from './Helpers.js' export type { IBootstrap } from './IBootstrap.js' export { IdTagsCache } from './IdTagsCache.js' +export type { CoherentSession } from './meter-values/index.js' export { SharedLRUCache } from './SharedLRUCache.js' export { applyMigration, coerceVersion, CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js' export { StrictTemplateSchema, TemplateSchema } from './TemplateSchema.js' diff --git a/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts b/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts new file mode 100644 index 00000000..1fe8dcce --- /dev/null +++ b/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts @@ -0,0 +1,820 @@ +// Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Physics-based coherent MeterValues generator. + * @description Constructs a coherent {@link MeterValue} in which every + * emitted measurand is derived from a single physics chain + * (V → P → I → ΔE → SoC) rather than sampled independently. + * + * Invariants (enforced by construction): + * - **INV-1**: AC: `P = V × I × phases`; DC: `P = V × I`. Emitted `powerW` + * is recomputed from the rounded emitted current and voltage so + * `|P - V·I·phases|` stays within the `ROUNDING_SCALE` half-width + * (≤ 0.005 W scalar bound) regardless of V or phases. Per-phase L-N + * `Power.Active.Import` emission is derived as + * `round(aggregate_P / phases, 2)`; the per-phase identity + * `|P_LxN - V_LxN · I_Lx|` therefore holds within `2 × ROUNDING_SCALE` + * half-width (≤ 0.01 W) — one half-width for the aggregate emit and + * one for the per-phase division. + * - **INV-2**: `SoC(t+1) ≥ SoC(t)` and `ΔSoC = ΔE / batteryCapacityWh × 100`. + * SoC monotone non-decreasing during charging and saturates at 100 %. + * - **INV-3**: `ΔE = P_clamped × Δt / MS_PER_HOUR` where `P_clamped` is the + * pre-emit-rounding capacity-clamped power. `E(t+1) ≥ E(t)`. A consumer + * integrating the post-rounding emitted `powerW` samples may diverge + * from the register by at most `ROUNDING_SCALE half-width × N × Δt / + * MS_PER_HOUR` Wh over `N` samples — bounded and invisible at + * `ROUNDING_SCALE` (~0.12 Wh over 24 h at 1 Hz). + * - `P ≤ min(EVSE_max, EV_acceptance(SoC))`. + * - `SoC ≥ 100 ⇒ P = 0, I = 0, ΔE = 0`. + * + * The generator owns the connector energy register update: it advances the + * register exactly once per sample, unconditionally, so `meterStop` is + * correct even when `Energy.Active.Import.Register` is not in the + * configured MeterValues. + * + * TODO(#1936): file size exceeds the 250 LOC ceiling documented in + * AGENTS.md. Modular split (CoherentSampleComputer.ts + CoherentMeterValueBuilder.ts, + * keeping session lifecycle helpers in this entry) tracked as follow-up. + */ + +import type { + ConnectorStatus, + MeterValue, + MeterValueContext, + SampledValue, + SampledValueTemplate, +} from '../../types/index.js' +import type { CoherentSession, EvProfile, ICoherentContext } from './types.js' + +import { + CurrentType, + MeterValueMeasurand, + MeterValuePhase, + MeterValueUnit, + Voltage, +} from '../../types/index.js' +import { Constants, logger, roundTo } from '../../utils/index.js' +import { interpolateChargingCurve, selectEvProfile } from './EvProfiles.js' +import { deriveSeed, hashLabel, mulberry32 } from './Prng.js' + +const moduleName = 'CoherentMeterValuesGenerator' + +/** + * Decimal places for all physics-quantity rounding (V, A, W, Wh, SoC). + * The `roundTo` half-width bound is `0.5 × 10^-ROUNDING_SCALE = 0.005` on + * each rounded quantity; INV-1 residual is bounded by this scalar. + */ +const ROUNDING_SCALE = 2 + +/** + * Signature of the versioned SampledValue builder returned by the + * OCPP-version dispatcher in {@link ../ocpp/OCPPServiceUtils.buildMeterValue}. + * Kept structurally compatible so a coherent generator can emit SampledValues + * in either OCPP 1.6 or 2.0 formats without knowing the version. + */ +export type BuildVersionedSampledValue = ( + sampledValueTemplate: SampledValueTemplate, + value: number, + context?: MeterValueContext, + phase?: MeterValuePhase +) => SampledValue + +/** + * Runtime-only per-session state. Kept in a module-scope WeakMap keyed by + * the {@link CoherentSession} object (rather than by transactionId) so + * runtime state is scoped to the session's identity — no cross-station + * coupling when two stations happen to share a transactionId — and is + * auto-collected when the session becomes unreachable. + */ +interface SessionRuntime { + voltagePrng?: () => number +} + +const sessionRuntimes = new WeakMap() + +/** + * Retrieves the runtime bag for a session, creating it on first access. + * Not exported: only the generator reads or writes runtime state. + * @param session - Coherent session. + * @returns Live runtime bag (mutated in place). + */ +const getSessionRuntime = (session: CoherentSession): SessionRuntime => { + let runtime = sessionRuntimes.get(session) + if (runtime == null) { + runtime = {} + sessionRuntimes.set(session, runtime) + } + return runtime +} + +/** + * Disposes runtime state for a session. Call from every session-teardown + * path (stop/reset/disconnect) to release cached PRNG state eagerly. + * The WeakMap makes eager disposal optional — unreachable sessions are + * collected automatically — but eager disposal preserves determinism + * across sequential transactions that reuse the same session identity. + * Idempotent. + * @param session - Coherent session (or `undefined` when the caller has + * no session at hand). + * @returns `true` when runtime was removed, `false` otherwise. + */ +export const disposeCoherentSessionRuntime = (session: CoherentSession | undefined): boolean => { + if (session == null) { + return false + } + return sessionRuntimes.delete(session) +} + +/** + * Deterministic per-transaction stream splitter. Combines the station + * `randomSeed` (or a stable fallback), the transactionId, and a label so + * that adding a new consumer never shifts an existing stream's sequence. + * @param rootSeed - Root 32-bit seed for the station. + * @param transactionId - Transaction identifier. + * @param label - Stream label (`'VOLTAGE_NOISE'`, `'POWER_NOISE'`, ...). + * @returns PRNG function producing [0, 1) floats. + */ +export const createStreamPrng = ( + rootSeed: number, + transactionId: number | string, + label: string +): (() => number) => { + // Namespace the transactionId leg with a `tx:` prefix so + // `String(transactionId) === label` cannot trigger the XOR self-inverse + // `deriveSeed(deriveSeed(r, X), X) === r`. Labels never start with `tx:` + // by construction (`VOLTAGE_NOISE`, `POWER_NOISE`, ...). + const txSeed = deriveSeed(rootSeed, `tx:${String(transactionId)}`) + return mulberry32(deriveSeed(txSeed, label)) +} + +/** + * Symmetric fluctuation helper: draws a uniform sample and maps it into + * `[base * (1 - percent), base * (1 + percent))`. + * @param base - Nominal value. + * @param percent - Half-width of the symmetric interval (e.g. 0.01 = ±1 %). + * @param prng - Seeded PRNG stream. + * @returns Fluctuated value. + */ +const fluctuate = (base: number, percent: number, prng: () => number): number => { + return base * (1 + (prng() * 2 - 1) * percent) +} + +/** + * Determines whether coherent mode is enabled on the given context and a + * session exists for the transaction. Returned by the strategy gate to + * decide dispatch. + * @param context - Charging-station context (subset of `ChargingStation`). + * @param transactionId - Transaction identifier. + * @returns `true` if coherent mode should own MeterValue construction. + */ +export const isCoherentModeActive = ( + context: ICoherentContext, + transactionId: number | string +): boolean => { + if (context.stationInfo?.coherentMeterValues !== true) { + return false + } + return context.getCoherentSession(transactionId) != null +} + +/** + * Unconditionally advances the connector energy registers by `deltaEnergyWh`. + * The coherent path owns register updates so `meterStop` stays correct even + * when the `Energy.Active.Import.Register` measurand is not configured. + * Negative or nullish register starting values are clamped to zero before + * accrual. + * @param connectorStatus - Target connector status (in-place update). + * @param deltaEnergyWh - Energy delta (Wh). + */ +export const advanceEnergyRegister = ( + connectorStatus: ConnectorStatus | undefined, + deltaEnergyWh: number +): void => { + if (connectorStatus == null) { + return + } + connectorStatus.energyActiveImportRegisterValue = + Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0) + deltaEnergyWh + connectorStatus.transactionEnergyActiveImportRegisterValue = + Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + deltaEnergyWh +} + +/** + * Coherent physics sample. All fields follow the `` naming + * convention (`currentA`, `powerW`, `voltageV`, ...). + */ +export interface CoherentSample { + currentA: number + deltaEnergyWh: number + energyRegisterWh: number + powerW: number + socPercent: number + voltageV: number +} + +/** + * Options for {@link computeCoherentSample}. + */ +export interface ComputeSampleOptions { + /** + * Sample interval in milliseconds. Drives energy accrual and the + * remaining-capacity clamp. Non-positive/non-finite triggers the + * zero-sample defensive branch. + */ + intervalMs: number + /** + * Sample timestamp in milliseconds (typically `Date.now()`); combined + * with `session.sessionStartMs` for ramp-up progress. Non-finite + * triggers the zero-sample defensive branch. + */ + nowMs: number + /** + * Root 32-bit seed for stream splitting; combined with + * `session.transactionId` and per-measurand labels to derive + * independent PRNG streams via FNV-1a stream splitting. + */ + rootSeed: number + /** + * Enable or disable per-sample voltage noise. When `false`, `voltageV` + * is exactly the nominal voltage with no PRNG-derived fluctuation. + * Intended for deterministic unit tests. Defaults to `true` when + * omitted (the `options.voltageNoise !== false` guard). + */ + voltageNoise?: boolean +} + +const buildZeroSample = ( + socPercent: number, + voltageV: number, + energyRegisterWh: number +): CoherentSample => ({ + currentA: 0, + deltaEnergyWh: 0, + energyRegisterWh, + powerW: 0, + socPercent: roundTo(socPercent, ROUNDING_SCALE), + voltageV: voltageV > 0 && Number.isFinite(voltageV) ? roundTo(voltageV, ROUNDING_SCALE) : 0, +}) + +/** + * Computes a single coherent sample and mutates the caller-owned + * `session.socPercent`. The energy register is NOT advanced here; the + * caller (`buildCoherentMeterValue`) invokes {@link advanceEnergyRegister} + * once per emitted sample so the semantics match the OCPP energy meter + * model. + * + * Physics chain (INV-1/INV-2/INV-3 hold by construction): + * 1. `rampFactor = min(1, elapsed / rampUp)` — immutable session start. + * 2. `V` — nominal ± small seed-derived noise. + * 3. `evAcceptanceW = curve(SoC) × profile.maxPowerW`. + * 4. `powerW = rampFactor × min(EVSE_max, evAcceptance) × socCap`. + * EVSE cap already folds in charging profiles via + * {@link ICoherentContext.getConnectorMaximumAvailablePower}. + * 5. `powerW` is then clamped to remaining battery capacity so a sample + * crossing 100 % SoC never over-charges the register. + * 6. `currentAExact = powerW / (V_round · phases)` — exact fraction + * (phases=1 for DC). Emitted current is rounded to `ROUNDING_SCALE`. + * 7. Emitted `powerW = round(V_round × currentA_round × phases)`; + * INV-1 holds within `ROUNDING_SCALE` half-width (≤ 0.005 W). + * 8. `ΔE = P_clamped × Δt / MS_PER_HOUR` — uses the pre-emit-rounding + * `powerW` so the register integrates the capacity-clamped power + * exactly (INV-3). + * 9. `ΔSoC = ΔE / capacity × 100`; `socPercent = min(100, soc + ΔSoC)`. + * @param context - Charging-station context. + * @param connectorStatus - Connector status. + * @param session - Active coherent session (resolved by caller). + * @param options - Per-sample parameters (interval, seed material, ...). + * @returns The computed sample. `energyRegisterWh` reflects the projected + * register value AFTER `advanceEnergyRegister` is applied by the caller. + */ +export const computeCoherentSample = ( + context: ICoherentContext, + connectorStatus: ConnectorStatus, + session: CoherentSession, + options: ComputeSampleOptions +): CoherentSample => { + const transactionId = session.transactionId + + // Defensive guard bundle covering NaN/incoherence sources: + // - intervalMs ≤ 0 or non-finite: divide-by-zero, negative Δt, or NaN/Infinity + // propagates through `maxPowerFromCapacityW = remainingWh · MS_PER_HOUR / + // intervalMs` and permanently poisons session.socPercent. `Number.isFinite` + // covers the NaN/±Infinity paths since `NaN <= 0 === false`. + // - batteryCapacityWh ≤ 0 or non-finite: Zod (`EvProfileSchema`) enforces + // `.positive()` at file load, but `injectCoherentSession` bypasses Zod; + // `deltaSocPercent = ΔE / batteryCapacityWh × 100 = NaN` would poison SoC. + // - nominal voltage ≤ 0 or non-finite: `Voltage` enum values are all-positive, + // but a template override or future dynamic supply could return 0. + // - nowMs non-finite: pushes elapsedMs to NaN and destabilizes rampFactor. + // - AC with numberOfPhases ≤ 0: divisor collapses to 0 (`V · 0 = 0`), + // currentA is guarded to zero, and P = 0 silently — a misconfigured + // multi-phase station would emit zeros for the whole session. Fail + // loudly with a zero sample so operators notice the misconfiguration. + const batteryCapacityWh = session.profile.batteryCapacityWh + const nominalV: number = session.voltageOutNominal + const currentType = session.currentType + const numberOfPhases = session.numberOfPhases + if ( + options.intervalMs <= 0 || + !Number.isFinite(options.intervalMs) || + batteryCapacityWh <= 0 || + !Number.isFinite(batteryCapacityWh) || + nominalV <= 0 || + !Number.isFinite(nominalV) || + !Number.isFinite(options.nowMs) || + (currentType === CurrentType.AC && numberOfPhases <= 0) + ) { + const safeV = nominalV > 0 && Number.isFinite(nominalV) ? nominalV : 0 + return buildZeroSample( + session.socPercent, + safeV, + Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + ) + } + + const elapsedMs = Math.max(0, options.nowMs - session.sessionStartMs) + // Non-positive or non-finite rampUpDurationMs would either divide by zero + // (NaN) or produce rampFactor > 1 / negative. Treat any invalid value as + // "no ramp" (immediate full-power), matching the existing semantic where + // rampUpDurationMs = 0 means immediate full-power. + // Sub-millisecond values (e.g. Number.EPSILON) pass the guard but yield + // elapsedMs / rampUpDurationMs >> 1 for any real elapsed time, so + // Math.min(1, …) = 1 — semantically equivalent to rampUpDurationMs = 0. + const rampFactor = + session.rampUpDurationMs > 0 && Number.isFinite(session.rampUpDurationMs) + ? Math.min(1, elapsedMs / session.rampUpDurationMs) + : 1 + + // Voltage: nominal ± small seed-derived noise. The voltage PRNG lives on + // module-scope runtime state (not on the serializable session) so its + // stream advances across samples; constructing a new PRNG per sample + // would restart from the same seed each draw and produce a stalled + // (non-advancing) sequence. + let sampledV = nominalV + if (options.voltageNoise !== false) { + const runtime = getSessionRuntime(session) + runtime.voltagePrng ??= createStreamPrng(options.rootSeed, transactionId, 'VOLTAGE_NOISE') + sampledV = fluctuate( + nominalV, + Constants.DEFAULT_COHERENT_VOLTAGE_NOISE_PERCENT, + runtime.voltagePrng + ) + } + const roundedV = roundTo(sampledV, ROUNDING_SCALE) + + // EV acceptance from the curve at running SoC. + const acceptanceFraction = interpolateChargingCurve( + session.profile.chargingCurve, + session.socPercent + ) + const evAcceptanceW = acceptanceFraction * session.profile.maxPowerW + + // EVSE cap (already includes hardware/charging-profile clamps via ChargingStation). + const evseLimitW = context.getConnectorMaximumAvailablePower(session.connectorId) + + const socCap = session.socPercent >= 100 ? 0 : 1 + const targetPowerW = rampFactor * Math.min(evseLimitW, evAcceptanceW) * socCap + let powerW = Math.max(0, targetPowerW) + + // Clamp powerW to whatever the remaining battery capacity accepts over + // this interval so a sample that crosses 100 % SoC cannot over-charge the + // register. INV-3 is preserved because ΔE is computed from the clamped + // power below. + const remainingWh = Math.max( + 0, + ((100 - session.socPercent) / 100) * session.profile.batteryCapacityWh + ) + const maxPowerFromCapacityW = (remainingWh * Constants.MS_PER_HOUR) / options.intervalMs + powerW = Math.min(powerW, maxPowerFromCapacityW) + + // Physics: derive per-phase current as an exact fraction so + // V_round · currentAExact · phases = powerW + // holds identically. `numberOfPhases` is 1 for DC (line above) so a + // single branch covers both currents. Using integer-rounded amps here + // would inflate V·I·phases above the capacity-clamped powerW by up to + // V·phases·0.5 W, breaking INV-1. + const divisor = roundedV * numberOfPhases + const currentAExact = divisor > 0 ? powerW / divisor : 0 + + // Emission: round current to `ROUNDING_SCALE`, then derive emitted power + // from the rounded current so INV-1 (P = V·I·phases) holds within + // `ROUNDING_SCALE` half-width (≤ 0.005 W) regardless of V or phases. + const roundedCurrent = roundTo(currentAExact, ROUNDING_SCALE) + const roundedPower = roundTo(roundedV * roundedCurrent * numberOfPhases, ROUNDING_SCALE) + + // Energy accounting uses the clamped (pre-rounding) `powerW` so INV-3 + // holds within floating-point ε and the capacity budget is respected + // exactly. `Math.max(0, ...)` on `preRegisterWh` mirrors the clamp + // applied by `advanceEnergyRegister` so the reported `energyRegisterWh` + // and the post-advance persisted state agree even if the persisted + // register is corrupted to a negative value. + const deltaEnergyWh = (powerW * options.intervalMs) / Constants.MS_PER_HOUR + const preRegisterWh = Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + const projectedRegisterWh = preRegisterWh + deltaEnergyWh + + // INV-2: SoC(t+1) ≥ SoC(t); ΔSoC = ΔE / batteryCapacityWh × 100. Saturates at 100 %. + const deltaSocPercent = (deltaEnergyWh / session.profile.batteryCapacityWh) * 100 + session.socPercent = Math.min(100, session.socPercent + deltaSocPercent) + + return { + currentA: roundedCurrent, + deltaEnergyWh, + energyRegisterWh: projectedRegisterWh, + powerW: roundedPower, + socPercent: roundTo(session.socPercent, ROUNDING_SCALE), + voltageV: roundedV, + } +} + +/** + * Phase family classifier lookup for coherent emission. `satisfies Record<...>` + * gates compile-time exhaustiveness so a new `MeterValuePhase` value fails + * compile until classified. `Aggregate` is applied when `phase` is `undefined` + * (the sentinel handled by `phaseFamily` outside the table). + * - `LineToNeutral`: bare `L1`/`L2`/`L3` and `L1-N`/`L2-N`/`L3-N` + * (line-current or phase-voltage measurements). + * - `LineToLine`: `L1-L2`/`L2-L3`/`L3-L1` (line-to-line voltage; not + * defined for current or power in the coherent model). + * - `Neutral`: `N` (physically 0 for balanced 3-phase Y). + */ +const PHASE_FAMILY = { + [MeterValuePhase.L1]: 'LineToNeutral', + [MeterValuePhase.L1_L2]: 'LineToLine', + [MeterValuePhase.L1_N]: 'LineToNeutral', + [MeterValuePhase.L2]: 'LineToNeutral', + [MeterValuePhase.L2_L3]: 'LineToLine', + [MeterValuePhase.L2_N]: 'LineToNeutral', + [MeterValuePhase.L3]: 'LineToNeutral', + [MeterValuePhase.L3_L1]: 'LineToLine', + [MeterValuePhase.L3_N]: 'LineToNeutral', + [MeterValuePhase.N]: 'Neutral', +} as const satisfies Record + +const phaseFamily = ( + phase: MeterValuePhase | undefined +): 'Aggregate' | 'LineToLine' | 'LineToNeutral' | 'Neutral' => + phase == null ? 'Aggregate' : PHASE_FAMILY[phase] + +/** + * Emit order across measurands, mirroring the `getSampledValueTemplate` + * path (SoC → Voltage → Power → Current → Energy). Preserved so downstream + * consumers relying on OCPP MeterValue ordering keep working. + */ +const MEASURAND_EMIT_ORDER = [ + MeterValueMeasurand.STATE_OF_CHARGE, + MeterValueMeasurand.VOLTAGE, + MeterValueMeasurand.POWER_ACTIVE_IMPORT, + MeterValueMeasurand.CURRENT_IMPORT, + MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, +] as const + +/** + * Within-measurand phase order for deterministic per-phase emission: + * no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N. + * Lower rank emits first. `satisfies Record<...>` gates exhaustiveness + * so a new `MeterValuePhase` value fails compile until ranked. + */ +const PHASE_RANK = { + [MeterValuePhase.L1]: 1, + [MeterValuePhase.L1_L2]: 4, + [MeterValuePhase.L1_N]: 1, + [MeterValuePhase.L2]: 2, + [MeterValuePhase.L2_L3]: 5, + [MeterValuePhase.L2_N]: 2, + [MeterValuePhase.L3]: 3, + [MeterValuePhase.L3_L1]: 6, + [MeterValuePhase.L3_N]: 3, + [MeterValuePhase.N]: 7, +} as const satisfies Record + +/** + * Groups templates by measurand and sorts each bucket by phase rank. + * Templates without an explicit `measurand` default to + * `Energy.Active.Import.Register`, mirroring the existing convention. + * @param templates - Templates configured on the connector (or `undefined`). + * @returns Grouped, phase-ordered templates. + */ +const groupTemplatesByMeasurand = ( + templates: SampledValueTemplate[] | undefined +): Map => { + const groups = Map.groupBy( + templates ?? [], + t => t.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + for (const bucket of groups.values()) { + bucket.sort( + (a, b) => + (a.phase == null ? 0 : PHASE_RANK[a.phase]) - (b.phase == null ? 0 : PHASE_RANK[b.phase]) + ) + } + return groups +} + +/** + * Resolves the exact physical value to emit for a template given the + * coherent sample. Returns `undefined` for unsupported `(measurand, phase)` + * pairs so the caller can log-and-skip. Rounding is deferred to the emit + * site so unit-conversion divisions round once. + * + * Per-phase resolution (balanced 3-phase Y assumption): + * - Voltage: L-N ⇒ `sample.voltageV`; L-L ⇒ `√phases × sample.voltageV` + * (`√phases` collapses to 1 on single-phase, in which case L-L has no + * physical meaning and the template is skipped); N ⇒ 0. + * - Power.Active.Import: aggregate ⇒ total P; L-N ⇒ `P / phases`; + * L-L undefined; N undefined (neutral carries no active power in + * balanced 3-φ Y). + * - Current.Import: any line phase ⇒ `sample.currentA` (line current); + * L-L undefined; N ⇒ 0 (balanced 3-φ Y neutral current is zero). + * - SoC: aggregate scalar; phase-qualified templates rejected. + * - Energy.Active.Import.Register: aggregate ⇒ total register; L-N ⇒ + * `register / phases` (per-phase energy contribution under balanced + * 3-φ Y; Σ across all L-N templates equals the aggregate register + * within emit-unit rounding granularity — Wh: ≤ phases · 0.005 Wh; + * kWh: ≤ phases · 5 Wh); L-L undefined; N undefined. OCPP 2.0.1 + * `SampledDataCtrlr.RegisterValuesWithoutPhases` is not consulted; + * per-phase emission is driven by the connector template's phase + * qualifier. + * @param measurand - Target measurand. + * @param phase - Template `phase` field (may be `undefined`). + * @param sample - Coherent sample (source of aggregate values). + * @param numberOfPhases - Session phase count. + * @param connectorStatus - Connector status (for the energy register). + * @returns Value to emit, or `undefined` if the combination is unsupported. + */ +const resolvePhasedValue = ( + measurand: MeterValueMeasurand, + phase: MeterValuePhase | undefined, + sample: CoherentSample, + numberOfPhases: number, + connectorStatus: ConnectorStatus +): number | undefined => { + const family = phaseFamily(phase) + switch (measurand) { + case MeterValueMeasurand.CURRENT_IMPORT: + if (family === 'LineToLine') return undefined + if (family === 'Neutral') return 0 + return sample.currentA + case MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER: { + if (family === 'LineToLine' || family === 'Neutral') return undefined + const register = Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0) + if (family === 'LineToNeutral') { + if (numberOfPhases <= 0) return undefined + return register / numberOfPhases + } + return register + } + case MeterValueMeasurand.POWER_ACTIVE_IMPORT: + if (family === 'LineToLine' || family === 'Neutral') return undefined + if (family === 'LineToNeutral') { + if (numberOfPhases <= 0) return undefined + return sample.powerW / numberOfPhases + } + return sample.powerW + case MeterValueMeasurand.STATE_OF_CHARGE: + if (family !== 'Aggregate') return undefined + return sample.socPercent + case MeterValueMeasurand.VOLTAGE: + if (family === 'Neutral') return 0 + if (family === 'LineToLine') { + if (numberOfPhases <= 1) return undefined + return Math.sqrt(numberOfPhases) * sample.voltageV + } + return sample.voltageV + default: + return undefined + } +} + +/** + * Measurand → matching kilo-prefixed unit lookup. Populated only for the + * measurands whose `SampledValueTemplate.unit` may legitimately carry a + * kilo-scaled value (kW / kWh). Any other `(measurand, unit)` pair + * emits at unit scale (divider = 1). + */ +const KILO_UNIT_BY_MEASURAND: ReadonlyMap = new Map< + MeterValueMeasurand, + MeterValueUnit +>([ + [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, MeterValueUnit.KILO_WATT_HOUR], + [MeterValueMeasurand.POWER_ACTIVE_IMPORT, MeterValueUnit.KILO_WATT], +]) + +/** + * Returns the unit divider for a `(measurand, unit)` pair: the kilo divider + * when the template's unit is the kilo-prefixed variant of the measurand's + * base unit (kW for Power, kWh for Energy register), otherwise 1. + * @param measurand - Target measurand. + * @param unit - Template unit (may be `undefined`). + * @returns `Constants.UNIT_DIVIDER_KILO` or `1`. + */ +const resolveUnitDivider = ( + measurand: MeterValueMeasurand, + unit: MeterValueUnit | undefined +): number => + unit != null && KILO_UNIT_BY_MEASURAND.get(measurand) === unit ? Constants.UNIT_DIVIDER_KILO : 1 + +/** + * Returns the SampledValueTemplate array configured on the given connector. + * + * Reads the connector-level `MeterValues` templates only. Unlike + * {@link ../ocpp/OCPPServiceUtils.getSampledValueTemplate}, this does NOT + * fall back to EVSE-level `MeterValues` templates: the {@link ICoherentContext} + * surface exposes `getConnectorStatus` but not `getEvseStatus`. Adding + * EVSE-level template inheritance to the coherent path requires extending + * the context interface and is tracked as a follow-up in issue #1936. + * @param context - Charging-station context. + * @param connectorId - Connector identifier. + * @returns Templates or `undefined`. + */ +const resolveTemplates = ( + context: ICoherentContext, + connectorId: number +): SampledValueTemplate[] | undefined => { + return context.getConnectorStatus(connectorId)?.MeterValues +} + +/** + * Builds a complete OCPP {@link MeterValue} from a coherent sample. + * + * Emission order: + * - Across measurands: `SoC → Voltage → Power → Current → Energy`. + * - Within a measurand with multiple phase-qualified templates: no-phase + * first, then `L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N`. + * + * Per-phase resolution — see {@link resolvePhasedValue}. Unsupported + * `(measurand, phase)` combinations are logged and skipped. + * + * Only measurands enabled by the caller-resolved allow-list are emitted. + * The energy register is advanced unconditionally by + * {@link advanceEnergyRegister} independent of whether the Energy + * measurand is emitted. + * @param context - Charging-station context. + * @param transactionId - Active transaction identifier. + * @param buildVersionedSampledValue - Versioned SampledValue builder from + * the OCPP dispatcher in `OCPPServiceUtils.buildMeterValue`. + * @param options - Per-sample parameters (interval, seed material, timestamp). + * @param mvContext - Optional MeterValue reading context. + * @param enabledMeasurands - Optional allow-list resolved from the + * version-appropriate OCPP variable at the `buildMeterValue` boundary. + * When `undefined`, all templates emit (default behavior). When defined, + * only measurands in the set emit. Governs OCPP 2.0.1 J02.FR.11 / + * E02.FR.09 / E06.FR.11 and OCPP 1.6 `MeterValuesSampledData`. + * @returns MeterValue with sampled values and current timestamp. + */ +export const buildCoherentMeterValue = ( + context: ICoherentContext, + transactionId: number | string, + buildVersionedSampledValue: BuildVersionedSampledValue, + options: ComputeSampleOptions, + mvContext?: MeterValueContext, + enabledMeasurands?: ReadonlySet +): MeterValue => { + const session = context.getCoherentSession(transactionId) + const connectorStatus = + session != null ? context.getConnectorStatus(session.connectorId) : undefined + if (session == null || connectorStatus == null) { + logger.warn( + `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: missing session or connector for transaction ${String(transactionId)}` + ) + return { sampledValue: [], timestamp: new Date() } + } + + const sample = computeCoherentSample(context, connectorStatus, session, options) + // Own the register update: happens once per sample, unconditionally, so + // meterStop is correct even when Energy.Active.Import.Register is not in + // the configured MeterValues. + advanceEnergyRegister(connectorStatus, sample.deltaEnergyWh) + + const templates = resolveTemplates(context, session.connectorId) + const groups = groupTemplatesByMeasurand(templates) + const sampledValue: SampledValue[] = [] + const isEnabled = (measurand: MeterValueMeasurand): boolean => + enabledMeasurands == null || enabledMeasurands.has(measurand) + const numberOfPhases = session.numberOfPhases + + for (const measurand of MEASURAND_EMIT_ORDER) { + if (!isEnabled(measurand)) continue + const bucket = groups.get(measurand) + if (bucket == null) continue + for (const template of bucket) { + const raw = resolvePhasedValue( + measurand, + template.phase, + sample, + numberOfPhases, + connectorStatus + ) + if (raw == null) { + logger.warn( + `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: unsupported (${measurand}, phase=${String(template.phase)}) — template skipped` + ) + continue + } + // Narrow the OCPP 2.0 `SampledValueTemplate.unit` open-string branch + // to the closed `MeterValueUnit` union for the Map lookup below; any + // string outside the enum returns `undefined` from the Map and falls + // through to divider = 1 (unit-scale emission). + const unitDivider = resolveUnitDivider(measurand, template.unit as MeterValueUnit | undefined) + const scaled = roundTo(raw / unitDivider, ROUNDING_SCALE) + sampledValue.push(buildVersionedSampledValue(template, scaled, mvContext)) + } + } + + // MeterValue = OCPP16MeterValue | OCPP20MeterValue is a discriminated + // union that diverges on the SampledValue.context enum. Coherent path + // produces version-appropriate SampledValues via the injected + // buildVersionedSampledValue callback, but the compile-time union of + // SampledValue[] cannot be narrowed here — a boundary cast is required. + return { sampledValue, timestamp: new Date() } as MeterValue +} + +/** + * Options for {@link createCoherentSession}. + */ +export interface CreateSessionOptions { + /** Target connector id. */ + connectorId: number + /** Session start timestamp in milliseconds. Defaults to `Date.now()`. */ + now?: number + /** Non-empty EV profile pool; one profile is picked via seeded weighted random selection. */ + profiles: EvProfile[] + /** + * Optional ramp-up duration in milliseconds. Defaults to + * `Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS`. + */ + rampUpDurationMs?: number + /** Root 32-bit seed for stream splitting. */ + rootSeed: number + /** Transaction identifier. */ + transactionId: number | string +} + +/** + * Builds a {@link CoherentSession} deterministically from the profile pool + * and per-transaction seed material. Weight-based profile selection uses a + * dedicated `'PROFILE_PICK'` stream and initial SoC uses `'INITIAL_SOC'`, + * so adding one consumer does not shift any other stream's sequence + * (stream-splitting via FNV-1a label hashing — see `deriveSeed` in `Prng.ts`). + * + * The nominal AC voltage is treated as phase voltage (line-to-neutral) per + * {@link ../../utils/ElectricUtils.ACElectricUtils}. If the station is AC + * and `voltageOut` is 400 V or 800 V, a warning is logged since those + * values are line-to-line in most catalogs and the utilities would compute + * physically implausible power. + * @param context - Charging-station context. + * @param options - Session parameters. + * @returns Fully initialized session, or `undefined` when profiles is empty. + */ +export const createCoherentSession = ( + context: ICoherentContext, + options: CreateSessionOptions +): CoherentSession | undefined => { + if (options.profiles.length === 0) { + return undefined + } + const now = options.now ?? Date.now() + const profilePickPrng = createStreamPrng(options.rootSeed, options.transactionId, 'PROFILE_PICK') + const socPrng = createStreamPrng(options.rootSeed, options.transactionId, 'INITIAL_SOC') + const profile = selectEvProfile(options.profiles, profilePickPrng()) + const socRange = Math.max(0, profile.initialSocPercentMax - profile.initialSocPercentMin) + const initialSoc = profile.initialSocPercentMin + socPrng() * socRange + + const currentType = context.stationInfo?.currentOutType ?? CurrentType.AC + const voltageOutNominal = context.getVoltageOut() + if ( + currentType === CurrentType.AC && + (voltageOutNominal === Voltage.VOLTAGE_400 || voltageOutNominal === Voltage.VOLTAGE_800) + ) { + logger.warn( + `${context.logPrefix()} ${moduleName}.createCoherentSession: AC voltageOut=${voltageOutNominal.toString()}V is treated as line-to-neutral (phase voltage) by ACElectricUtils. If this value is meant as line-to-line, coherent power/current will be physically implausible.` + ) + } + + return { + connectorId: options.connectorId, + currentType, + numberOfPhases: currentType === CurrentType.AC ? context.getNumberOfPhases() : 1, + profile, + rampUpDurationMs: options.rampUpDurationMs ?? Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS, + sessionStartMs: now, + socPercent: initialSoc, + transactionId: options.transactionId, + voltageOutNominal, + } +} + +/** + * Resolves the root PRNG seed for a station. Prefers the template + * `randomSeed` and falls back to a FNV-1a hash of `hashId`, ensuring + * determinism across stations without accidentally sharing streams. + * @param stationInfo - Station info (`randomSeed` and `hashId`). + * @returns 32-bit unsigned root seed. + */ +export const resolveRootSeed = ( + stationInfo: undefined | { hashId?: string; randomSeed?: number } +): number => { + if (stationInfo?.randomSeed != null && Number.isFinite(stationInfo.randomSeed)) { + return stationInfo.randomSeed >>> 0 + } + return hashLabel(stationInfo?.hashId ?? '') +} diff --git a/src/charging-station/meter-values/EvProfiles.ts b/src/charging-station/meter-values/EvProfiles.ts new file mode 100644 index 00000000..33325baa --- /dev/null +++ b/src/charging-station/meter-values/EvProfiles.ts @@ -0,0 +1,140 @@ +// Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file EV profile file loader and selection helpers. + * @description Loads and validates the on-disk `evProfilesFile` referenced + * by a station template. Loading is fail-soft: malformed or missing files + * return `undefined` so the caller disables coherent generation for that + * station rather than crashing station startup. + */ + +import { readFileSync } from 'node:fs' +import { z } from 'zod' + +import { BaseError } from '../../exception/index.js' +import { getErrorMessage, logger } from '../../utils/index.js' +import { type EvProfile, type EvProfilesFile, EvProfilesFileSchema } from './types.js' + +const moduleName = 'EvProfiles' + +/** + * Loads, parses, validates, and normalizes an EV profile file. Sorting the + * charging curve by `socPercent` in-place makes downstream interpolation + * assume a monotone x-axis without repeating the sort at every sample. + * + * Fail-soft: any error (missing file, invalid JSON, schema violation) is + * logged and returns `undefined`. + * @param filePath - Absolute path to the EV profile JSON file. + * @param logPrefix - Log prefix for warnings; typically `chargingStation.logPrefix()`. + * @returns Parsed {@link EvProfilesFile} or `undefined` on failure. + */ +export const loadEvProfilesFile = ( + filePath: string, + logPrefix: string +): EvProfilesFile | undefined => { + try { + const raw = readFileSync(filePath, 'utf8') + const json = JSON.parse(raw) as unknown + const parsed = EvProfilesFileSchema.parse(json) + for (const profile of parsed.profiles) { + profile.chargingCurve.sort((a, b) => a.socPercent - b.socPercent) + if (profile.initialSocPercentMin > profile.initialSocPercentMax) { + logger.warn( + `${logPrefix} ${moduleName}.loadEvProfilesFile: profile '${profile.id}' has initialSocPercentMin > initialSocPercentMax, swapping bounds` + ) + const tmp = profile.initialSocPercentMin + profile.initialSocPercentMin = profile.initialSocPercentMax + profile.initialSocPercentMax = tmp + } + } + return parsed + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn( + `${logPrefix} ${moduleName}.loadEvProfilesFile: EV profile file '${filePath}' failed validation: ${getErrorMessage(error)}` + ) + } else { + logger.warn( + `${logPrefix} ${moduleName}.loadEvProfilesFile: EV profile file '${filePath}' could not be loaded: ${getErrorMessage(error)}` + ) + } + return undefined + } +} + +/** + * Weight-based profile selection. Chooses a profile from `profiles` using + * the supplied random number in [0, 1). If total weight is zero, falls back + * to the first profile. + * @param profiles - Non-empty array of EV profiles. + * @param random - Uniform float in [0, 1) (typically from a seeded stream). + * @returns Selected profile. + */ +export const selectEvProfile = (profiles: EvProfile[], random: number): EvProfile => { + const totalWeight = profiles.reduce((sum, profile) => sum + profile.weight, 0) + if (totalWeight <= 0) { + return profiles[0] + } + const target = random * totalWeight + let cumulative = 0 + for (const profile of profiles) { + cumulative += profile.weight + if (target < cumulative) { + return profile + } + } + return profiles[profiles.length - 1] +} + +/** + * Piecewise-linear interpolation of `chargingCurve` at `socPercent`. The + * curve must be sorted non-decreasing by `socPercent` (`loadEvProfilesFile` + * guarantees this). At an interior curve node the left segment's endpoint + * is returned (equivalent for continuous curves; matters only for duplicate + * `socPercent` where `a.powerFraction` wins). + * @param curve - Sorted-by-`socPercent` curve. + * @param socPercent - Query point in [0, 100]. + * @returns `powerFraction` in [0, 1] (clamped to the endpoints outside the + * curve range). + * @throws {BaseError} In non-production environments when `curve` is not + * sorted non-decreasing by `socPercent`. Production path is + * `loadEvProfilesFile` which sorts in place; test seams + * (`__injectCoherentSession`) that bypass Zod are the only reachable + * source of an unsorted curve. + */ +export const interpolateChargingCurve = ( + curve: { powerFraction: number; socPercent: number }[], + socPercent: number +): number => { + if (curve.length === 0) { + return 1 + } + if (process.env.NODE_ENV !== 'production') { + for (let i = 1; i < curve.length; i++) { + if (curve[i].socPercent < curve[i - 1].socPercent) { + throw new BaseError( + `interpolateChargingCurve: chargingCurve must be sorted non-decreasing by socPercent (index ${i.toString()})` + ) + } + } + } + if (socPercent <= curve[0].socPercent) { + return curve[0].powerFraction + } + if (socPercent >= curve[curve.length - 1].socPercent) { + return curve[curve.length - 1].powerFraction + } + for (let index = 0; index < curve.length - 1; index++) { + const a = curve[index] + const b = curve[index + 1] + if (socPercent >= a.socPercent && socPercent <= b.socPercent) { + const span = b.socPercent - a.socPercent + if (span === 0) { + return a.powerFraction + } + const t = (socPercent - a.socPercent) / span + return a.powerFraction + t * (b.powerFraction - a.powerFraction) + } + } + return curve[curve.length - 1].powerFraction +} diff --git a/src/charging-station/meter-values/Prng.ts b/src/charging-station/meter-values/Prng.ts new file mode 100644 index 00000000..f18bc0be --- /dev/null +++ b/src/charging-station/meter-values/Prng.ts @@ -0,0 +1,66 @@ +// Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Deterministic seeded PRNG for coherent MeterValues. + * @description Mulberry32 core PRNG with FNV-1a label hashing for + * independent per-stream seed derivation. No runtime dependency; kept + * intentionally small. + * + * Stream splitting: `deriveSeed(rootSeed, label)` XORs a stable FNV-1a + * 32-bit hash of the label into the root seed so adding one consumer + * (e.g. a new `POWER_NOISE` stream) does not shift any other stream's + * sequence. + */ + +/** + * Mulberry32 PRNG. Returns a function producing uniform floats in [0, 1). + * Same seed twice ⇒ identical infinite sequence. + * @param seed - 32-bit unsigned integer seed. + * @returns Function `() => number` producing the next float in [0, 1). + */ +export const mulberry32 = (seed: number): (() => number) => { + let state = seed >>> 0 + return () => { + state = (state + 0x6d2b79f5) >>> 0 + let t = state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** + * Stable 32-bit FNV-1a hash of a UTF-16 string. Used to derive per-stream + * seed offsets so labelled streams stay independent. + * @param label - Stream label (e.g. `'VOLTAGE_NOISE'`). + * @returns Unsigned 32-bit hash. + */ +export const hashLabel = (label: string): number => { + let hash = 0x811c9dc5 + for (let i = 0; i < label.length; i++) { + hash ^= label.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash >>> 0 +} + +/** + * Derive a per-stream seed from a root seed and a stable label. + * The XOR mix keeps `deriveSeed(root, 'A') !== deriveSeed(root, 'B')` + * as long as `hashLabel('A') !== hashLabel('B')`, and adding a new + * consumer never shifts an existing stream's sequence. + * + * Chained derivations reduce to `deriveSeed(deriveSeed(r, x), y) = r ^ H(x) ^ H(y)`, + * so two chains collide when `H(x1) ^ H(y1) === H(x2) ^ H(y2)`. Birthday + * bound on the 32-bit hash space is negligible at simulator scale + * (expected collisions ≈ N²/2^33; ≈ 0.3 at N = 5×10⁴). The deterministic + * self-inverse `H(x) ^ H(x) === 0` is neutralized by + * {@link ./CoherentMeterValuesGenerator.createStreamPrng} namespacing the + * transactionId leg with a `tx:` prefix labels never carry. + * @param rootSeed - Root 32-bit seed. + * @param label - Stable stream label. + * @returns Derived 32-bit unsigned seed. + */ +export const deriveSeed = (rootSeed: number, label: string): number => { + return ((rootSeed >>> 0) ^ hashLabel(label)) >>> 0 +} diff --git a/src/charging-station/meter-values/index.ts b/src/charging-station/meter-values/index.ts new file mode 100644 index 00000000..3397bb84 --- /dev/null +++ b/src/charging-station/meter-values/index.ts @@ -0,0 +1,28 @@ +// Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Coherent MeterValues module barrel. + * @description Physics-based coherent MeterValues generation for the + * OCPP simulator. Opt-in via the template flag `coherentMeterValues`; + * when disabled or absent, the random/fixed measurand generation is used + * unchanged (see `../ocpp/OCPPServiceUtils.ts`). + * + * Internal helpers are intentionally not re-exported; tests and internal + * callers should import them directly from the owning sub-module. + */ + +export { + buildCoherentMeterValue, + createCoherentSession, + isCoherentModeActive, + resolveRootSeed, +} from './CoherentMeterValuesGenerator.js' +export type { BuildVersionedSampledValue } from './CoherentMeterValuesGenerator.js' +export { loadEvProfilesFile } from './EvProfiles.js' +export type { + ChargingCurvePoint, + CoherentSession, + EvProfile, + EvProfilesFile, + ICoherentContext, +} from './types.js' diff --git a/src/charging-station/meter-values/types.ts b/src/charging-station/meter-values/types.ts new file mode 100644 index 00000000..65d92a85 --- /dev/null +++ b/src/charging-station/meter-values/types.ts @@ -0,0 +1,124 @@ +// Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Coherent MeterValues shared types and validation schemas. + * @description Types and Zod schemas for physics-based coherent MeterValues. + * The `ICoherentContext` interface is a minimal structural subset of + * {@link ../ChargingStation.ChargingStation}, keeping the coherent + * generator decoupled from the full class. + */ + +import { z } from 'zod' + +import type { + ChargingStationInfo, + ConnectorStatus, + CurrentType, + Voltage, +} from '../../types/index.js' + +/** + * A single point on the piecewise-linear charging power curve. + * `powerFraction` is the fraction of `maxPowerW` accepted at `socPercent`. + */ +export interface ChargingCurvePoint { + powerFraction: number + socPercent: number +} + +/** + * A weighted EV profile. `weight` biases random per-transaction selection. + * `batteryCapacityWh` bounds ΔSoC per ΔE. `maxPowerW` bounds acceptance. + * `chargingCurve` is a sorted-by-`socPercent` piecewise-linear taper. + */ +export interface EvProfile { + batteryCapacityWh: number + chargingCurve: ChargingCurvePoint[] + id: string + initialSocPercentMax: number + initialSocPercentMin: number + maxPowerW: number + weight: number +} + +/** + * On-disk EV profiles file schema (mirrors `evProfilesFile` template field). + */ +export interface EvProfilesFile { + profiles: EvProfile[] +} + +/** + * Zod schema for {@link ChargingCurvePoint}. `socPercent` in [0, 100], + * `powerFraction` in [0, 1]. + */ +export const ChargingCurvePointSchema = z.object({ + powerFraction: z.number().min(0).max(1), + socPercent: z.number().min(0).max(100), +}) + +/** + * Zod schema for {@link EvProfile}. `chargingCurve` must be non-empty; the + * on-disk loader (`loadEvProfilesFile`) sorts by `socPercent` in-place + * after parse. Programmatic constructors that bypass the loader (e.g. + * `__injectCoherentSession` in tests) are responsible for providing a + * sorted curve — `interpolateChargingCurve` assumes a monotone x-axis + * to bracket in O(n) without repeated sorts. + * + * Monotone-non-increasing `powerFraction` (physical taper) is a caller + * responsibility and is NOT enforced by the schema: real EV curves + * typically hold `powerFraction` flat at 1.0 through the CC region before + * tapering, so strict monotonicity would over-constrain valid profiles. + * Callers requiring a monotone taper should validate at load time. + */ +export const EvProfileSchema = z.object({ + batteryCapacityWh: z.number().positive(), + chargingCurve: z.array(ChargingCurvePointSchema).min(1), + id: z.string().min(1), + initialSocPercentMax: z.number().min(0).max(100), + initialSocPercentMin: z.number().min(0).max(100), + maxPowerW: z.number().positive(), + weight: z.number().nonnegative(), +}) + +/** + * Zod schema for {@link EvProfilesFile}. `profiles` must be non-empty. + */ +export const EvProfilesFileSchema = z.object({ + profiles: z.array(EvProfileSchema).min(1), +}) + +/** + * A per-transaction coherent session. Created after StartTransaction is + * accepted (or forced), destroyed at every reset/stop/disconnect path. + * `sessionStartMs` is immutable so SetChargingProfile cannot reset the ramp. + * All non-mutable fields are `readonly` to enforce write-once semantics + * outside {@link createCoherentSession}; only `socPercent` is mutated + * (once per sample, monotone non-decreasing per INV-2). + */ +export interface CoherentSession { + readonly connectorId: number + readonly currentType: CurrentType + readonly numberOfPhases: number + readonly profile: EvProfile + readonly rampUpDurationMs: number + readonly sessionStartMs: number + socPercent: number + readonly transactionId: number | string + readonly voltageOutNominal: Voltage +} + +/** + * Minimal structural interface consumed by the coherent generator. Only + * fields/methods actually used are declared to break any potential type + * cycle back to `ChargingStation`. + */ +export interface ICoherentContext { + getCoherentSession: (transactionId: number | string) => CoherentSession | undefined + getConnectorMaximumAvailablePower: (connectorId: number) => number + getConnectorStatus: (connectorId: number) => ConnectorStatus | undefined + getNumberOfPhases: () => number + getVoltageOut: () => Voltage + logPrefix: () => string + stationInfo?: ChargingStationInfo +} diff --git a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts index e8a301d6..c04a8991 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts @@ -35,7 +35,14 @@ import { ReservationTerminationReason, type ResponseHandler, } from '../../../types/index.js' -import { Constants, convertToInt, logger, sleep, truncateId } from '../../../utils/index.js' +import { + Constants, + convertToInt, + isNotEmptyArray, + logger, + sleep, + truncateId, +} from '../../../utils/index.js' import { restoreConnectorStatus, sendAndSetConnectorStatus, @@ -62,11 +69,16 @@ const decrementPowerDivider = (chargingStation: ChargingStation): void => { } const finalizeTransactionConnectorStatus = ( + chargingStation: ChargingStation, connectorStatus: ConnectorStatus | undefined, requestPayload: OCPP16StopTransactionRequest ): string | undefined => { const transactionIdTag = requestPayload.idTag ?? connectorStatus?.transactionIdTag + // resetConnectorStatus deletes transactionId (Helpers.ts:508). Destroy the + // coherent session using requestPayload.transactionId, which is always + // present in the StopTransaction request and unaffected by the reset. resetConnectorStatus(connectorStatus) + chargingStation.destroyCoherentSession(requestPayload.transactionId) if (connectorStatus != null) { connectorStatus.locked = false } @@ -431,6 +443,10 @@ export class OCPP16ResponseService extends OCPPResponseService { connectorStatus.transactionIdTag = requestPayload.idTag connectorStatus.transactionEnergyActiveImportRegisterValue = 0 connectorStatus.locked = true + // Session must exist before buildTransactionBeginMeterValue so the + // coherent-mode branch inside it can route through buildMeterValue. + // No-op when coherent mode is off or the EV profile pool is absent. + chargingStation.createCoherentSession(payload.transactionId, connectorId) connectorStatus.transactionBeginMeterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue( chargingStation, @@ -465,15 +481,25 @@ export class OCPP16ResponseService extends OCPPResponseService { ) } } - chargingStation.stationInfo?.beginEndMeterValues === true && - (await chargingStation.ocppRequestService.requestHandler< + // Whitepaper §3.3.4 composition: send only when the built begin + // MeterValue carries at least one SampledValue. When + // `StartTxnSampledData` is set, `buildTransactionBeginMeterValue` + // uses it; when absent it falls back to `MeterValuesSampledData`. + // Both empty ⇒ no send (avoids empty sampledValue array in + // `MeterValues.req`). + if ( + chargingStation.stationInfo?.beginEndMeterValues === true && + isNotEmptyArray(connectorStatus.transactionBeginMeterValue.sampledValue) + ) { + await chargingStation.ocppRequestService.requestHandler< OCPP16MeterValuesRequest, OCPP16MeterValuesResponse >(chargingStation, OCPP16RequestCommand.METER_VALUES, { connectorId, meterValue: [connectorStatus.transactionBeginMeterValue], transactionId: payload.transactionId, - } satisfies OCPP16MeterValuesRequest)) + } satisfies OCPP16MeterValuesRequest) + } await sendAndSetConnectorStatus(chargingStation, { connectorId, status: OCPP16ChargePointStatus.Charging, @@ -526,25 +552,33 @@ export class OCPP16ResponseService extends OCPPResponseService { logger.warn( `${chargingStation.logPrefix()} ${moduleName}.handleResponseStopTransaction: Trying to stop a non-existent transaction with id ${requestPayload.transactionId.toString()}` ) + // Destroy any lingering coherent session on this transaction so the + // Map does not leak entries when the connector-side state has already + // been reset. + chargingStation.destroyCoherentSession(requestPayload.transactionId) return } - chargingStation.stationInfo?.beginEndMeterValues === true && + if ( + chargingStation.stationInfo?.beginEndMeterValues === true && chargingStation.stationInfo.ocppStrictCompliance === false && - chargingStation.stationInfo.outOfOrderEndMeterValues === true && - (await chargingStation.ocppRequestService.requestHandler< - OCPP16MeterValuesRequest, - OCPP16MeterValuesResponse - >(chargingStation, OCPP16RequestCommand.METER_VALUES, { - connectorId: transactionConnectorId, - meterValue: [ - OCPP16ServiceUtils.buildTransactionEndMeterValue( - chargingStation, - transactionConnectorId, - requestPayload.meterStop - ), - ], - transactionId: requestPayload.transactionId, - })) + chargingStation.stationInfo.outOfOrderEndMeterValues === true + ) { + const endMeterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue( + chargingStation, + transactionConnectorId, + requestPayload.meterStop + ) + if (isNotEmptyArray(endMeterValue.sampledValue)) { + await chargingStation.ocppRequestService.requestHandler< + OCPP16MeterValuesRequest, + OCPP16MeterValuesResponse + >(chargingStation, OCPP16RequestCommand.METER_VALUES, { + connectorId: transactionConnectorId, + meterValue: [endMeterValue], + transactionId: requestPayload.transactionId, + }) + } + } const postTransactionDelay = chargingStation.stationInfo?.postTransactionDelay ?? 0 let transactionIdTag: string | undefined if (postTransactionDelay > 0) { @@ -561,11 +595,17 @@ export class OCPP16ResponseService extends OCPPResponseService { if (transactionConnectorStatus != null) { delete transactionConnectorStatus.transactionId } + // Destroy the coherent session BEFORE sleeping so an intervening + // stop cannot leak it. `destroyCoherentSession` is idempotent so a + // subsequent call from `finalizeTransactionConnectorStatus` post-sleep + // is a no-op. + chargingStation.destroyCoherentSession(requestPayload.transactionId) await sleep(secondsToMilliseconds(postTransactionDelay)) if (!chargingStation.started) { return } transactionIdTag = finalizeTransactionConnectorStatus( + chargingStation, transactionConnectorStatus, requestPayload ) @@ -575,6 +615,7 @@ export class OCPP16ResponseService extends OCPPResponseService { decrementPowerDivider(chargingStation) const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId) transactionIdTag = finalizeTransactionConnectorStatus( + chargingStation, transactionConnectorStatus, requestPayload ) @@ -608,7 +649,10 @@ export class OCPP16ResponseService extends OCPPResponseService { ): Promise { OCPP16ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId) const connectorStatus = chargingStation.getConnectorStatus(connectorId) + // Snapshot transactionId BEFORE resetConnectorStatus deletes it. + const txId = connectorStatus?.transactionId resetConnectorStatus(connectorStatus) + chargingStation.destroyCoherentSession(txId) await restoreConnectorStatus(chargingStation, connectorId, connectorStatus) } } diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index 2b68a6e8..e496199f 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -60,6 +60,7 @@ import { roundTo, truncateId, } from '../../../utils/index.js' +import { isCoherentModeActive } from '../../meter-values/index.js' import { mapOCPP16Status, OCPPAuthServiceFactory } from '../auth/index.js' import { sendAndSetConnectorStatus } from '../OCPPConnectorStatusOperations.js' import { @@ -148,6 +149,30 @@ export class OCPP16ServiceUtils { connectorId: number, meterStart: number | undefined ): OCPP16MeterValue { + // Coherent path: when an active coherent session exists for this + // transaction, route through `buildMeterValue` so the begin MeterValue + // is drawn from the same physics chain as subsequent samples (SoC in + // the profile's initial band, energy=0, V=nominal, P=I=0). Vendor + // parameter `StartTxnSampledData` (per OCPP 1.6 Signed Meter Values + // whitepaper) overrides `MeterValuesSampledData` for this MeterValue + // when configured; `resolveEnabledMeasurands` falls back to + // `MeterValuesSampledData` when the vendor key is absent. + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + const transactionId = connectorStatus?.transactionId + if (transactionId != null && isCoherentModeActive(chargingStation, transactionId)) { + const startTxnSampledDataKey = OCPP16VendorParametersKey.StartTxnSampledData + const measurandsKey = + getConfigurationKey(chargingStation, startTxnSampledDataKey)?.value != null + ? startTxnSampledDataKey + : undefined + return buildMeterValue( + chargingStation, + transactionId, + 0, + measurandsKey, + OCPP16MeterValueContext.TRANSACTION_BEGIN + ) as OCPP16MeterValue + } const meterValue = buildEmptyMeterValue() as OCPP16MeterValue // Energy.Active.Import.Register measurand (default) const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId) diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 92c61ff5..83a9433d 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -87,6 +87,7 @@ import { type OCPP20NotifyReportResponse, OCPP20OperationalStatusEnumType, OCPP20OptionalVariableName, + OCPP20ReadingContextEnumType, OCPP20ReasonEnumType, OCPP20RequestCommand, type OCPP20RequestStartTransactionRequest, @@ -469,21 +470,27 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { if (response.status === RequestStartStopStatusEnumType.Accepted) { const connectorId = chargingStation.getConnectorIdByTransactionId(response.transactionId) if (connectorId != null && response.transactionId != null) { + const txId = response.transactionId + chargingStation.createCoherentSession(txId, connectorId) const startedMeterValues = OCPP20ServiceUtils.buildTransactionStartedMeterValues( chargingStation, - response.transactionId + txId ) OCPP20ServiceUtils.sendTransactionEvent( chargingStation, OCPP20TransactionEventEnumType.Started, OCPP20TriggerReasonEnumType.RemoteStart, connectorId, - response.transactionId, + txId, { ...(isNotEmptyArray(startedMeterValues) && { meterValue: startedMeterValues }), remoteStartId: request.remoteStartId, } ).catch((error: unknown) => { + // Session was created for this fire-and-forget send. If the + // send rejects, destroy it here — no other cleanup path + // covers this branch. `destroyCoherentSession` is idempotent. + chargingStation.destroyCoherentSession(txId) logger.error( `${chargingStation.logPrefix()} ${moduleName}.constructor: TransactionEvent(Started) error:`, error @@ -605,15 +612,27 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { const meterValue = buildMeterValue( chargingStation, connector.transactionId, - txUpdatedInterval + txUpdatedInterval, + buildConfigKey( + OCPP20ComponentName.SampledDataCtrlr, + OCPP20RequiredVariableName.TxUpdatedMeasurands + ), + OCPP20ReadingContextEnumType.TRIGGER ) as OCPP20MeterValue + // OCPP 2.0.1 MeterValueType.sampledValue cardinality is 1..*, while + // TransactionEventRequest.meterValue is 0..*: when TxUpdatedMeasurands + // yields no sampled values, omit the meterValue field entirely rather + // than send an empty-wrapper schema violation. + const eventPayload = isNotEmptyArray(meterValue.sampledValue) + ? { meterValue: [meterValue] } + : {} OCPP20ServiceUtils.sendTransactionEvent( chargingStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.Trigger, cId, connector.transactionId as string, - { meterValue: [meterValue] } + eventPayload ).catch(errorHandler) } } @@ -3222,7 +3241,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ): Promise { OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId) const connectorStatus = chargingStation.getConnectorStatus(connectorId) + // Snapshot transactionId BEFORE resetConnectorStatus deletes it. + const txId = connectorStatus?.transactionId resetConnectorStatus(connectorStatus) + chargingStation.destroyCoherentSession(txId) await restoreConnectorStatus(chargingStation, connectorId, connectorStatus) } diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index 901001f1..ea27be29 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -412,6 +412,15 @@ export class OCPP20ResponseService extends OCPPResponseService { logger.info( `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} ENDED on connector ${connectorId.toString()}` ) + } else { + // connectorId is unknown (e.g. connector state already reset before + // the CSMS response arrived). Destroy any lingering coherent session + // so the Map does not leak entries — symmetric with the OCPP 1.6 + // defensive destroy in handleResponseStopTransaction. + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Ending transaction ${requestPayload.transactionInfo.transactionId} on unknown connector — connector state already reset` + ) + chargingStation.destroyCoherentSession(requestPayload.transactionInfo.transactionId) } break case OCPP20TransactionEventEnumType.Started: @@ -447,6 +456,13 @@ export class OCPP20ResponseService extends OCPPResponseService { ) const txEndedInterval = OCPP20ServiceUtils.getTxEndedInterval(chargingStation) OCPP20ServiceUtils.startEndedMeterValues(chargingStation, connectorId, txEndedInterval) + // Create coherent MeterValues session after transactionId is known. + // No-op when the feature flag or the EV profile file is not + // configured (see ChargingStation.createCoherentSession). + chargingStation.createCoherentSession( + requestPayload.transactionInfo.transactionId, + connectorId + ) } logger.info( `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} STARTED on connector ${String(connectorId)}` diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 92743273..4eb45de6 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -200,16 +200,24 @@ export class OCPP20ServiceUtils { ) { return } + // Snapshot transactionId BEFORE any mutation below deletes it, so the + // coherent session (if any) can still be destroyed after the reset. + const txId = connectorStatus.transactionId OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId) const postTransactionDelay = chargingStation.stationInfo?.postTransactionDelay ?? 0 if (postTransactionDelay > 0) { delete connectorStatus.transactionId + // Destroy the coherent session BEFORE sleeping so an intervening + // stop cannot leak it. `destroyCoherentSession` is idempotent so the + // post-sleep call remains valid. + chargingStation.destroyCoherentSession(txId) await sleep(secondsToMilliseconds(postTransactionDelay)) if (!chargingStation.started) { return } } resetConnectorStatus(connectorStatus) + chargingStation.destroyCoherentSession(txId) connectorStatus.locked = false await sendPostTransactionStatus(chargingStation, connectorId) } @@ -883,6 +891,11 @@ export class OCPP20ServiceUtils { } OCPP20ServiceUtils.resetTransactionSequenceNumber(chargingStation, connectorId) } + // Create coherent session BEFORE building the Transaction.Started MeterValue + // so the coherent gate in `buildMeterValue` runs against a live session + // (E02.FR.09 measurands from a physics-consistent initial state). + // Idempotent — a duplicate call from the response handler is a no-op. + chargingStation.createCoherentSession(transactionId, connectorId) const startedMeterValues = OCPP20ServiceUtils.buildTransactionStartedMeterValues( chargingStation, transactionId @@ -890,18 +903,28 @@ export class OCPP20ServiceUtils { if (isNotEmptyArray(startedMeterValues) && connectorStatus != null) { connectorStatus.transactionBeginMeterValue = startedMeterValues[0] as MeterValue } - const response = await OCPP20ServiceUtils.sendTransactionEvent( - chargingStation, - OCPP20TransactionEventEnumType.Started, - OCPP20TriggerReasonEnumType.Authorized, - connectorId, - transactionId, - { - idToken: - idTag != null ? { idToken: idTag, type: OCPP20IdTokenEnumType.ISO14443 } : undefined, - ...(isNotEmptyArray(startedMeterValues) && { meterValue: startedMeterValues }), - } - ) + let response + try { + response = await OCPP20ServiceUtils.sendTransactionEvent( + chargingStation, + OCPP20TransactionEventEnumType.Started, + OCPP20TriggerReasonEnumType.Authorized, + connectorId, + transactionId, + { + idToken: + idTag != null ? { idToken: idTag, type: OCPP20IdTokenEnumType.ISO14443 } : undefined, + ...(isNotEmptyArray(startedMeterValues) && { meterValue: startedMeterValues }), + } + ) + } catch (error) { + // Symmetric with OCPP 1.6 `resetConnectorOnStartTransactionError`: if + // the Started TransactionEvent fails to send, the coherent session + // (created at line 898 for the physics-consistent begin MeterValue) + // must not leak. `destroyCoherentSession` is idempotent. + chargingStation.destroyCoherentSession(transactionId) + throw error + } return { accepted: response.idTokenInfo == null || @@ -976,15 +999,26 @@ export class OCPP20ServiceUtils { const meterValue = buildMeterValue( chargingStation, connectorStatus.transactionId, - interval + interval, + buildConfigKey( + OCPP20ComponentName.SampledDataCtrlr, + OCPP20RequiredVariableName.TxUpdatedMeasurands + ) ) as OCPP20MeterValue + // OCPP 2.0.1 `MeterValueType.sampledValue` cardinality is `1..*`, while + // `TransactionEventRequest.meterValue` is `0..*`: when `TxUpdatedMeasurands` + // yields no sampled values, omit the `meterValue` field entirely rather + // than send an empty-wrapper schema violation. + const eventPayload = isNotEmptyArray(meterValue.sampledValue) + ? { meterValue: [meterValue] } + : {} OCPP20ServiceUtils.sendTransactionEvent( chargingStation, OCPP20TransactionEventEnumType.Updated, OCPP20TriggerReasonEnumType.MeterValuePeriodic, connectorId, connectorStatus.transactionId as string, - { meterValue: [meterValue] } + eventPayload ).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: Error sending periodic TransactionEvent:`, diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index a34daef7..d2cad639 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -68,6 +68,12 @@ import { min, roundTo, } from '../../utils/index.js' +import { + buildCoherentMeterValue, + type BuildVersionedSampledValue, + isCoherentModeActive, + resolveRootSeed, +} from '../meter-values/index.js' import { buildOCPP16BootNotificationRequest, buildOCPP16SampledValue, @@ -86,8 +92,6 @@ import { const moduleName = 'OCPPServiceUtils' const SOC_MAXIMUM_VALUE = 100 -const UNIT_DIVIDER_KILO = 1000 -const MS_PER_HOUR = 3_600_000 const isOCPP20FlagEnabled = ( chargingStation: ChargingStation, @@ -424,11 +428,12 @@ const buildEnergyMeasurandValue = ( } checkMeasurandPowerDivider(chargingStation, energyTemplate.measurand) - const unitDivider = energyTemplate.unit === MeterValueUnit.KILO_WATT_HOUR ? UNIT_DIVIDER_KILO : 1 + const unitDivider = + energyTemplate.unit === MeterValueUnit.KILO_WATT_HOUR ? Constants.UNIT_DIVIDER_KILO : 1 const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(connectorId) const connectorMaximumEnergyRounded = roundTo( - (connectorMaximumAvailablePower * interval) / MS_PER_HOUR, + (connectorMaximumAvailablePower * interval) / Constants.MS_PER_HOUR, 2 ) const connectorMinimumEnergyRounded = roundTo(energyTemplate.minimumValue ?? 0, 2) @@ -524,7 +529,8 @@ const buildPowerMeasurandValue = ( checkMeasurandPowerDivider(chargingStation, powerTemplate.measurand) const powerValues: MeasurandValues = {} as MeasurandValues - const unitDivider = powerTemplate.unit === MeterValueUnit.KILO_WATT ? UNIT_DIVIDER_KILO : 1 + const unitDivider = + powerTemplate.unit === MeterValueUnit.KILO_WATT ? Constants.UNIT_DIVIDER_KILO : 1 const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(connectorId) const connectorMaximumPower = Math.round(connectorMaximumAvailablePower) @@ -896,34 +902,41 @@ export const buildEmptyMeterValue = (): MeterValue => ({ }) /** - * Builds a complete MeterValue with all configured measurands for a transaction. - * @param chargingStation - Target charging station - * @param transactionId - Active transaction identifier - * @param interval - Meter value sampling interval in milliseconds - * @param measurandsKey - Configuration key for the sampled measurands list - * @param context - Meter value reading context - * @param debug - Enable debug logging for measurand validation - * @returns Populated MeterValue object + * Internal dispatch bag returned by {@link createVersionedSampledValueDispatcher} + * and consumed by {@link buildMeterValue}. Not exported: kept out of the + * module surface so external callers rely on the higher-level entry point. */ -export const buildMeterValue = ( +interface VersionedSampledValueDispatch { + buildVersionedSampledValue: BuildVersionedSampledValue + connectorId: number + evseId: number | undefined + signingConfig: SampledValueSigningConfig | undefined + /** + * Passed by reference; the closure assigned to `buildVersionedSampledValue` + * mutates its `publicKeyIncluded` flag when a signed OCPP 2.0 SampledValue + * is emitted. Callers rely on the reference identity to detect the mutation. + */ + signingState: { publicKeyIncluded: boolean } +} + +/** + * Resolves the connector/EVSE ids and constructs the OCPP-version dispatcher + * used by {@link buildMeterValue}. Extracted verbatim from the original + * `buildMeterValue` switch so behavior is preserved exactly. + * @param chargingStation - Target charging station. + * @param transactionId - Active transaction identifier. + * @param context - Optional MeterValue reading context (drives signing + * configuration for OCPP 2.0). + * @returns The dispatch bundle. + */ +const createVersionedSampledValueDispatcher = ( chargingStation: ChargingStation, - transactionId: number | string | undefined, - interval: number, - measurandsKey?: ConfigurationKeyType, - context?: MeterValueContext, - debug = false -): MeterValue => { - if (transactionId == null) { - return buildEmptyMeterValue() - } + transactionId: number | string, + context?: MeterValueContext +): VersionedSampledValueDispatch => { const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId) let evseId: number | undefined - let buildVersionedSampledValue: ( - sampledValueTemplate: SampledValueTemplate, - value: number, - context?: MeterValueContext, - phase?: MeterValuePhase - ) => SampledValue + let buildVersionedSampledValue: BuildVersionedSampledValue let signingConfig: SampledValueSigningConfig | undefined const signingState = { publicKeyIncluded: false } switch (chargingStation.stationInfo?.ocppVersion) { @@ -1045,6 +1058,123 @@ export const buildMeterValue = ( RequestCommand.METER_VALUES ) } + return { buildVersionedSampledValue, connectorId, evseId, signingConfig, signingState } +} + +// Module-scope keyed by `ChargingStation` instance (auto-collected on GC). +// Kept off the class API to avoid touching `ChargingStation` for a +// warn-once diagnostic; the WeakMap's semantics match the intent — one +// bag of already-warned entries per station, freed with the station. +const warnedInvalidMeasurands = new WeakMap>() +const KNOWN_MEASURANDS: ReadonlySet = new Set(Object.values(MeterValueMeasurand)) + +/** + * Resolves the set of measurands enabled by the configured OCPP variable. + * + * Presence-aware semantics: + * - No key resolves ⇒ returns `undefined` (no filter — all templates emit, + * preserving the default behavior). + * - Key resolves but the configuration variable is **absent** (never + * written) ⇒ returns `{Energy.Active.Import.Register}` (default measurand, + * ergonomic parity with a station that never set the variable). + * - Key resolves and the configuration variable is **present** ⇒ the CSV + * is honored verbatim, including an explicit empty value which yields an + * empty allow-list (spec-compliant suppression per OCPP 2.0.1 J02.FR.11). + * + * Governs OCPP 2.0.1 J02.FR.11 (`TxUpdatedMeasurands`), E02.FR.09 + * (`TxStartedMeasurands`), E06.FR.11 (`TxEndedMeasurands`), and OCPP 1.6 + * `MeterValuesSampledData`. + * @param chargingStation - Target charging station. + * @param measurandsKey - Configuration key threaded from the caller. When + * `undefined` (or omitted), defaults to `StandardParametersKey.MeterValuesSampledData` + * for OCPP 1.6 stations and returns `undefined` (no filter) for all + * other versions. + * @returns Enabled measurand set, or `undefined` for no filter. + */ +const resolveEnabledMeasurands = ( + chargingStation: ChargingStation, + measurandsKey: ConfigurationKeyType | undefined +): ReadonlySet | undefined => { + const effectiveKey = + measurandsKey ?? + (chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_16 + ? StandardParametersKey.MeterValuesSampledData + : undefined) + if (effectiveKey == null) { + return undefined + } + const rawValue = getConfigurationKey(chargingStation, effectiveKey)?.value + if (rawValue == null) { + return new Set([MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER]) + } + const enabled = new Set() + for (const entry of rawValue.split(',')) { + const trimmed = entry.trim() + if (trimmed.length === 0) { + continue + } + if (KNOWN_MEASURANDS.has(trimmed)) { + enabled.add(trimmed as MeterValueMeasurand) + continue + } + let warned = warnedInvalidMeasurands.get(chargingStation) + if (warned == null) { + warned = new Set() + warnedInvalidMeasurands.set(chargingStation, warned) + } + if (!warned.has(trimmed)) { + warned.add(trimmed) + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.resolveEnabledMeasurands: unknown measurand '${trimmed}' in ${effectiveKey} — ignored` + ) + } + } + return enabled +} + +/** + * Builds a complete MeterValue with all configured measurands for a transaction. + * @param chargingStation - Target charging station + * @param transactionId - Active transaction identifier + * @param interval - Meter value sampling interval in milliseconds + * @param measurandsKey - Configuration key for the sampled measurands list + * @param context - Meter value reading context + * @param debug - Enable debug logging for measurand validation + * @returns Populated MeterValue object + */ +export const buildMeterValue = ( + chargingStation: ChargingStation, + transactionId: number | string | undefined, + interval: number, + measurandsKey?: ConfigurationKeyType, + context?: MeterValueContext, + debug = false +): MeterValue => { + if (transactionId == null) { + return buildEmptyMeterValue() + } + const { buildVersionedSampledValue, connectorId, evseId, signingConfig, signingState } = + createVersionedSampledValueDispatcher(chargingStation, transactionId, context) + // Coherent MeterValues strategy gate. Placed AFTER the versioned dispatcher + // is available (so the coherent path can emit versioned SampledValues) and + // BEFORE the random/fixed measurand generation runs. When coherent mode + // is not active for this station or no session exists for the transaction, + // this is a no-op and the random/fixed code path is unchanged. + if (isCoherentModeActive(chargingStation, transactionId)) { + const rootSeed = resolveRootSeed(chargingStation.stationInfo) + return buildCoherentMeterValue( + chargingStation, + transactionId, + buildVersionedSampledValue, + { + intervalMs: interval, + nowMs: Date.now(), + rootSeed, + }, + context, + resolveEnabledMeasurands(chargingStation, measurandsKey) + ) + } const connectorStatus = chargingStation.getConnectorStatus(connectorId) const meterValue: { sampledValue: SampledValue[]; timestamp: Date } = buildEmptyMeterValue() if (signingConfig != null) { @@ -1101,7 +1231,7 @@ export const buildMeterValue = ( context, voltageMeasurand.value ) - if (chargingStation.stationInfo.phaseLineToLineVoltageMeterValues === true) { + if (chargingStation.stationInfo?.phaseLineToLineVoltageMeterValues === true) { const nextPhase = (phase + 1) % chargingStation.getNumberOfPhases() !== 0 ? ((phase + 1) % chargingStation.getNumberOfPhases()).toString() @@ -1134,7 +1264,7 @@ export const buildMeterValue = ( ) if (powerMeasurand?.values.allPhases != null) { const unitDivider = - powerMeasurand.template.unit === MeterValueUnit.KILO_WATT ? UNIT_DIVIDER_KILO : 1 + powerMeasurand.template.unit === MeterValueUnit.KILO_WATT ? Constants.UNIT_DIVIDER_KILO : 1 const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(connectorId) const connectorMaximumPower = Math.round(connectorMaximumAvailablePower) @@ -1199,7 +1329,7 @@ export const buildMeterValue = ( const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(connectorId) const connectorMaximumAmperage = - chargingStation.stationInfo.currentOutType === CurrentType.AC + chargingStation.stationInfo?.currentOutType === CurrentType.AC ? ACElectricUtils.amperagePerPhaseFromPower( chargingStation.getNumberOfPhases(), connectorMaximumAvailablePower, @@ -1266,7 +1396,9 @@ export const buildMeterValue = ( if (energyMeasurand != null) { updateConnectorEnergyValues(connectorStatus, energyMeasurand.value) const unitDivider = - energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? UNIT_DIVIDER_KILO : 1 + energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR + ? Constants.UNIT_DIVIDER_KILO + : 1 const energySampledValue = buildVersionedSampledValue( energyMeasurand.template, roundTo( @@ -1279,7 +1411,7 @@ export const buildMeterValue = ( const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(connectorId) const connectorMaximumEnergyRounded = roundTo( - (connectorMaximumAvailablePower * interval) / MS_PER_HOUR, + (connectorMaximumAvailablePower * interval) / Constants.MS_PER_HOUR, 2 ) const connectorMinimumEnergyRounded = roundTo(energyMeasurand.template.minimumValue ?? 0, 2) diff --git a/src/types/ChargingStationTemplate.ts b/src/types/ChargingStationTemplate.ts index 10a34e4f..b9a2cd09 100644 --- a/src/types/ChargingStationTemplate.ts +++ b/src/types/ChargingStationTemplate.ts @@ -62,12 +62,27 @@ export interface ChargingStationTemplate { chargePointModel: string chargePointSerialNumberPrefix?: string chargePointVendor: string + /** + * Enable physics-based coherent MeterValues generation (opt-in, default `false`). + * When `true`, MeterValues are derived from a single physics chain + * (V → P → I → ΔE → SoC) using a deterministic seeded PRNG and an + * optional {@link evProfilesFile}. When `false` or absent, the random/fixed + * per-measurand generation is used and behavior is unchanged. + */ + coherentMeterValues?: boolean commandsSupport?: CommandsSupport Configuration?: ChargingStationOcppConfiguration Connectors?: Record currentOutType?: CurrentType customValueLimitationMeterValues?: boolean enableStatistics?: boolean + /** + * Optional EV profile file (same asset pattern as {@link idTagsFile}). + * Consumed only when `coherentMeterValues` is `true`. Malformed or + * missing files disable coherent generation for the station and log + * a warning; the station still starts. + */ + evProfilesFile?: string Evses?: Record firmwareUpgrade?: FirmwareUpgrade firmwareVersion?: string @@ -111,6 +126,14 @@ export interface ChargingStationTemplate { powerSharedByConnectors?: boolean powerUnit?: PowerUnits randomConnectors?: boolean + /** + * Optional deterministic seed for coherent MeterValues generation. + * When set together with `coherentMeterValues=true`, the physics-based + * generator produces reproducible MeterValues sequences for identical + * inputs (same seed + same transactionId + same interval). Ignored when + * `coherentMeterValues` is `false` or absent. + */ + randomSeed?: number reconnectExponentialDelay?: boolean registrationMaxRetries?: number remoteAuthorization?: boolean diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index a0622fb4..b6a62242 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -36,6 +36,12 @@ export class Constants { static readonly DEFAULT_CIRCULAR_BUFFER_CAPACITY = 386 + /** Default coherent MeterValues ramp-up duration in milliseconds. Non-positive values disable the ramp. */ + static readonly DEFAULT_COHERENT_RAMP_UP_DURATION_MS = 5000 + + /** Default coherent MeterValues voltage-noise symmetric half-width (0.01 = ±1 %). */ + static readonly DEFAULT_COHERENT_VOLTAGE_NOISE_PERCENT = 0.01 + static readonly DEFAULT_EV_CONNECTION_TIMEOUT_SECONDS = 180 static readonly DEFAULT_FLUCTUATION_PERCENT = 5 @@ -118,9 +124,15 @@ export class Constants { // Values exceeding this limit cause Node.js to reset the delay to 1ms static readonly MAX_SETINTERVAL_DELAY_MS = 2147483647 + /** Milliseconds per hour; conversion factor for `Wh` accrual from `W·ms`. */ + static readonly MS_PER_HOUR = 3_600_000 + static readonly PERFORMANCE_RECORDS_TABLE = 'performance_records' static readonly STOP_CHARGING_STATIONS_TIMEOUT_MS = 60000 static readonly STOP_MESSAGE_SEQUENCE_TIMEOUT_MS = 30000 + + /** Divider between base units (W, Wh) and kilo units (kW, kWh). */ + static readonly UNIT_DIVIDER_KILO = 1000 } diff --git a/tests/charging-station/ChargingStationTestConstants.ts b/tests/charging-station/ChargingStationTestConstants.ts index 35bef79c..4e95d399 100644 --- a/tests/charging-station/ChargingStationTestConstants.ts +++ b/tests/charging-station/ChargingStationTestConstants.ts @@ -1,11 +1,10 @@ /** - * Common test constants for charging station tests across all OCPP versions - * - * This file serves as the single source of truth for test constants used across - * charging station test suites. Constants are organized by functional area and - * follow naming conventions: UPPERCASE_WITH_UNDERSCORES. - * @see tests/charging-station/ocpp/2.0/OCPP20TestConstants.ts for OCPP 2.0 specific constants - * @see tests/charging-station/OCPPSpecRequirements.md for OCPP specification requirements + * @file Common test constants for charging station tests. + * @description Single source of truth for test constants shared across + * charging station test suites, organized by functional area. Naming + * convention: `TEST_UPPERCASE_WITH_UNDERSCORES`. OCPP-2.0-specific + * constants live in `OCPP20TestConstants.ts`; specification requirements + * live in `OCPPSpecRequirements.md`. */ /** @@ -17,11 +16,15 @@ export const TEST_CHARGING_STATION_HASH_ID = 'cs-test-hash-001' /** * Timer Intervals - * Test values for timing-related configuration and expectations + * Test values for timing-related configuration and expectations. + * The `_MS` intervals are intentionally half of their production + * `Constants.DEFAULT_*_INTERVAL_MS` counterparts to keep test wall-clock + * time bounded without changing the invariants under test. */ export const TEST_HEARTBEAT_INTERVAL_SECONDS = 60 export const TEST_HEARTBEAT_INTERVAL_MS = 30000 export const TEST_AUTHORIZATION_TIMEOUT_MS = 30000 +export const TEST_METER_VALUES_INTERVAL_MS = 30_000 export const TEST_ONE_HOUR_SECONDS = 3600 export const TEST_ONE_HOUR_MS = TEST_ONE_HOUR_SECONDS * 1000 diff --git a/tests/charging-station/helpers/StationHelpers.ts b/tests/charging-station/helpers/StationHelpers.ts index c973c9d8..82f59ba8 100644 --- a/tests/charging-station/helpers/StationHelpers.ts +++ b/tests/charging-station/helpers/StationHelpers.ts @@ -2,9 +2,14 @@ * Station helper functions for testing * * Factory functions to create mock ChargingStation instances with isolated dependencies. + * + * TODO(#1936): file size (≈ 950 LOC) exceeds the 250 LOC ceiling documented + * in AGENTS.md. Modular refactor (StationHelpers.types / .cleanup / .connector / + * .template / .factory) tracked as follow-up; 30+ test files import from + * this path so the split must preserve the public re-export surface. */ -import type { ChargingStation } from '../../../src/charging-station/index.js' +import type { ChargingStation, CoherentSession } from '../../../src/charging-station/index.js' import type { ChargingStationInfo, ChargingStationOcppConfiguration, @@ -397,6 +402,9 @@ export function createMockChargingStation ( const station = { // Reservation methods (mock implementations - eslint disabled for test utilities) + __injectCoherentSession (transactionId: number | string, session: CoherentSession): void { + this.coherentSessions.set(transactionId, session) + }, addReservation (reservation: Record): void { // Check if reservation with same ID exists and remove it const existingReservation = this.getReservationBy( @@ -412,6 +420,7 @@ export function createMockChargingStation ( } }, automaticTransactionGenerator: undefined, + bootNotificationRequest: undefined, bootNotificationResponse: { @@ -425,7 +434,6 @@ export function createMockChargingStation ( interval: number status: RegistrationStatusEnumType }, - bufferMessage (message: string): void { this.messageQueue.push(message) }, @@ -435,8 +443,17 @@ export function createMockChargingStation ( this.wsConnection = null } }, - connectors, + // Coherent MeterValues session store (real class uses a private Map). + coherentSessions: new Map(), + connectors, + createCoherentSession ( + _transactionId: number | string, + _connectorId: number + ): CoherentSession | undefined { + // Mock: never auto-create; tests inject sessions directly when needed. + return undefined + }, async delete (deleteConfiguration = true): Promise { if (this.started) { await this.stop() @@ -447,6 +464,13 @@ export function createMockChargingStation ( // Note: deleteConfiguration controls file deletion in real implementation // Mock doesn't have file system access, so parameter is unused }, + + destroyCoherentSession (transactionId: number | string | undefined): boolean { + if (transactionId == null) { + return false + } + return this.coherentSessions.delete(transactionId) + }, // Event emitter methods (minimal implementation) emit: () => true, // Empty implementations for interface compatibility @@ -456,6 +480,9 @@ export function createMockChargingStation ( getAuthorizeRemoteTxRequests (): boolean { return false // Default to false in mock }, + getCoherentSession (transactionId: number | string): CoherentSession | undefined { + return this.coherentSessions.get(transactionId) + }, getConnectionTimeout (): number { return connectionTimeout }, @@ -551,6 +578,7 @@ export function createMockChargingStation ( getWebSocketPingInterval (): number { return websocketPingInterval }, + hasConnector (connectorId: number): boolean { return this.iterateConnectors().some(({ connectorId: id }) => id === connectorId) }, @@ -574,10 +602,10 @@ export function createMockChargingStation ( // Core properties index, - inPendingState (): boolean { return this.bootNotificationResponse?.status === RegistrationStatusEnumType.PENDING }, + inRejectedState (): boolean { return this.bootNotificationResponse?.status === RegistrationStatusEnumType.REJECTED }, diff --git a/tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts b/tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts new file mode 100644 index 00000000..99245421 --- /dev/null +++ b/tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts @@ -0,0 +1,1000 @@ +/** + * @file Tests for CoherentMeterValuesGenerator physics. + * @description Verifies invariants (P=V·I·phases, ΔE=P·Δt/3.6e6, SoC monotone, + * saturation at 100 %), Wh/kWh unit conversion, energy-register ownership, + * and same-seed determinism across AC 1-phase, AC 3-phase, and DC modes. + */ + +import assert from 'node:assert/strict' +import { afterEach, describe, it } from 'node:test' + +import type { BuildVersionedSampledValue } from '../../../src/charging-station/meter-values/CoherentMeterValuesGenerator.js' +import type { + CoherentSession, + EvProfile, + ICoherentContext, +} from '../../../src/charging-station/meter-values/types.js' +import type { + ChargingStationInfo, + ConnectorStatus, + SampledValue, + SampledValueTemplate, +} from '../../../src/types/index.js' + +import { + buildCoherentMeterValue, + computeCoherentSample, + createCoherentSession, + disposeCoherentSessionRuntime, + resolveRootSeed, +} from '../../../src/charging-station/meter-values/CoherentMeterValuesGenerator.js' +import { hashLabel } from '../../../src/charging-station/meter-values/Prng.js' +import { + AvailabilityType, + CurrentType, + MeterValueMeasurand, + MeterValuePhase, + MeterValueUnit, + OCPPVersion, +} from '../../../src/types/index.js' +import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' +import { TEST_METER_VALUES_INTERVAL_MS } from '../ChargingStationTestConstants.js' + +const baseProfile: EvProfile = { + batteryCapacityWh: 40000, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 80 }, + { powerFraction: 0.2, socPercent: 100 }, + ], + id: 'unit-ev', + initialSocPercentMax: 30, + initialSocPercentMin: 30, + maxPowerW: 11000, + weight: 1, +} + +/** + * Builds an in-memory ICoherentContext + connector status for unit tests. + * @param overrides - Optional overrides bag for context knobs. + * @param overrides.currentType - `CurrentType.AC` or `CurrentType.DC`. + * @param overrides.evseMaxPowerW - EVSE cap returned by `getConnectorMaximumAvailablePower`. + * @param overrides.numberOfPhases - Number of AC phases (ignored for DC). + * @param overrides.voltageOut - Nominal phase voltage. + * @returns Bundle exposing context, sessions map, station info, and connector status. + */ +const buildContext = ( + overrides: { + currentType?: CurrentType + evseMaxPowerW?: number + numberOfPhases?: number + voltageOut?: number + } = {} +): { + connectorStatus: ConnectorStatus + context: ICoherentContext + sessions: Map + stationInfo: ChargingStationInfo +} => { + const numberOfPhases = overrides.numberOfPhases ?? 1 + const voltageOut = overrides.voltageOut ?? 230 + const evseMax = overrides.evseMaxPowerW ?? 22000 + + const stationInfo: ChargingStationInfo = { + baseName: 'CS-TEST', + chargePointModel: 'model', + chargePointVendor: 'vendor', + coherentMeterValues: true, + currentOutType: overrides.currentType ?? CurrentType.AC, + hashId: 'hash-1', + numberOfPhases, + ocppVersion: OCPPVersion.VERSION_16, + randomSeed: 42, + templateIndex: 0, + templateName: 'CS-TEST', + voltageOut, + } + + const connectorStatus: ConnectorStatus = { + availability: AvailabilityType.Operative, + energyActiveImportRegisterValue: 0, + MeterValues: [], + transactionEnergyActiveImportRegisterValue: 0, + transactionId: 1, + } + + const sessions = new Map() + + const context: ICoherentContext = { + getCoherentSession: (id: number | string) => sessions.get(id), + getConnectorMaximumAvailablePower: () => evseMax, + getConnectorStatus: () => connectorStatus, + getNumberOfPhases: () => numberOfPhases, + getVoltageOut: () => voltageOut, + logPrefix: () => '[test]', + stationInfo, + } + return { connectorStatus, context, sessions, stationInfo } +} + +const templatesFor = ( + measurands: MeterValueMeasurand[], + energyUnit: MeterValueUnit = MeterValueUnit.WATT_HOUR +): SampledValueTemplate[] => { + return measurands.map(measurand => { + const unit = + measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ? energyUnit + : measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT + ? MeterValueUnit.WATT + : undefined + return (unit != null ? { measurand, unit } : { measurand }) as SampledValueTemplate + }) +} + +/** + * OCPP 1.6-style pass-through SampledValue builder used in unit tests. Keeps + * the numeric value accessible for algebraic invariant checks. + * @param template - SampledValueTemplate. + * @param value - Numeric value to be serialized. + * @returns Minimal SampledValue with stringified value. + */ +const passThroughBuilder: BuildVersionedSampledValue = (template, value): SampledValue => { + return { + ...(template.measurand != null && { measurand: template.measurand }), + ...(template.unit != null && { unit: template.unit as never }), + value: value.toString(), + } as SampledValue +} + +/** + * Creates a coherent session and asserts it is defined. Encapsulates the + * `undefined` fallback so callers avoid non-null assertions. + * @param context - ICoherentContext. + * @param options - Session parameters (see createCoherentSession). + * @returns The created session. + */ +const createSessionOrFail = ( + context: ICoherentContext, + options: Parameters[1] +): CoherentSession => { + const session = createCoherentSession(context, options) + assert.ok(session != null, 'expected a session to be created') + return session +} + +await describe('CoherentMeterValuesGenerator', async () => { + afterEach(() => { + standardCleanup() + }) + + await describe('resolveRootSeed', async () => { + await it('should prefer explicit randomSeed', () => { + assert.strictEqual(resolveRootSeed({ hashId: 'x', randomSeed: 12345 }), 12345) + }) + + await it('should derive from hashId when randomSeed is missing', () => { + const a = resolveRootSeed({ hashId: 'x' }) + const b = resolveRootSeed({ hashId: 'x' }) + assert.strictEqual(a, b) + assert.notStrictEqual(a, resolveRootSeed({ hashId: 'y' })) + }) + + await it('should be stable and non-zero for empty hashId', () => { + const seed = resolveRootSeed({ hashId: '' }) + assert.strictEqual(typeof seed, 'number') + assert.ok(seed >>> 0 === seed, 'expected a 32-bit unsigned integer') + }) + }) + + await describe('AC 1-phase invariants', async () => { + await it('should satisfy P = V·I·phases within ±1 W after rounding', () => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 7400, + numberOfPhases: 1, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }) + const expected = sample.voltageV * sample.currentA * 1 + assert.ok( + Math.abs(sample.powerW - expected) <= 0.01, + `AC1: |P - V·I·phases|=${Math.abs(sample.powerW - expected).toString()} exceeded 0.01W tolerance` + ) + }) + }) + + await describe('AC 3-phase invariants', async () => { + await it('should satisfy P = V·I·3 within ±3 W after rounding', () => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 22000, + numberOfPhases: 3, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 7, + transactionId: 1, + }) + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: 10000, + nowMs: 10000, + rootSeed: 7, + voltageNoise: false, + }) + const expected = sample.voltageV * sample.currentA * 3 + assert.ok( + Math.abs(sample.powerW - expected) <= 0.01, + `AC3: |P - V·I·3|=${Math.abs(sample.powerW - expected).toString()} exceeded 0.01W tolerance` + ) + }) + }) + + await describe('DC invariants', async () => { + await it('should satisfy P = V·I within ±1 W after rounding', () => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.DC, + evseMaxPowerW: 50000, + voltageOut: 400, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 1337, + transactionId: 1, + }) + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: 5000, + nowMs: 5000, + rootSeed: 1337, + voltageNoise: false, + }) + const expected = sample.voltageV * sample.currentA + assert.ok( + Math.abs(sample.powerW - expected) <= 0.01, + `DC: |P - V·I|=${Math.abs(sample.powerW - expected).toString()} exceeded 0.01W tolerance` + ) + }) + }) + + await describe('SoC saturation', async () => { + await it('should produce zero power and no ΔE when SoC ≥ 100', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + + session.socPercent = 100 + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }) + assert.strictEqual(sample.powerW, 0) + assert.strictEqual(sample.currentA, 0) + assert.strictEqual(sample.deltaEnergyWh, 0) + }) + + await it('should keep SoC as a fixed point at 100 %', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + + session.socPercent = 100 + sessions.set(1, session) + for (let i = 0; i < 5; i++) { + computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS * (i + 1), + rootSeed: 42, + voltageNoise: false, + }) + } + assert.strictEqual(session.socPercent, 100) + }) + }) + + await describe('multi-sample monotonicity', async () => { + await it('should keep energy and SoC monotone non-decreasing over N samples', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + let prevEnergy = 0 + let prevSoc = 0 + for (let i = 0; i < 30; i++) { + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: 10000, + nowMs: 10000 * (i + 1), + rootSeed: 42, + voltageNoise: false, + }) + // The exported register is projected; caller applies advance. Simulate. + connectorStatus.energyActiveImportRegisterValue = + (connectorStatus.energyActiveImportRegisterValue ?? 0) + sample.deltaEnergyWh + connectorStatus.transactionEnergyActiveImportRegisterValue = + (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + sample.deltaEnergyWh + assert.ok(sample.energyRegisterWh >= prevEnergy) + assert.ok(sample.socPercent >= prevSoc) + prevEnergy = sample.energyRegisterWh + prevSoc = sample.socPercent + } + }) + }) + + await describe('energy register ownership', async () => { + await it('should advance registers even when Energy measurand is not emitted', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + // Only SoC + Voltage — no Energy template configured. + connectorStatus.MeterValues = templatesFor([ + MeterValueMeasurand.STATE_OF_CHARGE, + MeterValueMeasurand.VOLTAGE, + ]) + const before = connectorStatus.energyActiveImportRegisterValue ?? 0 + buildCoherentMeterValue(context, 1, passThroughBuilder, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }) + const after = connectorStatus.energyActiveImportRegisterValue ?? 0 + assert.ok(after > before, 'energy register must advance regardless of template presence') + }) + }) + + await describe('Wh / kWh unit conversion', async () => { + await it('should emit Wh raw when template unit is Wh', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + connectorStatus.MeterValues = templatesFor( + [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER], + MeterValueUnit.WATT_HOUR + ) + const meterValue = buildCoherentMeterValue(context, 1, passThroughBuilder, { + intervalMs: 3_600_000, // 1 h → 1 Wh per 1 W + nowMs: 3_600_000, + rootSeed: 42, + voltageNoise: false, + }) + const registerWh = connectorStatus.energyActiveImportRegisterValue ?? 0 + const emitted = Number(meterValue.sampledValue[0].value) + // Rounded to 2 decimals: emitted ≈ registerWh. + assert.ok( + Math.abs(emitted - Math.round(registerWh * 100) / 100) < 0.01, + `Wh mismatch: registerWh=${registerWh.toString()} emitted=${emitted.toString()}` + ) + }) + + await it('should emit kWh divided by 1000 when template unit is kWh', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + connectorStatus.MeterValues = templatesFor( + [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER], + MeterValueUnit.KILO_WATT_HOUR + ) + const meterValue = buildCoherentMeterValue(context, 1, passThroughBuilder, { + intervalMs: 3_600_000, + nowMs: 3_600_000, + rootSeed: 42, + voltageNoise: false, + }) + const registerWh = connectorStatus.energyActiveImportRegisterValue ?? 0 + const emitted = Number(meterValue.sampledValue[0].value) + const expectedKwh = Math.round((registerWh / 1000) * 100) / 100 + assert.ok( + Math.abs(emitted - expectedKwh) < 0.01, + `kWh mismatch: registerWh=${registerWh.toString()} emitted=${emitted.toString()} expectedKwh=${expectedKwh.toString()}` + ) + }) + }) + + await describe('voltage noise across samples', async () => { + await it('should advance the voltage PRNG state across samples with voltageNoise=true', () => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 22000, + numberOfPhases: 1, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const voltages: number[] = [] + for (let i = 0; i < 5; i++) { + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS * (i + 1), + rootSeed: 42, + }) + voltages.push(sample.voltageV) + } + const uniqueVoltages = new Set(voltages) + assert.ok( + uniqueVoltages.size >= 2, + `voltage noise stagnated across samples (PRNG seed reset each call): ${voltages + .map(v => v.toString()) + .join(', ')}` + ) + for (const v of voltages) { + assert.ok( + v >= 230 * 0.99 - 1e-6 && v <= 230 * 1.01 + 1e-6, + `voltage out of ±1 % band: ${v.toString()}` + ) + } + }) + }) + + await describe('SoC cap energy coherency', async () => { + await it('should clamp deltaEnergyWh to remaining battery capacity when crossing 100 % SoC', () => { + // Taper-free profile so full power is delivered at 99.8 % SoC. + // 40 kWh battery, 99.8 % SoC → remaining = 0.2/100 × 40000 = 80 Wh. + // 11 kW × 60 s = 183 Wh would overshoot without clamping. + const flatProfile: EvProfile = { + ...baseProfile, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 100 }, + ], + } + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 22000, + numberOfPhases: 1, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [flatProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + session.socPercent = 99.8 + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: 60000, + nowMs: 60000, + rootSeed: 42, + voltageNoise: false, + }) + const remainingWh = ((100 - 99.8) / 100) * flatProfile.batteryCapacityWh + assert.ok( + sample.deltaEnergyWh <= remainingWh + 1e-6, + `deltaEnergyWh=${sample.deltaEnergyWh.toString()} exceeded remaining capacity ${remainingWh.toString()} Wh` + ) + assert.strictEqual(sample.socPercent, 100) + // INV-3 (P × Δt / 3.6e6 = ΔE): reported P must be recomputed from clamped ΔE. + const expectedPowerW = (sample.deltaEnergyWh * 3_600_000) / 60000 + assert.ok( + Math.abs(sample.powerW - expectedPowerW) <= 1, + `emitted powerW=${sample.powerW.toString()} incoherent with clamped ΔE (expected ~${expectedPowerW.toString()} W)` + ) + assert.ok( + Math.abs(sample.deltaEnergyWh - remainingWh) < 0.01, + `deltaEnergyWh=${sample.deltaEnergyWh.toString()} != remainingWh=${remainingWh.toString()}` + ) + }) + }) + + await describe('determinism', async () => { + await it('should produce identical sequences for identical seed + transactionId', () => { + const runOnce = (): number[] => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const values: number[] = [] + for (let i = 0; i < 20; i++) { + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: 10000, + nowMs: 10000 * (i + 1), + rootSeed: 42, + }) + values.push(sample.voltageV, sample.powerW, sample.currentA, sample.socPercent) + } + return values + } + const a = runOnce() + const b = runOnce() + assert.deepStrictEqual(a, b) + }) + }) + + await describe('INV-1 in capacity-clamp branch', async () => { + await it('should keep V·I·phases coherent with reported P after AC 3-phase clamp', () => { + const flatProfile: EvProfile = { + ...baseProfile, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 100 }, + ], + } + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 22000, + numberOfPhases: 3, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [flatProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + session.socPercent = 99.8 + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: 60000, + nowMs: 60000, + rootSeed: 42, + voltageNoise: false, + }) + const viPhases = sample.voltageV * sample.currentA * 3 + assert.ok( + Math.abs(sample.powerW - viPhases) <= 0.01, + `AC 3-phase capacity clamp: |P - V·I·phases|=${Math.abs(sample.powerW - viPhases).toString()} exceeded 2 × ROUNDING_SCALE (0.01 W)` + ) + const remainingWh = ((100 - 99.8) / 100) * flatProfile.batteryCapacityWh + assert.ok( + sample.deltaEnergyWh <= remainingWh + 1e-6, + `AC 3-phase capacity clamp: ΔE=${sample.deltaEnergyWh.toString()} > remainingWh=${remainingWh.toString()}` + ) + }) + + await it('should keep V·I coherent with reported P after DC clamp', () => { + const flatProfile: EvProfile = { + ...baseProfile, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 100 }, + ], + } + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.DC, + evseMaxPowerW: 50000, + voltageOut: 400, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [flatProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + session.socPercent = 99.9 + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: 60000, + nowMs: 60000, + rootSeed: 42, + voltageNoise: false, + }) + const vi = sample.voltageV * sample.currentA + assert.ok( + Math.abs(sample.powerW - vi) <= 0.01, + `DC capacity clamp: |P - V·I|=${Math.abs(sample.powerW - vi).toString()} exceeded 2 × ROUNDING_SCALE (0.01 W)` + ) + }) + }) + + await describe('intervalMs defensive guard', async () => { + await it('should not contaminate socPercent or deltaEnergyWh with NaN at saturated SoC', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + session.socPercent = 100 + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: 0, + nowMs: 0, + rootSeed: 42, + voltageNoise: false, + }) + assert.ok(!Number.isNaN(sample.powerW), 'powerW must not be NaN') + assert.ok(!Number.isNaN(sample.currentA), 'currentA must not be NaN') + assert.ok(!Number.isNaN(sample.deltaEnergyWh), 'deltaEnergyWh must not be NaN') + assert.ok(!Number.isNaN(sample.socPercent), 'sample.socPercent must not be NaN') + assert.ok(!Number.isNaN(session.socPercent), 'session.socPercent must not be NaN') + assert.strictEqual(sample.deltaEnergyWh, 0) + // Second call to confirm session state stays healthy. + const next = computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }) + assert.ok(!Number.isNaN(next.powerW)) + assert.ok(!Number.isNaN(session.socPercent)) + assert.strictEqual(session.socPercent, 100) + }) + + await it('should short-circuit on intervalMs=0 with non-saturated SoC', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const socBefore = session.socPercent + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: 0, + nowMs: 0, + rootSeed: 42, + voltageNoise: false, + }) + assert.strictEqual(sample.deltaEnergyWh, 0) + assert.strictEqual(sample.powerW, 0) + assert.strictEqual(session.socPercent, socBefore) + }) + + await it('should short-circuit on NaN intervalMs without poisoning session state', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const socBefore = session.socPercent + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: Number.NaN, + nowMs: 0, + rootSeed: 42, + voltageNoise: false, + }) + assert.ok(Number.isFinite(sample.powerW), 'powerW must be finite') + assert.ok(Number.isFinite(sample.currentA), 'currentA must be finite') + assert.ok(Number.isFinite(sample.deltaEnergyWh), 'deltaEnergyWh must be finite') + assert.ok(Number.isFinite(sample.socPercent), 'sample.socPercent must be finite') + assert.ok(Number.isFinite(session.socPercent), 'session.socPercent must be finite') + assert.strictEqual(sample.deltaEnergyWh, 0) + assert.strictEqual(sample.powerW, 0) + assert.strictEqual(session.socPercent, socBefore) + }) + + await it('should short-circuit on +Infinity intervalMs without poisoning session state', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const socBefore = session.socPercent + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: Number.POSITIVE_INFINITY, + nowMs: 0, + rootSeed: 42, + voltageNoise: false, + }) + assert.ok(Number.isFinite(sample.powerW), 'powerW must be finite') + assert.ok(Number.isFinite(sample.currentA), 'currentA must be finite') + assert.ok(Number.isFinite(sample.deltaEnergyWh), 'deltaEnergyWh must be finite') + assert.ok(Number.isFinite(sample.socPercent), 'sample.socPercent must be finite') + assert.ok(Number.isFinite(session.socPercent), 'session.socPercent must be finite') + assert.strictEqual(sample.deltaEnergyWh, 0) + assert.strictEqual(sample.powerW, 0) + assert.strictEqual(session.socPercent, socBefore) + }) + }) + + await describe('runtime PRNG isolation from session', async () => { + await it('should not expose voltagePrng on CoherentSession', () => { + const { context } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + assert.ok( + !Object.prototype.hasOwnProperty.call(session, 'voltagePrng'), + 'CoherentSession must not carry voltagePrng (moved to module-scope runtime state)' + ) + }) + + await it('should restart voltage-noise stream after dispose', () => { + const { connectorStatus, context, sessions } = buildContext() + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const v1 = computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + }).voltageV + const v2 = computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: 60000, + rootSeed: 42, + }).voltageV + // Dispose and re-run: v3 must equal v1 (fresh PRNG from same seed). + assert.ok(disposeCoherentSessionRuntime(session)) + const v3 = computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: 90000, + rootSeed: 42, + }).voltageV + assert.strictEqual(v3, v1, 'dispose must clear cached PRNG state') + assert.notStrictEqual(v2, v3, 'without dispose, v2 would differ from a fresh draw') + }) + }) + + await describe('resolveRootSeed DRY dedup', async () => { + await it('should match hashLabel for the hashId path', () => { + assert.strictEqual(resolveRootSeed({ hashId: 'abc' }), hashLabel('abc')) + assert.strictEqual(resolveRootSeed({ hashId: '' }), hashLabel('')) + assert.strictEqual(resolveRootSeed({ hashId: 'CS-1234' }), hashLabel('CS-1234')) + }) + }) + + await describe('per-phase emission', async () => { + await it('should emit one SampledValue per phase-qualified template on AC 3-phase', () => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 22000, + numberOfPhases: 3, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + // Phase-preserving builder: include template.phase and template.measurand + // so per-phase emissions can be counted and cross-checked. + const phaseBuilder: BuildVersionedSampledValue = (template, value): SampledValue => + ({ + ...(template.measurand != null && { measurand: template.measurand }), + ...(template.phase != null && { phase: template.phase as never }), + ...(template.unit != null && { unit: template.unit as never }), + value: value.toString(), + }) as SampledValue + connectorStatus.MeterValues = [ + { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L1_N }, + { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L2_N }, + { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L3_N }, + { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L1_L2 }, + { measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT, unit: MeterValueUnit.WATT }, + { + measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT, + phase: MeterValuePhase.L1_N, + unit: MeterValueUnit.WATT, + }, + { + measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT, + phase: MeterValuePhase.L2_N, + unit: MeterValueUnit.WATT, + }, + { + measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT, + phase: MeterValuePhase.L3_N, + unit: MeterValueUnit.WATT, + }, + { measurand: MeterValueMeasurand.CURRENT_IMPORT, phase: MeterValuePhase.L1 }, + { measurand: MeterValueMeasurand.CURRENT_IMPORT, phase: MeterValuePhase.N }, + ] as unknown as SampledValueTemplate[] + const mv = buildCoherentMeterValue(context, 1, phaseBuilder, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }) + const byPhase = (measurand: MeterValueMeasurand): Record => { + const out: Record = {} + for (const sv of mv.sampledValue) { + if ((sv as { measurand?: MeterValueMeasurand }).measurand !== measurand) continue + const p = (sv as { phase?: string }).phase ?? 'aggregate' + out[p] = Number((sv as { value: string }).value) + } + return out + } + const v = byPhase(MeterValueMeasurand.VOLTAGE) + const p = byPhase(MeterValueMeasurand.POWER_ACTIVE_IMPORT) + const i = byPhase(MeterValueMeasurand.CURRENT_IMPORT) + // L-N voltages carry the sampled voltage (nominal here since noise is off). + assert.strictEqual(v['L1-N'], 230) + assert.strictEqual(v['L2-N'], 230) + assert.strictEqual(v['L3-N'], 230) + // L-L voltage: √3 × V_LN ≈ 398.37. + assert.ok( + Math.abs(v['L1-L2'] - Math.sqrt(3) * 230) < 0.01, + `L-L voltage=${v['L1-L2'].toString()} not ≈ √3·230` + ) + // Aggregate power exists and per-phase powers sum to it within tolerance. + const perPhaseSum = p['L1-N'] + p['L2-N'] + p['L3-N'] + assert.ok( + Math.abs(perPhaseSum - p.aggregate) < 0.05, + `per-phase Σ=${perPhaseSum.toString()} not ≈ aggregate=${p.aggregate.toString()}` + ) + // N-phase current is 0 for balanced 3-φ Y. + assert.strictEqual(i.N, 0) + // L1 current matches the aggregate line current from computeCoherentSample. + assert.ok(i.L1 > 0) + }) + + await it('should skip L-L voltage on 1-phase AC (log-and-skip)', () => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 7400, + numberOfPhases: 1, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + connectorStatus.MeterValues = [ + { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L1_L2 }, + { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L1_N }, + ] as unknown as SampledValueTemplate[] + const mv = buildCoherentMeterValue(context, 1, passThroughBuilder, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }) + const voltageSamples = mv.sampledValue.filter( + sv => (sv as { measurand?: MeterValueMeasurand }).measurand === MeterValueMeasurand.VOLTAGE + ) + assert.strictEqual(voltageSamples.length, 1, 'L-L voltage on 1-phase must be skipped') + }) + + await it('should emit per-phase energy register as register/phases on AC 3-phase', () => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 22000, + numberOfPhases: 3, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [baseProfile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + connectorStatus.energyActiveImportRegisterValue = 6000 + connectorStatus.transactionEnergyActiveImportRegisterValue = 6000 + connectorStatus.MeterValues = [ + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L1_N, + unit: MeterValueUnit.WATT_HOUR, + }, + ] as unknown as SampledValueTemplate[] + const mv = buildCoherentMeterValue(context, 1, passThroughBuilder, { + intervalMs: 3_600_000, + nowMs: 3_600_000, + rootSeed: 42, + voltageNoise: false, + }) + const emitted = Number(mv.sampledValue[0].value) + // register / phases; register is advanced during the call, so the emitted + // value reflects the post-advance state divided by 3. + const postRegister = connectorStatus.energyActiveImportRegisterValue ?? 0 + assert.ok( + Math.abs(emitted - postRegister / 3) < 0.05, + `L-N register=${emitted.toString()} not ≈ ${(postRegister / 3).toString()}` + ) + }) + }) +}) diff --git a/tests/charging-station/meter-values/EvProfiles.test.ts b/tests/charging-station/meter-values/EvProfiles.test.ts new file mode 100644 index 00000000..cf9a1b4e --- /dev/null +++ b/tests/charging-station/meter-values/EvProfiles.test.ts @@ -0,0 +1,207 @@ +/** + * @file Tests for EV profile loader and helpers. + * @description Verifies profile validation, selection, and curve + * interpolation used by the coherent MeterValues generator. + * + * Covers: + * - interpolateChargingCurve — boundaries and mid-points + * - selectEvProfile — weight-based selection determinism + * - loadEvProfilesFile — fail-soft on missing/invalid files, sort by SoC + */ + +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, it } from 'node:test' + +import type { EvProfile } from '../../../src/charging-station/meter-values/types.js' + +import { + interpolateChargingCurve, + loadEvProfilesFile, + selectEvProfile, +} from '../../../src/charging-station/meter-values/EvProfiles.js' +import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' + +const midProfile: EvProfile = { + batteryCapacityWh: 40000, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 0.5, socPercent: 50 }, + { powerFraction: 0.1, socPercent: 100 }, + ], + id: 'mid', + initialSocPercentMax: 60, + initialSocPercentMin: 10, + maxPowerW: 11000, + weight: 1, +} + +await describe('EvProfiles', async () => { + afterEach(() => { + standardCleanup() + }) + await describe('interpolateChargingCurve', async () => { + await it('should return endpoint value at the lower boundary', () => { + assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 0), 1) + }) + + await it('should return endpoint value at the upper boundary', () => { + assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 100), 0.1) + }) + + await it('should clamp below the first point', () => { + assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, -10), 1) + }) + + await it('should clamp above the last point', () => { + assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 200), 0.1) + }) + + await it('should interpolate the midpoint linearly', () => { + // At SoC=50 the curve value is 0.5 exactly. + assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 50), 0.5) + // At SoC=25: halfway between (0,1) and (50,0.5) → 0.75. + assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 25), 0.75) + // At SoC=75: halfway between (50,0.5) and (100,0.1) → 0.30. + assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 75), 0.3) + }) + + await it('should return 1 for empty curves', () => { + assert.strictEqual(interpolateChargingCurve([], 50), 1) + }) + }) + + await describe('selectEvProfile', async () => { + const profiles: EvProfile[] = [ + { ...midProfile, id: 'A', weight: 1 }, + { ...midProfile, id: 'B', weight: 3 }, + ] + + await it('should always select first profile with random=0', () => { + const chosen = selectEvProfile(profiles, 0) + assert.strictEqual(chosen.id, 'A') + }) + + await it('should select second profile when random exceeds first weight fraction', () => { + // totalWeight=4. At random=0.5 → target=2 → A(1) < 2, B(4) >= 2 ⇒ B. + assert.strictEqual(selectEvProfile(profiles, 0.5).id, 'B') + }) + + await it('should fall back to first profile when total weight is zero', () => { + const zero: EvProfile[] = [ + { ...midProfile, id: 'A', weight: 0 }, + { ...midProfile, id: 'B', weight: 0 }, + ] + assert.strictEqual(selectEvProfile(zero, 0.999).id, 'A') + }) + }) + + await describe('loadEvProfilesFile', async () => { + await it('should return undefined on missing file (fail-soft)', () => { + const result = loadEvProfilesFile('/nonexistent/path/ev-profiles.json', 'test') + assert.strictEqual(result, undefined) + }) + + await it('should return undefined on invalid JSON (fail-soft)', () => { + const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-')) + const path = join(dir, 'bad.json') + writeFileSync(path, '{not json}') + try { + const result = loadEvProfilesFile(path, 'test') + assert.strictEqual(result, undefined) + } finally { + rmSync(dir, { force: true, recursive: true }) + } + }) + + await it('should return undefined on schema violation', () => { + const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-')) + const path = join(dir, 'bad-schema.json') + writeFileSync( + path, + JSON.stringify({ + profiles: [ + { + // missing batteryCapacityWh and others + chargingCurve: [{ powerFraction: 1, socPercent: 0 }], + id: 'x', + }, + ], + }) + ) + try { + const result = loadEvProfilesFile(path, 'test') + assert.strictEqual(result, undefined) + } finally { + rmSync(dir, { force: true, recursive: true }) + } + }) + + await it('should load a valid file and sort curve by socPercent', () => { + const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-')) + const path = join(dir, 'ok.json') + writeFileSync( + path, + JSON.stringify({ + profiles: [ + { + batteryCapacityWh: 40000, + // Intentionally unordered — loader must sort in place. + chargingCurve: [ + { powerFraction: 0.1, socPercent: 100 }, + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 0.5, socPercent: 50 }, + ], + id: 'test', + initialSocPercentMax: 60, + initialSocPercentMin: 10, + maxPowerW: 11000, + weight: 1, + }, + ], + }) + ) + try { + const result = loadEvProfilesFile(path, 'test') + assert.ok(result != null) + const curve = result.profiles[0].chargingCurve + assert.strictEqual(curve[0].socPercent, 0) + assert.strictEqual(curve[1].socPercent, 50) + assert.strictEqual(curve[2].socPercent, 100) + } finally { + rmSync(dir, { force: true, recursive: true }) + } + }) + + await it('should swap inverted initial SoC bounds', () => { + const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-')) + const path = join(dir, 'inverted.json') + writeFileSync( + path, + JSON.stringify({ + profiles: [ + { + batteryCapacityWh: 40000, + chargingCurve: [{ powerFraction: 1, socPercent: 0 }], + id: 'test', + initialSocPercentMax: 20, + initialSocPercentMin: 80, + maxPowerW: 11000, + weight: 1, + }, + ], + }) + ) + try { + const result = loadEvProfilesFile(path, 'test') + assert.ok(result != null) + assert.strictEqual(result.profiles[0].initialSocPercentMin, 20) + assert.strictEqual(result.profiles[0].initialSocPercentMax, 80) + } finally { + rmSync(dir, { force: true, recursive: true }) + } + }) + }) +}) diff --git a/tests/charging-station/meter-values/Prng.test.ts b/tests/charging-station/meter-values/Prng.test.ts new file mode 100644 index 00000000..702d6ad0 --- /dev/null +++ b/tests/charging-station/meter-values/Prng.test.ts @@ -0,0 +1,107 @@ +/** + * @file Tests for coherent MeterValues PRNG. + * @description Verifies deterministic seeded PRNG behavior and stream + * splitting independence for the physics-based coherent generator. + * + * Covers: + * - mulberry32 — determinism (same seed twice → identical sequence) + * - deriveSeed — label-based split streams stay independent + * - hashLabel — stable across runs + */ + +import assert from 'node:assert/strict' +import { afterEach, describe, it } from 'node:test' + +import { + deriveSeed, + hashLabel, + mulberry32, +} from '../../../src/charging-station/meter-values/Prng.js' +import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' + +await describe('Prng', async () => { + afterEach(() => { + standardCleanup() + }) + await describe('mulberry32', async () => { + await it('should produce identical sequences for identical seeds', () => { + const a = mulberry32(42) + const b = mulberry32(42) + for (let i = 0; i < 100; i++) { + assert.strictEqual(a(), b()) + } + }) + + await it('should produce different sequences for different seeds', () => { + const a = mulberry32(1) + const b = mulberry32(2) + let differences = 0 + for (let i = 0; i < 100; i++) { + if (a() !== b()) { + differences++ + } + } + assert.ok(differences > 95, `expected >95 differing samples, got ${differences.toString()}`) + }) + + await it('should produce floats in [0, 1)', () => { + const prng = mulberry32(1337) + for (let i = 0; i < 10000; i++) { + const v = prng() + assert.ok(v >= 0 && v < 1, `value ${v.toString()} outside [0, 1)`) + } + }) + }) + + await describe('hashLabel', async () => { + await it('should hash identical labels to identical values', () => { + assert.strictEqual(hashLabel('VOLTAGE_NOISE'), hashLabel('VOLTAGE_NOISE')) + }) + + await it('should hash distinct labels to distinct values', () => { + assert.notStrictEqual(hashLabel('VOLTAGE_NOISE'), hashLabel('POWER_NOISE')) + assert.notStrictEqual(hashLabel('PROFILE_PICK'), hashLabel('INITIAL_SOC')) + }) + + await it('should be stable across calls (documented as such)', () => { + // Regression guard: if the FNV-1a constants ever change, this test + // catches the resulting sequence shift. + assert.strictEqual(hashLabel('SEED'), 0x8e37556c) + assert.strictEqual(hashLabel('A'), 0xc40bf6cc) + }) + }) + + await describe('deriveSeed', async () => { + await it('should produce independent streams per label', () => { + const root = 42 + const seedA = deriveSeed(root, 'A') + const seedB = deriveSeed(root, 'B') + assert.notStrictEqual(seedA, seedB) + + const streamA = mulberry32(seedA) + const streamB = mulberry32(seedB) + // Two streams from different labels should not lock-step. + let same = 0 + for (let i = 0; i < 100; i++) { + if (streamA() === streamB()) { + same++ + } + } + assert.ok(same < 5, `expected mostly different samples, ${same.toString()} matched`) + }) + + await it('should be non-shifting: adding a new label leaves existing streams intact', () => { + const root = 1234 + const seed1 = deriveSeed(root, 'STREAM_A') + const seed2 = deriveSeed(root, 'STREAM_B') + // Introduce a fictitious new consumer. + const seedNew = deriveSeed(root, 'STREAM_NEW') + // Existing seeds must be unchanged. + assert.strictEqual(seed1, deriveSeed(root, 'STREAM_A')) + assert.strictEqual(seed2, deriveSeed(root, 'STREAM_B')) + // The new stream must be distinct from the existing ones. + assert.notStrictEqual(seedNew, seed1) + assert.notStrictEqual(seedNew, seed2) + }) + }) +}) diff --git a/tests/charging-station/meter-values/StrategyDispatch.test.ts b/tests/charging-station/meter-values/StrategyDispatch.test.ts new file mode 100644 index 00000000..4d1d3ecc --- /dev/null +++ b/tests/charging-station/meter-values/StrategyDispatch.test.ts @@ -0,0 +1,197 @@ +/** + * @file Tests for coherent MeterValues strategy dispatch. + * @description Verifies the strategy gate in buildMeterValue: + * - flag off / absent → random/fixed code path unchanged. + * - flag on + session → coherent path emits coherent SampledValues. + * - flag on + no session → falls back to the random/fixed path. + * + * Covers the strategy gate boundary: the gate runs AFTER the versioned + * SampledValue dispatcher is constructed and BEFORE the random/fixed + * measurand generation, so the coherent path can emit versioned + * SampledValues without duplicating the dispatcher logic. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation, CoherentSession } from '../../../src/charging-station/index.js' +import type { SampledValueTemplate } from '../../../src/types/index.js' + +import { addConfigurationKey } from '../../../src/charging-station/index.js' +import { buildMeterValue } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js' +import { + CurrentType, + MeterValueMeasurand, + OCPPVersion, + StandardParametersKey, + Voltage, +} from '../../../src/types/index.js' +import { Constants } from '../../../src/utils/index.js' +import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' +import { + TEST_CHARGING_STATION_BASE_NAME, + TEST_METER_VALUES_INTERVAL_MS, + TEST_TRANSACTION_ID, +} from '../ChargingStationTestConstants.js' +import { createMockChargingStation } from '../helpers/StationHelpers.js' + +const energyTemplate: SampledValueTemplate = { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: 'Wh', + value: '0', +} as unknown as SampledValueTemplate + +await describe('StrategyDispatch', async () => { + let station: ChargingStation + + afterEach(() => { + standardCleanup() + }) + + await describe('flag absent (default random/fixed)', async () => { + beforeEach(() => { + const { station: s } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + stationInfo: { ocppVersion: OCPPVersion.VERSION_16 }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = [energyTemplate] + connectorStatus.transactionId = TEST_TRANSACTION_ID + } + }) + + await it('should not create coherent sessions', () => { + assert.strictEqual(station.getCoherentSession(TEST_TRANSACTION_ID), undefined) + }) + + await it('should return no-op when destroying a non-existent session', () => { + assert.strictEqual(station.destroyCoherentSession(TEST_TRANSACTION_ID), false) + }) + + await it('should build a MeterValue via the random/fixed path', () => { + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID, + TEST_METER_VALUES_INTERVAL_MS + ) + assert.ok(meterValue.timestamp instanceof Date) + assert.ok(Array.isArray(meterValue.sampledValue)) + }) + }) + + await describe('flag on + no EV profiles configured', async () => { + beforeEach(() => { + const { station: s } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + stationInfo: { + coherentMeterValues: true, + ocppVersion: OCPPVersion.VERSION_16, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = [energyTemplate] + connectorStatus.transactionId = TEST_TRANSACTION_ID + } + }) + + await it('should not create a coherent session without profiles', () => { + const created = station.createCoherentSession(TEST_TRANSACTION_ID, 1) + assert.strictEqual(created, undefined) + assert.strictEqual(station.getCoherentSession(TEST_TRANSACTION_ID), undefined) + }) + + await it('should fall through to the random/fixed path in buildMeterValue', () => { + // Without a session buildMeterValue must NOT call coherent code. + // Random/fixed path returns non-empty when Energy template is present. + addConfigurationKey( + station, + StandardParametersKey.MeterValuesSampledData, + 'Energy.Active.Import.Register' + ) + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID, + TEST_METER_VALUES_INTERVAL_MS + ) + assert.ok(meterValue.sampledValue.length > 0) + }) + }) + + await describe('flag on + injected session', async () => { + beforeEach(() => { + const { station: s } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + stationInfo: { + coherentMeterValues: true, + ocppVersion: OCPPVersion.VERSION_16, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = [ + { + measurand: MeterValueMeasurand.STATE_OF_CHARGE, + unit: 'Percent', + } as unknown as SampledValueTemplate, + ] + connectorStatus.transactionId = TEST_TRANSACTION_ID + connectorStatus.transactionEnergyActiveImportRegisterValue = 0 + connectorStatus.energyActiveImportRegisterValue = 0 + } + }) + + await it('should not create a session when profiles are missing', () => { + // Simulate missing profiles: createCoherentSession is a no-op. + assert.strictEqual(station.createCoherentSession(TEST_TRANSACTION_ID, 1), undefined) + }) + + await it('should route through the coherent path when a session is injected directly', () => { + const now = Date.now() + const session: CoherentSession = { + connectorId: 1, + currentType: CurrentType.AC, + numberOfPhases: 1, + profile: { + batteryCapacityWh: 40000, + chargingCurve: [{ powerFraction: 1, socPercent: 0 }], + id: 'inline', + initialSocPercentMax: 30, + initialSocPercentMin: 30, + maxPowerW: 11000, + weight: 1, + }, + rampUpDurationMs: 0, + sessionStartMs: now, + socPercent: 30, + transactionId: TEST_TRANSACTION_ID, + voltageOutNominal: Voltage.VOLTAGE_230, + } + station.__injectCoherentSession(TEST_TRANSACTION_ID, session) + + addConfigurationKey(station, StandardParametersKey.MeterValuesSampledData, 'SoC') + const registerBefore = station.getConnectorStatus(1)?.energyActiveImportRegisterValue ?? -1 + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID, + TEST_METER_VALUES_INTERVAL_MS + ) + const registerAfter = station.getConnectorStatus(1)?.energyActiveImportRegisterValue ?? -1 + + // Register must advance because coherent path owns updates. + assert.ok(registerAfter >= registerBefore) + // Only SoC template configured → SampledValue count matches. + assert.ok(meterValue.sampledValue.length <= 1) + }) + }) +}) diff --git a/tests/charging-station/ocpp/1.6/OCPP16-CoherentMeterValues.test.ts b/tests/charging-station/ocpp/1.6/OCPP16-CoherentMeterValues.test.ts new file mode 100644 index 00000000..cbba4b0d --- /dev/null +++ b/tests/charging-station/ocpp/1.6/OCPP16-CoherentMeterValues.test.ts @@ -0,0 +1,399 @@ +/** + * @file Integration-style test for coherent MeterValues over an OCPP 1.6 transaction. + * @description Simulates StartTransaction → 3 MeterValues samples → StopTransaction + * with `coherentMeterValues: true`, a directly-injected coherent session + * (bypasses on-disk profile file loading in tests), and a fixed seed. + * + * Covers: per-sample INV-1/INV-2/INV-3 invariants, `meterStop == last coherent + * register`, and fixed-seed determinism across a full StartTransaction → + * MeterValues → StopTransaction cycle. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import type { ChargingStation, CoherentSession } from '../../../../src/charging-station/index.js' +import type { OCPP16ResponseService } from '../../../../src/charging-station/ocpp/1.6/OCPP16ResponseService.js' +import type { + MeterValue, + OCPP16StartTransactionRequest, + OCPP16StartTransactionResponse, + OCPP16StopTransactionRequest, + OCPP16StopTransactionResponse, + SampledValueTemplate, +} from '../../../../src/types/index.js' + +import { addConfigurationKey } from '../../../../src/charging-station/index.js' +import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js' +import { buildMeterValue } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js' +import { + CurrentType, + MeterValueMeasurand, + OCPP16AuthorizationStatus, + OCPP16MeterValueUnit, + OCPP16RequestCommand, + StandardParametersKey, + Voltage, +} from '../../../../src/types/index.js' +import { Constants } from '../../../../src/utils/index.js' +import { + flushMicrotasks, + setupConnectorWithTransaction, + standardCleanup, + withMockTimers, +} from '../../../helpers/TestLifecycleHelpers.js' +import { TEST_ID_TAG, TEST_METER_VALUES_INTERVAL_MS } from '../../ChargingStationTestConstants.js' +import { createOCPP16ResponseTestContext, setMockRequestHandler } from './OCPP16TestUtils.js' + +const CONNECTOR_ID = 1 +const TRANSACTION_ID = 4242 + +const injectSession = (station: ChargingStation, startMs: number): CoherentSession => { + const session: CoherentSession = { + connectorId: CONNECTOR_ID, + currentType: CurrentType.AC, + numberOfPhases: 3, + profile: { + batteryCapacityWh: 40000, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 80 }, + { powerFraction: 0.5, socPercent: 100 }, + ], + id: 'test-ev', + initialSocPercentMax: 30, + initialSocPercentMin: 30, + maxPowerW: 11000, + weight: 1, + }, + rampUpDurationMs: 0, + sessionStartMs: startMs, + socPercent: 30, + transactionId: TRANSACTION_ID, + voltageOutNominal: Voltage.VOLTAGE_230, + } + station.__injectCoherentSession(TRANSACTION_ID, session) + return session +} + +const configureMeterValueTemplates = (station: ChargingStation): void => { + const templates: SampledValueTemplate[] = [ + { + measurand: MeterValueMeasurand.STATE_OF_CHARGE, + unit: 'Percent', + } as unknown as SampledValueTemplate, + { measurand: MeterValueMeasurand.VOLTAGE, unit: 'V' } as unknown as SampledValueTemplate, + { + measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT, + unit: 'W', + } as unknown as SampledValueTemplate, + { measurand: MeterValueMeasurand.CURRENT_IMPORT, unit: 'A' } as unknown as SampledValueTemplate, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: OCPP16MeterValueUnit.WATT_HOUR, + } as unknown as SampledValueTemplate, + ] + const connectorStatus = station.getConnectorStatus(CONNECTOR_ID) + if (connectorStatus != null) { + connectorStatus.MeterValues = templates + } + addConfigurationKey( + station, + StandardParametersKey.MeterValuesSampledData, + 'SoC,Voltage,Power.Active.Import,Current.Import,Energy.Active.Import.Register' + ) +} + +const findValue = (mv: MeterValue, measurand: MeterValueMeasurand): number | undefined => { + const sv = mv.sampledValue.find(x => x.measurand === measurand) + if (sv == null) { + return undefined + } + return Number(sv.value) +} + +const runTransaction = async ( + station: ChargingStation, + responseService: OCPP16ResponseService, + seed: number, + startMs: number +): Promise<{ meterValues: MeterValue[]; stopEnergyWh: number }> => { + // Configure feature flag + seed on the station's live stationInfo. + const stationInfo = station.stationInfo + if (stationInfo == null) { + throw new Error('stationInfo missing') + } + stationInfo.coherentMeterValues = true + stationInfo.randomSeed = seed + + configureMeterValueTemplates(station) + + // Dispatch StartTransaction response so the connector gets a live transaction id. + const request: OCPP16StartTransactionRequest = { + connectorId: CONNECTOR_ID, + idTag: TEST_ID_TAG, + meterStart: 0, + timestamp: new Date(), + } + const response: OCPP16StartTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED }, + transactionId: TRANSACTION_ID, + } + await responseService.responseHandler( + station, + OCPP16RequestCommand.START_TRANSACTION, + response, + request + ) + + // Inject a coherent session directly (skipping on-disk profile file + // loading which is validated elsewhere in EvProfiles.test.ts). + injectSession(station, startMs) + + // Emit 3 samples. Scope `Date.now` per iteration via node:test mock so + // the mock is atomically restored on each loop cycle even if the buildMeterValue + // call throws, avoiding global mutation leaks under concurrent runners. + const meterValues: MeterValue[] = [] + for (let i = 0; i < 3; i++) { + const nowMs = startMs + TEST_METER_VALUES_INTERVAL_MS * (i + 1) + const nowMock = mock.method(Date, 'now', () => nowMs) + try { + const mv = buildMeterValue(station, TRANSACTION_ID, TEST_METER_VALUES_INTERVAL_MS) + meterValues.push(mv) + } finally { + nowMock.mock.restore() + } + } + + // StopTransaction — meterStop should equal the connector register. + const connectorStatus = station.getConnectorStatus(CONNECTOR_ID) + assert.ok(connectorStatus != null, 'connector status missing') + const meterStop = Math.round(connectorStatus.energyActiveImportRegisterValue ?? 0) + const stopRequest: OCPP16StopTransactionRequest = { + idTag: TEST_ID_TAG, + meterStop, + timestamp: new Date(), + transactionId: TRANSACTION_ID, + } + const stopResponse: OCPP16StopTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED }, + } + await responseService.responseHandler( + station, + OCPP16RequestCommand.STOP_TRANSACTION, + stopResponse, + stopRequest + ) + + return { meterValues, stopEnergyWh: meterStop } +} + +await describe('OCPP16CoherentMeterValues', async () => { + let station: ChargingStation + let responseService: OCPP16ResponseService + + beforeEach(() => { + const ctx = createOCPP16ResponseTestContext() + station = ctx.station + responseService = ctx.responseService + setMockRequestHandler(station, async () => Promise.resolve({})) + mock.method(OCPP16ServiceUtils, 'startUpdatedMeterValues', () => { + /* noop */ + }) + mock.method(OCPP16ServiceUtils, 'stopUpdatedMeterValues', () => { + /* noop */ + }) + // Provide a valid MeterValues template so buildTransactionBeginMeterValue + // succeeds during StartTransaction handling. + for (const { connectorId } of station.iterateConnectors(true)) { + const cs = station.getConnectorStatus(connectorId) + if (cs != null) { + cs.MeterValues = [{ unit: OCPP16MeterValueUnit.WATT_HOUR, value: '0' }] + } + } + }) + + afterEach(() => { + standardCleanup() + }) + + await it('should emit invariant-satisfying MeterValues and meterStop == last register', async () => { + const startMs = 1_700_000_000_000 + const { meterValues, stopEnergyWh } = await runTransaction( + station, + responseService, + 42, + startMs + ) + + assert.strictEqual(meterValues.length, 3) + let prevEnergy = -Infinity + let prevSoc = -Infinity + for (let i = 0; i < meterValues.length; i++) { + const mv = meterValues[i] + const voltage = findValue(mv, MeterValueMeasurand.VOLTAGE) + const current = findValue(mv, MeterValueMeasurand.CURRENT_IMPORT) + const power = findValue(mv, MeterValueMeasurand.POWER_ACTIVE_IMPORT) + const energy = findValue(mv, MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) + const soc = findValue(mv, MeterValueMeasurand.STATE_OF_CHARGE) + assert.ok( + voltage != null && current != null && power != null && energy != null && soc != null + ) + // INV-1: P = V·I·phases within ±3 W after rounding (AC 3-phase). + const expectedP = voltage * current * 3 + assert.ok( + Math.abs(power - expectedP) <= 3, + `sample ${i.toString()}: |P - V·I·3|=${Math.abs(power - expectedP).toString()} exceeded 3W` + ) + // INV-2 (SoC monotone non-decreasing) and INV-3 (E monotone non-decreasing). + assert.ok(energy >= prevEnergy, `sample ${i.toString()}: energy regressed`) + assert.ok(soc >= prevSoc, `sample ${i.toString()}: SoC regressed`) + prevEnergy = energy + prevSoc = soc + } + + // Reconstruct the expected stop energy from emitted power samples + // (Σ P·Δt / 3.6e6) as an INDEPENDENT reference derived from the + // MeterValue stream — not from the register itself. Divergence + // between this reconstruction and the reported stop energy would + // indicate the register drifted away from the reported physics. + const MS_PER_HOUR = Constants.MS_PER_HOUR + let expectedAccumulatedWh = 0 + for (const mv of meterValues) { + const powerW = findValue(mv, MeterValueMeasurand.POWER_ACTIVE_IMPORT) + assert.ok(powerW != null) + expectedAccumulatedWh += (powerW * TEST_METER_VALUES_INTERVAL_MS) / MS_PER_HOUR + } + assert.ok( + expectedAccumulatedWh > 0, + `expected some energy delivered across ${meterValues.length.toString()} samples` + ) + assert.ok( + Math.abs(stopEnergyWh - expectedAccumulatedWh) <= 1, + `stopEnergyWh=${stopEnergyWh.toString()} diverged from Σ(P·Δt)=${expectedAccumulatedWh.toString()} Wh` + ) + const lastMeterValue = meterValues.at(-1) + assert.ok(lastMeterValue != null, 'expected at least one MeterValue in stream') + const lastEnergy = findValue(lastMeterValue, MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) + assert.ok(lastEnergy != null) + // Cross-check: stopEnergyWh must match the last MV register within the + // same 1 Wh tolerance as the independent Σ(P·Δt) check. Both are read + // from the emitted OCPP stream (final register value) vs. the response's + // StopTransaction meterStop — divergence would indicate that meterStop + // and the final register drifted apart between emission and finalization. + assert.ok( + Math.abs(stopEnergyWh - lastEnergy) <= 1, + `stopEnergyWh=${stopEnergyWh.toString()} vs last MV register=${lastEnergy.toString()}` + ) + }) + + await it('should produce identical MeterValues sequences for identical seed + transactionId', async () => { + const startMs = 1_700_000_000_000 + const runA = await runTransaction(station, responseService, 99, startMs) + + // Fresh station for second run — same seed + transactionId must reproduce. + const ctx2 = createOCPP16ResponseTestContext() + const station2 = ctx2.station + const responseService2 = ctx2.responseService + setMockRequestHandler(station2, async () => Promise.resolve({})) + for (const { connectorId } of station2.iterateConnectors(true)) { + const cs = station2.getConnectorStatus(connectorId) + if (cs != null) { + cs.MeterValues = [{ unit: OCPP16MeterValueUnit.WATT_HOUR, value: '0' }] + } + } + const runB = await runTransaction(station2, responseService2, 99, startMs) + + // Compare stringified SampledValues for byte-level equality of numeric outputs. + const serialize = (mv: MeterValue): string => + JSON.stringify( + mv.sampledValue.map(sv => ({ + measurand: sv.measurand, + value: sv.value, + })) + ) + for (let i = 0; i < 3; i++) { + assert.strictEqual( + serialize(runA.meterValues[i]), + serialize(runB.meterValues[i]), + `MeterValue ${i.toString()} diverged between runs` + ) + } + assert.strictEqual(runA.stopEnergyWh, runB.stopEnergyWh) + }) + + await it('should not create a session when StartTransaction is REJECTED', async () => { + const stationInfo = station.stationInfo + if (stationInfo != null) { + stationInfo.coherentMeterValues = true + stationInfo.randomSeed = 1 + } + configureMeterValueTemplates(station) + const request: OCPP16StartTransactionRequest = { + connectorId: CONNECTOR_ID, + idTag: TEST_ID_TAG, + meterStart: 0, + timestamp: new Date(), + } + const rejectedResponse: OCPP16StartTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.BLOCKED }, + transactionId: 7, + } + await responseService.responseHandler( + station, + OCPP16RequestCommand.START_TRANSACTION, + rejectedResponse, + request + ) + assert.strictEqual(station.getCoherentSession(7), undefined) + }) + + await it('should destroy the coherent session even when the station stops during postTransactionDelay', async t => { + const stationInfo = station.stationInfo + assert.ok(stationInfo != null, 'stationInfo should be defined') + stationInfo.coherentMeterValues = true + stationInfo.randomSeed = 42 + stationInfo.postTransactionDelay = 5 + station.started = true + setupConnectorWithTransaction(station, CONNECTOR_ID, { transactionId: TRANSACTION_ID }) + injectSession(station, 1_700_000_000_000) + assert.ok( + station.getCoherentSession(TRANSACTION_ID) != null, + 'session should exist before stop' + ) + + const stopRequest: OCPP16StopTransactionRequest = { + idTag: TEST_ID_TAG, + meterStop: 0, + timestamp: new Date(), + transactionId: TRANSACTION_ID, + } + const stopResponse: OCPP16StopTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED }, + } + + await withMockTimers(t, ['setTimeout'], async () => { + const promise = responseService.responseHandler( + station, + OCPP16RequestCommand.STOP_TRANSACTION, + stopResponse, + stopRequest + ) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + station.started = false + t.mock.timers.tick(5000) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + await promise + }) + + assert.strictEqual( + station.getCoherentSession(TRANSACTION_ID), + undefined, + 'coherent session leaked when station stopped during postTransactionDelay' + ) + }) +}) diff --git a/tests/charging-station/ocpp/1.6/OCPP16ResponseService-ForceTxOnInvalid.test.ts b/tests/charging-station/ocpp/1.6/OCPP16ResponseService-ForceTxOnInvalid.test.ts index d6895c2b..afdd584d 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16ResponseService-ForceTxOnInvalid.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16ResponseService-ForceTxOnInvalid.test.ts @@ -219,9 +219,9 @@ await describe('OCPP16ResponseService — forceTransactionOnInvalidIdToken (issu timestamp: new Date(), } const responsePayload: OCPP16StartTransactionResponse = { - // INVALID + flag=true exercises the regression: without the pre-Start - // guard the override would skip the abort path. The guard MUST still - // win. ACCEPTED would not exercise the flag-vs-guard interaction. + // INVALID + flag=true exercises the pre-Start guard: the override + // must not bypass the abort path. ACCEPTED would not exercise the + // flag-vs-guard interaction. idTagInfo: { status: OCPP16AuthorizationStatus.INVALID }, transactionId: 22, } diff --git a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CoherentSession.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CoherentSession.test.ts new file mode 100644 index 00000000..dff540b6 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CoherentSession.test.ts @@ -0,0 +1,150 @@ +/** + * @file Tests for OCPP20ResponseService coherent MeterValues session wiring. + * @description Verifies that TransactionEvent(Started) creates + * a coherent MeterValues session on OCPP 2.0.1, mirroring the OCPP 1.6 path + * in `OCPP16ResponseService.handleResponseStartTransaction`. Also verifies + * the guards: opt-in feature flag and idToken acceptance. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { + OCPP20TransactionEventRequest, + OCPP20TransactionEventResponse, + UUIDv4, +} from '../../../../src/types/index.js' + +import { + createTestableResponseService, + type TestableOCPP20ResponseService, +} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js' +import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js' +import { + OCPP20AuthorizationStatusEnumType, + OCPP20TransactionEventEnumType, + OCPPVersion, +} from '../../../../src/types/index.js' +import { Constants } from '../../../../src/utils/index.js' +import { + setupConnectorWithTransaction, + standardCleanup, +} from '../../../helpers/TestLifecycleHelpers.js' +import { + TEST_CHARGING_STATION_BASE_NAME, + TEST_TRANSACTION_UUID, +} from '../../ChargingStationTestConstants.js' +import { createMockChargingStation } from '../../helpers/StationHelpers.js' +import { buildTransactionEventRequest } from './OCPP20ResponseServiceTestUtils.js' + +const buildStartedRequest = (transactionId: UUIDv4): OCPP20TransactionEventRequest => { + const req = buildTransactionEventRequest(transactionId, OCPP20TransactionEventEnumType.Started) + return req +} + +await describe('OCPP20ResponseServiceCoherentSession', async () => { + let station: ChargingStation + let testable: TestableOCPP20ResponseService + let createSpy: ReturnType> + + beforeEach(() => { + const { station: mockStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + stationInfo: { + coherentMeterValues: true, + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + station = mockStation + setupConnectorWithTransaction(station, 1, { transactionId: 100 }) + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.transactionId = TEST_TRANSACTION_UUID + } + createSpy = mock.method(station, 'createCoherentSession', () => undefined) + const responseService = new OCPP20ResponseService() + testable = createTestableResponseService(responseService) + }) + + afterEach(() => { + standardCleanup() + }) + + await it('should create a coherent session on Started with Accepted idToken', async () => { + const request = buildStartedRequest(TEST_TRANSACTION_UUID) + const response: OCPP20TransactionEventResponse = { + idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted }, + } + + await testable.handleResponseTransactionEvent(station, response, request) + + assert.strictEqual(createSpy.mock.calls.length, 1, 'createCoherentSession must fire once') + assert.strictEqual(createSpy.mock.calls[0].arguments[0], TEST_TRANSACTION_UUID) + assert.strictEqual(createSpy.mock.calls[0].arguments[1], 1) + }) + + await it('should create a coherent session on Started with idTokenInfo omitted (implicit accept)', async () => { + const request = buildStartedRequest(TEST_TRANSACTION_UUID) + // No idTokenInfo → treated as Accepted by handleResponseTransactionEvent. + const response: OCPP20TransactionEventResponse = {} + + await testable.handleResponseTransactionEvent(station, response, request) + + assert.strictEqual(createSpy.mock.calls.length, 1) + }) + + await it('should NOT create a coherent session on rejected idToken without force override', async () => { + const request = buildStartedRequest(TEST_TRANSACTION_UUID) + const response: OCPP20TransactionEventResponse = { + idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Invalid }, + } + + await testable.handleResponseTransactionEvent(station, response, request) + + assert.strictEqual( + createSpy.mock.calls.length, + 0, + 'session must not be created when idToken rejected and force override is off' + ) + }) + + await it('should create a coherent session on rejected idToken WHEN forceTransactionOnInvalidIdToken=true', async () => { + assert.ok(station.stationInfo != null) + station.stationInfo.forceTransactionOnInvalidIdToken = true + const request = buildStartedRequest(TEST_TRANSACTION_UUID) + const response: OCPP20TransactionEventResponse = { + idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Invalid }, + } + + await testable.handleResponseTransactionEvent(station, response, request) + + assert.strictEqual( + createSpy.mock.calls.length, + 1, + 'session must be created when force override is enabled, mirroring OCPP 1.6' + ) + }) + + await it('should NOT create a session for non-Started event types', async () => { + const request = buildTransactionEventRequest( + TEST_TRANSACTION_UUID, + OCPP20TransactionEventEnumType.Updated + ) + const response: OCPP20TransactionEventResponse = { + idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted }, + } + + await testable.handleResponseTransactionEvent(station, response, request) + + assert.strictEqual( + createSpy.mock.calls.length, + 0, + 'session must only be created on eventType=Started' + ) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-PostTransactionDelay.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-PostTransactionDelay.test.ts index 4344088a..f01b7748 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-PostTransactionDelay.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-PostTransactionDelay.test.ts @@ -8,11 +8,11 @@ import assert from 'node:assert/strict' import { afterEach, beforeEach, describe, it, mock } from 'node:test' -import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { ChargingStation, CoherentSession } from '../../../../src/charging-station/index.js' import type { ConnectorStatus } from '../../../../src/types/index.js' import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' -import { OCPPVersion } from '../../../../src/types/index.js' +import { CurrentType, OCPPVersion, Voltage } from '../../../../src/types/index.js' import { flushMicrotasks, standardCleanup, @@ -20,7 +20,7 @@ import { } from '../../../helpers/TestLifecycleHelpers.js' import { createMockChargingStation } from '../../helpers/StationHelpers.js' -await describe('OCPP20ServiceUtils — PostTransactionDelay', async () => { +await describe('OCPP20ServiceUtilsPostTransactionDelay', async () => { let station: ChargingStation let connectorStatus: ConnectorStatus let requestHandlerMock: ReturnType @@ -42,9 +42,7 @@ await describe('OCPP20ServiceUtils — PostTransactionDelay', async () => { }) station = result.station const cs = station.getConnectorStatus(1) - if (cs == null) { - throw new Error('Expected connector 1 to exist') - } + assert.ok(cs != null, 'Expected connector 1 to exist') connectorStatus = cs connectorStatus.transactionStarted = true connectorStatus.transactionId = 'tx-1' @@ -116,4 +114,50 @@ await describe('OCPP20ServiceUtils — PostTransactionDelay', async () => { 'No StatusNotification should be sent' ) }) + + await it('should destroy the coherent session even when the station stops during delay', async t => { + const stubSession: CoherentSession = { + connectorId: 1, + currentType: CurrentType.AC, + numberOfPhases: 1, + profile: { + batteryCapacityWh: 1, + chargingCurve: [{ powerFraction: 0, socPercent: 0 }], + id: 'stub', + initialSocPercentMax: 0, + initialSocPercentMin: 0, + maxPowerW: 1, + weight: 1, + }, + rampUpDurationMs: 0, + sessionStartMs: 0, + socPercent: 0, + transactionId: 'tx-1', + voltageOutNominal: Voltage.VOLTAGE_230, + } + station.__injectCoherentSession('tx-1', stubSession) + assert.ok( + station.getCoherentSession('tx-1') != null, + 'session should exist before cleanupEndedTransaction' + ) + + await withMockTimers(t, ['setTimeout'], async () => { + const promise = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + station.started = false + t.mock.timers.tick(3000) + for (let i = 0; i < 10; i++) { + await flushMicrotasks() + } + await promise + }) + + assert.strictEqual( + station.getCoherentSession('tx-1'), + undefined, + 'coherent session leaked when station stopped during postTransactionDelay' + ) + }) }) -- 2.53.0