]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(meter-values): physics refinements - 3-phase-only L-L voltage guard + energyRegis...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sat, 4 Jul 2026 18:43:43 +0000 (20:43 +0200)
committerGitHub <noreply@github.com>
Sat, 4 Jul 2026 18:43:43 +0000 (20:43 +0200)
* 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).

src/charging-station/meter-values/CoherentMeterValueBuilder.ts
src/charging-station/meter-values/CoherentSampleComputer.ts

index 8864bf1078d40d97c19b33a2ace7a8878366b522..da822aefd8491a0a7c7eeb43cbc491643b40273f 100644 (file)
@@ -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
 }
index 2f143976ad216e4d92d2d9062bcc4f9b5e96d526..b1ea65a0d3501d25be213fd18304fb0885ab6847 100644 (file)
@@ -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,