* 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).
* - 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
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
* - 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.
* @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`.
)
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
}
// 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
}
* `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 %.
* 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`.
/**
* 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 {
/**
* 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
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
* 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)`.
// 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
// 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)
// 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,