From c471bd9b298709de00b4ef457825054d9d7abfd2 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 4 Jul 2026 20:43:43 +0200 Subject: [PATCH] fix(meter-values): physics refinements - 3-phase-only L-L voltage guard + energyRegisterWh docstring (issue #1936 h) (#1941) * docs(meter-values): document energyRegisterWh transaction-scope divergence (issue #1936 h.2) `CoherentSample.energyRegisterWh` (in `CoherentSampleComputer.ts`) is computed from `connectorStatus.transactionEnergyActiveImportRegisterValue + deltaEnergyWh` (transaction-scoped) at sample-compute time, but the emitted `Energy.Active.Import.Register` measurand reads `connectorStatus.energyActiveImportRegisterValue` (station-scoped) via `resolvePhasedValue` in `CoherentMeterValueBuilder.ts`. The two counters diverge whenever the station has non-zero pre-transaction register history, converging only when the transaction begins on a station whose register was previously zero. Documents the divergence on the field's JSDoc so tests and in-process introspection paths reading `sample.energyRegisterWh` understand the scope distinction. No runtime change. * fix(meter-values): tighten L-L voltage guard to 3-phase-only (issue #1936 h.1) `resolvePhasedValue`'s Line-to-Line voltage branch (in `CoherentMeterValueBuilder.ts`) previously used `numberOfPhases <= 1` as the skip guard, which meant that `numberOfPhases === 2` silently emitted `Math.sqrt(2) * voltageV` -- a physically meaningless value. 2-phase AC is unsupported by contract across the codebase: `Helpers.getPhaseRotationValue` branches only on `{0, 1, 3}` (AC 1-phase, AC 3-phase, and DC), and the station-template validator enforces numberOfPhases in `{0, 1, 3}`. The `<= 1` guard was written assuming the enum-open case would default to the 3-phase computation; the 2-phase gap escaped review because no valid station configuration reaches it in production. Tightens the guard to `numberOfPhases !== 3` so any non-3-phase configuration (0, 1, 2, or hypothetical future values) returns `undefined`, letting the caller log-and-skip rather than emit `sqrt(2) * V_LN` nonsense. Same behavior for 3-phase (still emits `sqrt(3) * V_LN`) and for 1-phase (still skipped). Adds a WHY comment documenting the `V_LL = sqrt(3) * V_LN` contract. Test invariant `fail: 0, skipped: 6` preserved (2895 pass / 2901 total). * docs(meter-values): sync resolvePhasedValue JSDoc L-L skip predicate with h.1 (issue #1936 h) The h.1 guard tightened `numberOfPhases <= 1` to `numberOfPhases !== 3`, widening the L-L voltage skip predicate from "1-phase only" to "any non-3-phase count". The `resolvePhasedValue` JSDoc header still described the pre-h.1 skip case as "`sqrt(phases)` collapses to 1 on single-phase, in which case L-L has no physical meaning and the template is skipped" -- factually incomplete post-h.1 (N=0/2/4+ also skip through the same predicate). Updates the JSDoc to reflect the current predicate: "L-L is defined only for balanced 3-phase AC; skipped for any other phase count". Documentation-atomicity fix per AGENTS.md instruction to "update code, tests, and documentation atomically". No runtime change. Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total). * style(meter-values): normalize prose em-dash and ellipsis to ASCII (issue #1936 h) The session ASCII rule permits only the section sign (U+00A7) plus pre-existing physics/math notation in JSDoc as non-ASCII exceptions. It does not permit em-dash (U+2014) or horizontal ellipsis (U+2026) as prose punctuation. Both symbols pre-existed in the 2 files touched by PR-H (5 em-dashes in `CoherentMeterValueBuilder.ts`, 14 em-dashes and 1 ellipsis in `CoherentSampleComputer.ts`) and were classified through R1-R8 as SUB-THRESHOLD per scope-discipline convention (pre-existing outside the line-level PR-H delta). Under explicit user directive to normalize (Option B follow-up), replaces all 20 prose Unicode occurrences with ASCII (U+2014 to `-`, U+2026 to `...`) in the 2 touched files. Physics/math notation (sqrt, multiplication dot, implication arrow, sigma, phi, element-of, delta, less-or-equal, epsilon) preserved per pre-existing JSDoc convention. Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total). --- .../meter-values/CoherentMeterValueBuilder.ts | 25 +++++++---- .../meter-values/CoherentSampleComputer.ts | 42 ++++++++++++------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/src/charging-station/meter-values/CoherentMeterValueBuilder.ts b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts index 8864bf10..da822aef 100644 --- a/src/charging-station/meter-values/CoherentMeterValueBuilder.ts +++ b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts @@ -148,9 +148,9 @@ const groupTemplatesByMeasurand = ( * 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. + * - Voltage: L-N ⇒ `sample.voltageV`; L-L ⇒ `sqrt(3) * sample.voltageV` + * when `numberOfPhases === 3` (L-L is defined only for balanced + * 3-phase AC; skipped for any other phase count); 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). @@ -160,7 +160,7 @@ const groupTemplatesByMeasurand = ( * - 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; + * 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 @@ -207,7 +207,14 @@ const resolvePhasedValue = ( case MeterValueMeasurand.VOLTAGE: if (family === 'Neutral') return 0 if (family === 'LineToLine') { - if (numberOfPhases <= 1) return undefined + // Line-to-line voltage is only defined for 3-phase AC + // (V_LL = sqrt(3) * V_LN); 1-phase has no L-L pair, and + // `numberOfPhases === 2` is unsupported by contract + // (`Helpers.getPhaseRotationValue` branches only on {0, 1, 3}). + // Return `undefined` on any non-3-phase configuration so the + // caller can log-and-skip rather than emit a physically + // meaningless sqrt(2) * V_LN value. + if (numberOfPhases !== 3) return undefined return Math.sqrt(numberOfPhases) * sample.voltageV } return sample.voltageV @@ -272,7 +279,7 @@ const resolveTemplates = ( * - 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 + * 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. @@ -283,7 +290,7 @@ const resolveTemplates = ( * @param session - Active coherent session for the transaction. Callers * look this up via * {@link ../CoherentMeterValuesManager.CoherentMeterValuesManager.getSession} - * at the strategy gate and thread it through — the port no longer + * at the strategy gate and thread it through - the port no longer * exposes session lookup. * @param buildVersionedSampledValue - Versioned SampledValue builder from * the OCPP dispatcher in `OCPPServiceUtils.buildMeterValue`. @@ -339,7 +346,7 @@ export const buildCoherentMeterValue = ( ) if (raw == null) { logger.warn( - `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: unsupported (${measurand}, phase=${String(template.phase)}) — template skipped` + `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: unsupported (${measurand}, phase=${String(template.phase)}) - template skipped` ) continue } @@ -357,6 +364,6 @@ export const buildCoherentMeterValue = ( // 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. + // SampledValue[] cannot be narrowed here - a boundary cast is required. return { sampledValue, timestamp: new Date() } as MeterValue } diff --git a/src/charging-station/meter-values/CoherentSampleComputer.ts b/src/charging-station/meter-values/CoherentSampleComputer.ts index 2f143976..b1ea65a0 100644 --- a/src/charging-station/meter-values/CoherentSampleComputer.ts +++ b/src/charging-station/meter-values/CoherentSampleComputer.ts @@ -16,7 +16,7 @@ * `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 + * 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 %. @@ -24,7 +24,7 @@ * 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 + * 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`. @@ -46,8 +46,8 @@ import { createStreamPrng } from './Prng.js' /** * 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 + * 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 { @@ -76,8 +76,8 @@ const getSessionRuntime = (session: CoherentSession): SessionRuntime => { /** * 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 + * 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 @@ -105,6 +105,20 @@ export const ROUNDING_SCALE = 2 export interface CoherentSample { currentA: number deltaEnergyWh: number + /** + * Transaction-scoped projected register value AFTER the delta from + * {@link advanceEnergyRegister} is applied by the caller: derived from + * `connectorStatus.transactionEnergyActiveImportRegisterValue + + * deltaEnergyWh` at compute time. Exists to expose the transaction-scoped + * projection to tests and other in-process introspection paths. + * + * NOT the value emitted for the `Energy.Active.Import.Register` measurand: + * that measurand reads `connectorStatus.energyActiveImportRegisterValue` + * (station-scoped, monotone-cumulative across the station's lifetime), + * which diverges from this field whenever the station has non-zero + * pre-transaction register history. Converges only when the transaction + * begins on a station whose register was previously zero. + */ energyRegisterWh: number powerW: number socPercent: number @@ -198,19 +212,19 @@ export const advanceEnergyRegister = ( * 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. + * 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 + * 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 + * 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)`. @@ -241,7 +255,7 @@ export const computeCoherentSample = ( // but a template override may set `voltageOut` to 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 + // 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 @@ -273,7 +287,7 @@ export const computeCoherentSample = ( // 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. + // Math.min(1, ...) = 1 - semantically equivalent to rampUpDurationMs = 0. const rampFactor = session.rampUpDurationMs > 0 && Number.isFinite(session.rampUpDurationMs) ? Math.min(1, elapsedMs / session.rampUpDurationMs) @@ -298,8 +312,8 @@ export const computeCoherentSample = ( // EV acceptance from the profile's charging curve at the SoC of THIS // sample (session.socPercent is advanced at the end of the previous - // computeCoherentSample tick — see the `session.socPercent = ...` - // assignment below), not the session's initial SoC — the taper must + // computeCoherentSample tick - see the `session.socPercent = ...` + // assignment below), not the session's initial SoC - the taper must // track live state so P falls off as the battery fills. const acceptanceFraction = interpolateChargingCurve( session.profile.chargingCurve, -- 2.53.0