From 2cbd92dd0ada34cc10982629fae91cac2e165593 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 5 Jul 2026 00:21:37 +0200 Subject: [PATCH] feat(meter-values): honor RegisterValuesWithoutPhases in coherent path (issue #1936 k) (#1942) * feat(meter-values): honor RegisterValuesWithoutPhases in coherent path (issue #1936 k) OCPP 2.0.1 `SampledDataCtrlr.RegisterValuesWithoutPhases` suppresses per-phase L-N emission of `Energy.Active.Import.Register` when set to `true`. The coherent path previously emitted per-phase register based solely on the connector template's phase qualifier, ignoring this config variable (documented at `CoherentMeterValueBuilder.ts` L-N branch as "not consulted"). Wire the variable through: - Add `OCPP20OptionalVariableName.RegisterValuesWithoutPhases` to the OCPP 2.0.1 optional-variable enum (registry entry already existed). - Extend `buildCoherentMeterValue` with an optional `registerValuesWithoutPhases` parameter (defaults to `false` when omitted, preserving current behavior for OCPP 1.6 callers). - Extend `resolvePhasedValue` with the same flag; the `ENERGY_ACTIVE_IMPORT_REGISTER` L-N branch returns `undefined` when the flag is `true` so the caller log-and-skips the per-phase template, leaving only the aggregate register to emit. - Wire the flag resolution from `OCPPServiceUtils.buildMeterValue`'s coherent strategy gate using the existing `isOCPP20FlagEnabled` helper. Safe on OCPP 1.6 stations because the component-scoped configuration key never resolves there (returns `false`, preserving current behavior). - Move `OCPP20OptionalVariableName` from the type-only import block to the value import block in `OCPPServiceUtils.ts` (previously imported as type only; now used as a runtime value). Three tests added exercising the new behavior: - L-N per-phase `Energy.Active.Import.Register` templates suppressed and only the aggregate register emitted when the flag is `true`. - Default per-phase L-N emission preserved when the flag is unset. - `Power.Active.Import` per-phase emission unaffected (flag scoped to `Energy.Active.Import.Register`). README section on coherent-mode phase emission updated to reflect the new semantics. Test invariant `fail: 0, skipped: 6` preserved. Spec citation: OCPP 2.0.1 edition 3 part 2, `SampledDataCtrlr` component: "If this variable reports a value of true, then meter values of measurand `Energy.Active.Import.Register` will only report the total energy over all phases without reporting the individual phase values." Closes issue #1936 item (k). * docs(meter-values): refine OCPP 1.6 safety wire-comment wording (issue #1936 k) The wire-site comment in `OCPPServiceUtils.ts` at the coherent strategy gate previously stated: > "OCPP 1.6 stations never resolve the component-scoped key (returns > false), preserving current behavior." `isOCPP20FlagEnabled` reads the raw configuration store via `getConfigurationKey`. An OCPP 1.6 station whose template literally set a key named `SampledDataCtrlr.RegisterValuesWithoutPhases` to `"true"` WOULD resolve it. In practice this is impossible under normal use (the key is not standard for 1.6), so the safety outcome is correct, but the "never resolve" wording is not strictly accurate. Weakened to: "OCPP 1.6 stations do not carry the component-scoped key by default: `getConfigurationKey` returns `undefined`, `convertToBoolean(undefined) === false`, so current behavior is preserved." Same intent (documenting OCPP 1.6 safety), more precise wording matching the actual semantic of `isOCPP20FlagEnabled`. No code change. Test invariant `fail: 0, skipped: 6` preserved. * refactor(meter-values): apply RegisterValuesWithoutPhases at bucket level (issue #1936 k) R2 Lane B found two spec-conformance issues in the initial threaded implementation of `SampledDataCtrlr.RegisterValuesWithoutPhases`: 1. When only per-phase L-N `Energy.Active.Import.Register` templates are configured on a connector and the flag is `true`, the previous implementation suppressed all three L-N templates and emitted nothing for the measurand. This violates the spec: > "will only report the total energy over all phases without > reporting the individual phase values." The spec mandates that the total IS reported; it does not permit a silent no-op when the config lacks an aggregate template. 2. `resolvePhasedValue` returned `undefined` for suppressed L-N templates, and `buildCoherentMeterValue`'s log-and-skip path treated this as an unsupported `(measurand, phase)` combination, emitting a spurious `WARN` for every configured L-N register template on OCPP 2.0.1 stations with the flag set. This is a legitimate, config-driven skip, not an error condition. Redesign the suppression as a bucket-level pre-filter in `buildCoherentMeterValue` (before the emit loop) instead of a per-call return-value negotiation with `resolvePhasedValue`. Both issues collapse to zero: - The new helper `applyRegisterValuesWithoutPhases` filters L-N templates out of the `Energy.Active.Import.Register` bucket before iteration. - When the connector configured only per-phase L-N templates (no aggregate), the helper synthesizes an aggregate template from the first suppressed L-N by shallow-cloning it with `phase` cleared; `unit`, `measurand`, `location`, and `context` fields inherit from the L-N template so the emitted aggregate matches the operator's intent for encoding and scale. - Because L-N templates are removed before iteration, the log-and-skip path fires only for genuinely unsupported combinations, never for a configured spec suppression. `resolvePhasedValue` reverts to its pre-`a46c0189` signature (5 parameters, no `suppressPerPhaseRegister` flag) since the flag is now handled entirely at the bucket boundary. The exported `buildCoherentMeterValue` retains its `registerValuesWithoutPhases` parameter and the strategy-gate wire in `OCPPServiceUtils.buildMeterValue` is unchanged. One test added exercising the synthesis path: - `should synthesize aggregate Energy.Active.Import.Register when only per-phase L-N templates configured and registerValuesWithoutPhases=true` asserts 1 sample survives, the surviving sample has no phase qualifier, the value equals the total register (not `register / phases`), and the synthesized template inherits the unit from the first suppressed L-N. README updated to describe the pre-filter + synthesis semantic and to weaken the OCPP 1.6 wording (`getConfigurationKey` returns `undefined`, `convertToBoolean(undefined) === false`) matching the `4dcb6213` comment fix. Test invariant `fail: 0, skipped: 6` preserved. Spec citation: OCPP 2.0.1 edition 3 part 2, `SampledDataCtrlr`, `RegisterValuesWithoutPhases`. * test(meter-values): add OCPP 2.0.1 boundary tests for RegisterValuesWithoutPhases (issue #1936 k) R5 (maintainer perspective) flagged missing OCPP 2.0 service-boundary regression tests: the existing suite covered `buildCoherentMeterValue` directly but not the `buildMeterValue` strategy gate that resolves the `SampledDataCtrlr.RegisterValuesWithoutPhases` variable and threads it into the coherent builder on an OCPP 2.0.1 station. Two tests added to `StrategyDispatch.test.ts`: 1. `should synthesize aggregate register at buildMeterValue boundary when the SampledDataCtrlr variable resolves to true`. Uses `addConfigurationKey(station, buildConfigKey(...), 'true')` to set the component-scoped key exactly as the runtime does, injects a 3-phase coherent session, configures only per-phase L-N Energy templates, calls `buildMeterValue`, and asserts exactly one synthesized aggregate sample survives with no phase qualifier. 2. `should preserve per-phase L-N emission when the SampledDataCtrlr variable is absent`. Same setup without the configuration key; asserts all 3 per-phase L-N samples emit (default behavior preserved when the variable does not resolve). These tests exercise the full wire path: `isOCPP20FlagEnabled` reads the config store, threads the boolean into `buildCoherentMeterValue`'s `registerValuesWithoutPhases` parameter, which triggers `applyRegisterValuesWithoutPhases` bucket pre-filtering and (when appropriate) aggregate synthesis. Test invariant `fail: 0, skipped: 6` preserved. * refactor(meter-values): partition RegisterValuesWithoutPhases by identity family (issue #1936 k) R8 Lane B (OCPP spec + physics + math adversarial review) surfaced a correctness gap in the previous `applyRegisterValuesWithoutPhases` helper: it keyed suppression/synthesis by measurand only, so a single aggregate `Energy.Active.Import.Register` template anywhere in the bucket satisfied `hasAggregate` and suppressed per-phase L-N templates in every other identity family. Example: connector configured with per-phase INLET templates plus an aggregate OUTLET template would drop the INLET family entirely and synthesize nothing for it, violating the spec requirement to report the total energy per configured family. Redesign the helper to partition templates into identity families keyed by `(context, format, location, unit)` before applying suppression/synthesis: - Per-phase L-N templates in each family are filtered out (no behavioral change per family). - Within each family that had per-phase L-N templates but no aggregate, an aggregate is synthesized from the first suppressed L-N template of that family (`phase` cleared, other identity fields inherited via shallow spread). - Families that had no per-phase L-N templates are untouched. - Result is re-sorted by `PHASE_RANK` to preserve stable emit order. Extract the `phaseFamily(t.phase) === 'LineToNeutral'` predicate into `isLineToNeutralTemplate` per R8 Lane A NIT (DRY, drops redundant `t.phase != null` guard because `phaseFamily(undefined)` is always `'Aggregate'`, never `'LineToNeutral'`). Normalize terminology `L-N per-phase` -> `per-phase L-N` in JSDoc and wire comments per R8 Lane C NIT (single term per concept). One regression test added exercising the mixed-family case: INLET per-phase L-N templates plus an OUTLET aggregate template with the flag enabled must emit two aggregate samples (INLET synthesized, OUTLET preserved), not one. Test invariant `fail: 0, skipped: 6` preserved. Spec citation: OCPP 2.0.1 DMD `docs/ocpp2/Appendices_CSV_v1.4/dm_components_vars.csv:213`: `SampledDataCtrlr;RegisterValuesWithoutPhases;;no;boolean` - "will only report the total energy over all phases". * refactor(meter-values): use Map.groupBy in RegisterValuesWithoutPhases helper (issue #1936 k) Redesign `applyRegisterValuesWithoutPhases` per session-established criteria for elegance, harmonization, and TS/JS state-of-the-art: - Replace the imperative two-Maps-plus-double-loop dance (`perPhaseLNByFamily` + `otherByFamily`) with a single `Map.groupBy(bucket, templateFamilyKey)` call, harmonizing with the sibling `groupTemplatesByMeasurand` helper in the same file. Both now use the same ES2024 grouping primitive for the same conceptual operation. - Extract `templateFamilyKey` to module scope alongside the sibling `phaseFamily` and `isLineToNeutralTemplate` helpers, matching the file's helper-placement convention (private module-scope pure functions above the exported builder). - Inline the synthesis into a single `surviving.push(...)` call (removes the intermediate `synthesized` local). - Net -4 lines with clearer control flow: per-family filter for L-N vs non-L-N, three explicit branches (no L-N to suppress, aggregate already configured, synthesize from first L-N), followed by a single stable sort. No behavioral change: the mixed-family regression test at `CoherentMeterValues.test.ts:1256+` (INLET per-phase L-N + OUTLET aggregate) still asserts both families emit an aggregate (INLET synthesized, OUTLET preserved). All prior R1-R8 tests pass unchanged. Test invariant `fail: 0, skipped: 6` preserved. * docs(meter-values): describe per-family synthesis in README and JSDoc (issue #1936 k) R10 Lane B cross-artifact alignment audit caught 2 MINOR wording drifts carried over from the pre-R8 (pre-family-partitioning) design that R9 Lane C had implicitly accepted as converged: 1. `README.md:496` Energy.Active.Import.Register bullet described synthesis as connector-global ("if no aggregate register template is configured, one is synthesized...") without the per-family qualifier. HEAD's `applyRegisterValuesWithoutPhases` groups templates into identity families keyed by `(context, format, location, unit)` and synthesizes an aggregate per family that lacks one, so the wording was implying a single global aggregate where the code emits one per family. 2. `CoherentMeterValueBuilder.ts` `buildCoherentMeterValue` JSDoc `@param registerValuesWithoutPhases` used the same pre-family phrasing ("if the connector configures only per-phase L-N templates (no aggregate), an aggregate template is synthesized"). The helper JSDoc was already accurate; only the exported API documentation lagged the redesign. Both descriptions rewritten to state the identity-family key explicitly and the per-family synthesis semantic. No code change. Test invariant `fail: 0, skipped: 6` preserved. --- README.md | 2 +- .../meter-values/CoherentMeterValueBuilder.ts | 78 +++- src/charging-station/ocpp/OCPPServiceUtils.ts | 17 +- src/types/ocpp/2.0/Variables.ts | 1 + .../meter-values/CoherentMeterValues.test.ts | 338 ++++++++++++++++++ .../meter-values/StrategyDispatch.test.ts | 139 ++++++- 6 files changed, 567 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ad7804b4..07a69c39 100644 --- a/README.md +++ b/README.md @@ -493,7 +493,7 @@ A template file is available at [src/assets/ev-profiles-template.json](./src/ass - `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. +- `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. On OCPP 2.0.1 stations, when `SampledDataCtrlr.RegisterValuesWithoutPhases` is `true`, templates are grouped into identity families keyed by `(context, format, location, unit)`; within each family, per-phase L-N register templates are filtered out and, when a family has no aggregate template configured, an aggregate is synthesized from the first suppressed L-N of that family (phase cleared, other identity fields preserved) so the spec requirement "will only report the total energy over all phases" holds per family. On OCPP 1.6 stations the component-scoped configuration key is not present by default (`getConfigurationKey` returns `undefined`, `convertToBoolean(undefined) === false`), so current behavior is preserved. - `SoC`: aggregate scalar; phase-qualified templates are skipped with a warning. ### Charging station configuration diff --git a/src/charging-station/meter-values/CoherentMeterValueBuilder.ts b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts index da822aef..cb92a545 100644 --- a/src/charging-station/meter-values/CoherentMeterValueBuilder.ts +++ b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts @@ -141,6 +141,58 @@ const groupTemplatesByMeasurand = ( return groups } +const isLineToNeutralTemplate = (t: SampledValueTemplate): boolean => + phaseFamily(t.phase) === 'LineToNeutral' + +const templateFamilyKey = (t: SampledValueTemplate): string => + JSON.stringify([t.context ?? null, t.format ?? null, t.location ?? null, t.unit ?? null]) + +/** + * Applies the OCPP 2.0.1 `SampledDataCtrlr.RegisterValuesWithoutPhases` + * suppression to the `Energy.Active.Import.Register` bucket in-place. + * Groups templates into identity families keyed by + * `(context, format, location, unit)`; within each family, per-phase + * L-N templates are filtered out (avoiding "unsupported combination" + * warnings for a configured skip). If a family has per-phase L-N + * templates but no aggregate template, an aggregate is synthesized + * from the first suppressed per-phase L-N of that family (phase + * cleared, other identity fields inherited via shallow spread), so + * the spec-mandated total is reported per family. Result is re-sorted + * by `PHASE_RANK` to preserve stable emit order. No-op when the + * measurand bucket is absent or has no per-phase L-N templates. + * @param groups - Grouped templates map (mutated in-place). + */ +const applyRegisterValuesWithoutPhases = ( + groups: Map +): void => { + const bucket = groups.get(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) + if (bucket == null) return + if (!bucket.some(isLineToNeutralTemplate)) return + const surviving: SampledValueTemplate[] = [] + for (const family of Map.groupBy(bucket, templateFamilyKey).values()) { + const perPhaseLN = family.filter(isLineToNeutralTemplate) + if (perPhaseLN.length === 0) { + surviving.push(...family) + continue + } + const nonLN = family.filter(t => !isLineToNeutralTemplate(t)) + if (nonLN.some(t => t.phase == null)) { + surviving.push(...nonLN) + } else { + // Synthesize family aggregate from first suppressed per-phase L-N: + // unit / measurand / location / context / format inherit via shallow + // spread; phase cleared so the aggregate branch of + // `resolvePhasedValue` emits the total register for this family. + surviving.push({ ...perPhaseLN[0], phase: undefined }, ...nonLN) + } + } + surviving.sort( + (a, b) => + (a.phase == null ? 0 : PHASE_RANK[a.phase]) - (b.phase == null ? 0 : PHASE_RANK[b.phase]) + ) + groups.set(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, surviving) +} + /** * Resolves the exact physical value to emit for a template given the * coherent sample. Returns `undefined` for unsupported `(measurand, phase)` @@ -162,9 +214,12 @@ const groupTemplatesByMeasurand = ( * 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. + * `SampledDataCtrlr.RegisterValuesWithoutPhases` suppression is + * applied at the bucket level in {@link buildCoherentMeterValue} + * before this function is called; L-N templates for + * `Energy.Active.Import.Register` are filtered out at that boundary + * when the flag is set, and an aggregate template is synthesized if + * the connector only configures per-phase templates. * @param measurand - Target measurand. * @param phase - Template `phase` field (may be `undefined`). * @param sample - Coherent sample (source of aggregate values). @@ -301,6 +356,17 @@ const resolveTemplates = ( * 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`. + * @param registerValuesWithoutPhases - Optional OCPP 2.0.1 + * `SampledDataCtrlr.RegisterValuesWithoutPhases` flag. When `true`, + * `Energy.Active.Import.Register` templates are grouped into identity + * families keyed by `(context, format, location, unit)`; within each + * family, per-phase L-N templates are filtered out and, when a + * family has no aggregate template configured, an aggregate is + * synthesized from the first suppressed L-N of that family (phase + * cleared, other identity fields preserved) so the spec requirement + * "will only report the total energy over all phases" holds per + * family. Defaults to `false` (or `undefined`) so OCPP 1.6 callers + * preserve current behavior. * @returns MeterValue with sampled values and current timestamp. */ export const buildCoherentMeterValue = ( @@ -309,7 +375,8 @@ export const buildCoherentMeterValue = ( buildVersionedSampledValue: BuildVersionedSampledValue, options: ComputeSampleOptions, mvContext?: MeterValueContext, - enabledMeasurands?: ReadonlySet + enabledMeasurands?: ReadonlySet, + registerValuesWithoutPhases?: boolean ): MeterValue => { const connectorStatus = context.getConnectorStatus(session.connectorId) if (connectorStatus == null) { @@ -327,6 +394,9 @@ export const buildCoherentMeterValue = ( const templates = resolveTemplates(context, session.connectorId) const groups = groupTemplatesByMeasurand(templates) + if (registerValuesWithoutPhases === true) { + applyRegisterValuesWithoutPhases(groups) + } const sampledValue: SampledValue[] = [] const isEnabled = (measurand: MeterValueMeasurand): boolean => enabledMeasurands == null || enabledMeasurands.has(measurand) diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index b2b9b74b..4e88b242 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -8,7 +8,6 @@ import { fileURLToPath } from 'node:url' import type { BootReasonEnumType, - OCPP20OptionalVariableName, OCPP20RequiredVariableName, OCPP20VendorVariableName, SigningMethodEnumType, @@ -40,6 +39,7 @@ import { MeterValuePhase, MeterValueUnit, OCPP20ComponentName, + OCPP20OptionalVariableName, OCPP20ReadingContextEnumType, OCPPVersion, RequestCommand, @@ -1162,6 +1162,18 @@ export const buildMeterValue = ( // this is a no-op and the random/fixed code path is unchanged. const coherentSession = chargingStation.getCoherentSession(transactionId) if (isCoherentModeActive(coherentSession)) { + // OCPP 2.0.1 SampledDataCtrlr.RegisterValuesWithoutPhases: threaded + // through to the coherent builder so per-phase L-N + // Energy.Active.Import.Register templates are skipped when the + // variable resolves to true. OCPP 1.6 stations do not carry the + // component-scoped key by default: `getConfigurationKey` returns + // `undefined`, `convertToBoolean(undefined) === false`, so current + // behavior is preserved. + const registerValuesWithoutPhases = isOCPP20FlagEnabled( + chargingStation, + OCPP20ComponentName.SampledDataCtrlr, + OCPP20OptionalVariableName.RegisterValuesWithoutPhases + ) return buildCoherentMeterValue( chargingStation, coherentSession, @@ -1172,7 +1184,8 @@ export const buildMeterValue = ( rootSeed: resolveRootSeed(chargingStation.stationInfo), }, context, - resolveEnabledMeasurands(chargingStation, measurandsKey) + resolveEnabledMeasurands(chargingStation, measurandsKey), + registerValuesWithoutPhases ) } const connectorStatus = chargingStation.getConnectorStatus(connectorId) diff --git a/src/types/ocpp/2.0/Variables.ts b/src/types/ocpp/2.0/Variables.ts index 2b226a7c..9b7f5fde 100644 --- a/src/types/ocpp/2.0/Variables.ts +++ b/src/types/ocpp/2.0/Variables.ts @@ -43,6 +43,7 @@ export enum OCPP20OptionalVariableName { MaxEnergyOnInvalidId = 'MaxEnergyOnInvalidId', NonEvseSpecific = 'NonEvseSpecific', PublicKeyWithSignedMeterValue = 'PublicKeyWithSignedMeterValue', + RegisterValuesWithoutPhases = 'RegisterValuesWithoutPhases', ReportingValueSize = 'ReportingValueSize', RetryBackOffRandomRange = 'RetryBackOffRandomRange', RetryBackOffRepeatTimes = 'RetryBackOffRepeatTimes', diff --git a/tests/charging-station/meter-values/CoherentMeterValues.test.ts b/tests/charging-station/meter-values/CoherentMeterValues.test.ts index 0bb99c70..41b0b5ac 100644 --- a/tests/charging-station/meter-values/CoherentMeterValues.test.ts +++ b/tests/charging-station/meter-values/CoherentMeterValues.test.ts @@ -37,6 +37,7 @@ import { hashLabel } from '../../../src/charging-station/meter-values/PRNG.js' import { AvailabilityType, CurrentType, + MeterValueLocation, MeterValueMeasurand, MeterValuePhase, MeterValueUnit, @@ -1000,5 +1001,342 @@ await describe('CoherentMeterValues', async () => { `L-N register=${emitted.toString()} not ≈ ${(postRegister / 3).toString()}` ) }) + + await it('should skip per-phase L-N Energy.Active.Import.Register templates when registerValuesWithoutPhases=true', () => { + 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, + unit: MeterValueUnit.WATT_HOUR, + }, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L1_N, + unit: MeterValueUnit.WATT_HOUR, + }, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L2_N, + unit: MeterValueUnit.WATT_HOUR, + }, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L3_N, + unit: MeterValueUnit.WATT_HOUR, + }, + ] as unknown as SampledValueTemplate[] + const mv = buildCoherentMeterValue( + context, + session, + passThroughBuilder, + { intervalMs: 3_600_000, nowMs: 3_600_000, rootSeed: 42, voltageNoise: false }, + undefined, + undefined, + true + ) + const energySamples = mv.sampledValue.filter( + sv => + (sv as { measurand?: MeterValueMeasurand }).measurand === + MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + assert.strictEqual( + energySamples.length, + 1, + 'exactly one aggregate Energy.Active.Import.Register sample must be emitted when registerValuesWithoutPhases=true (per-phase L-N templates suppressed)' + ) + assert.strictEqual( + (energySamples[0] as { phase?: string }).phase, + undefined, + 'the surviving sample must be the aggregate (no phase qualifier)' + ) + }) + + await it('should preserve default per-phase L-N Energy.Active.Import.Register emission when registerValuesWithoutPhases is undefined', () => { + 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, + }, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L2_N, + unit: MeterValueUnit.WATT_HOUR, + }, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L3_N, + unit: MeterValueUnit.WATT_HOUR, + }, + ] as unknown as SampledValueTemplate[] + const mv = buildCoherentMeterValue(context, session, passThroughBuilder, { + intervalMs: 3_600_000, + nowMs: 3_600_000, + rootSeed: 42, + voltageNoise: false, + }) + const energySamples = mv.sampledValue.filter( + sv => + (sv as { measurand?: MeterValueMeasurand }).measurand === + MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + assert.strictEqual( + energySamples.length, + 3, + 'all 3 per-phase L-N Energy.Active.Import.Register samples must be emitted when flag is unset (default behavior)' + ) + }) + + await it('should not affect Power.Active.Import per-phase emission when registerValuesWithoutPhases=true', () => { + 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.MeterValues = [ + { + 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, + }, + ] as unknown as SampledValueTemplate[] + const mv = buildCoherentMeterValue( + context, + session, + passThroughBuilder, + { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }, + undefined, + undefined, + true + ) + const powerSamples = mv.sampledValue.filter( + sv => + (sv as { measurand?: MeterValueMeasurand }).measurand === + MeterValueMeasurand.POWER_ACTIVE_IMPORT + ) + assert.strictEqual( + powerSamples.length, + 3, + 'Power.Active.Import per-phase L-N templates must remain emitted regardless of registerValuesWithoutPhases (flag scoped to Energy.Active.Import.Register)' + ) + }) + + await it('should synthesize aggregate Energy.Active.Import.Register when only per-phase L-N templates configured and registerValuesWithoutPhases=true', () => { + 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, + }, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L2_N, + unit: MeterValueUnit.WATT_HOUR, + }, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L3_N, + unit: MeterValueUnit.WATT_HOUR, + }, + ] as unknown as SampledValueTemplate[] + const mv = buildCoherentMeterValue( + context, + session, + passThroughBuilder, + { + intervalMs: 3_600_000, + nowMs: 3_600_000, + rootSeed: 42, + voltageNoise: false, + }, + undefined, + undefined, + true + ) + const energySamples = mv.sampledValue.filter( + sv => + (sv as { measurand?: MeterValueMeasurand }).measurand === + MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + assert.strictEqual( + energySamples.length, + 1, + 'exactly one synthesized aggregate Energy.Active.Import.Register sample must be emitted when only per-phase L-N templates are configured and registerValuesWithoutPhases=true (spec: "will only report the total energy over all phases")' + ) + assert.strictEqual( + (energySamples[0] as { phase?: string }).phase, + undefined, + 'the synthesized sample must be the aggregate (no phase qualifier)' + ) + const emitted = Number(energySamples[0].value) + const postRegister = connectorStatus.energyActiveImportRegisterValue ?? 0 + assert.ok( + Math.abs(emitted - postRegister) < 0.05, + `synthesized aggregate=${emitted.toString()} not ≈ ${postRegister.toString()} (total register, not register/phases)` + ) + assert.strictEqual( + (energySamples[0] as { unit?: string }).unit, + MeterValueUnit.WATT_HOUR, + 'synthesized aggregate must inherit unit from the first suppressed L-N template' + ) + }) + + await it('should synthesize aggregate per identity family when Energy.Active.Import.Register bucket mixes families and registerValuesWithoutPhases=true', () => { + 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 + // Two identity families in the Energy.Active.Import.Register bucket: + // - INLET / Wh: per-phase L-N templates only (no aggregate) + // - OUTLET / Wh: aggregate template only (no per-phase) + // Correct behavior per family: INLET must synthesize its aggregate, + // OUTLET's aggregate emits untouched. The pre-fix keyed-only-by-measurand + // logic would incorrectly rely on OUTLET's aggregate to satisfy INLET + // and drop INLET's per-phase family without synthesis. + connectorStatus.MeterValues = [ + { + location: MeterValueLocation.INLET, + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L1_N, + unit: MeterValueUnit.WATT_HOUR, + }, + { + location: MeterValueLocation.INLET, + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L2_N, + unit: MeterValueUnit.WATT_HOUR, + }, + { + location: MeterValueLocation.INLET, + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L3_N, + unit: MeterValueUnit.WATT_HOUR, + }, + { + location: MeterValueLocation.OUTLET, + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: MeterValueUnit.WATT_HOUR, + }, + ] as unknown as SampledValueTemplate[] + const mv = buildCoherentMeterValue( + context, + session, + passThroughBuilder, + { + intervalMs: 3_600_000, + nowMs: 3_600_000, + rootSeed: 42, + voltageNoise: false, + }, + undefined, + undefined, + true + ) + const energySamples = mv.sampledValue.filter( + sv => + (sv as { measurand?: MeterValueMeasurand }).measurand === + MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + assert.strictEqual( + energySamples.length, + 2, + 'both identity families must emit an aggregate: INLET synthesizes, OUTLET keeps its configured aggregate (per-family suppression semantic); pre-fix logic keyed only by measurand would emit exactly 1 (OUTLET) and silently drop the INLET family' + ) + for (const sv of energySamples) { + assert.strictEqual( + (sv as { phase?: string }).phase, + undefined, + 'both surviving samples must be aggregates (no phase qualifier)' + ) + } + }) }) }) diff --git a/tests/charging-station/meter-values/StrategyDispatch.test.ts b/tests/charging-station/meter-values/StrategyDispatch.test.ts index 4d1d3ecc..9d42e4a8 100644 --- a/tests/charging-station/meter-values/StrategyDispatch.test.ts +++ b/tests/charging-station/meter-values/StrategyDispatch.test.ts @@ -17,11 +17,15 @@ 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 { addConfigurationKey, buildConfigKey } from '../../../src/charging-station/index.js' import { buildMeterValue } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js' import { CurrentType, MeterValueMeasurand, + MeterValuePhase, + MeterValueUnit, + OCPP20ComponentName, + OCPP20OptionalVariableName, OCPPVersion, StandardParametersKey, Voltage, @@ -194,4 +198,137 @@ await describe('StrategyDispatch', async () => { assert.ok(meterValue.sampledValue.length <= 1) }) }) + + await describe('OCPP 2.0.1 SampledDataCtrlr.RegisterValuesWithoutPhases wire', async () => { + beforeEach(() => { + const { station: s } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + stationInfo: { + coherentMeterValues: true, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = [ + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L1_N, + unit: MeterValueUnit.WATT_HOUR, + }, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L2_N, + unit: MeterValueUnit.WATT_HOUR, + }, + { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + phase: MeterValuePhase.L3_N, + unit: MeterValueUnit.WATT_HOUR, + }, + ] as unknown as SampledValueTemplate[] + connectorStatus.transactionId = TEST_TRANSACTION_ID + connectorStatus.transactionEnergyActiveImportRegisterValue = 6000 + connectorStatus.energyActiveImportRegisterValue = 6000 + } + }) + + await it('should synthesize aggregate register at buildMeterValue boundary when the SampledDataCtrlr variable resolves to true', () => { + const now = Date.now() + const session: CoherentSession = { + connectorId: 1, + currentType: CurrentType.AC, + numberOfPhases: 3, + 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, + buildConfigKey( + OCPP20ComponentName.SampledDataCtrlr, + OCPP20OptionalVariableName.RegisterValuesWithoutPhases + ), + 'true' + ) + + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID, + TEST_METER_VALUES_INTERVAL_MS + ) + const energySamples = meterValue.sampledValue.filter( + sv => + (sv as { measurand?: MeterValueMeasurand }).measurand === + MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + assert.strictEqual( + energySamples.length, + 1, + 'the OCPP 2.0.1 strategy gate must resolve RegisterValuesWithoutPhases and thread it into the coherent builder so only one aggregate sample emits (synthesized when only per-phase L-N templates are configured)' + ) + assert.strictEqual( + (energySamples[0] as { phase?: string }).phase, + undefined, + 'the surviving sample must be the aggregate (no phase qualifier)' + ) + }) + + await it('should preserve per-phase L-N emission when the SampledDataCtrlr variable is absent', () => { + const now = Date.now() + const session: CoherentSession = { + connectorId: 1, + currentType: CurrentType.AC, + numberOfPhases: 3, + 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) + // Do not set the configuration key: the variable is absent, so + // isOCPP20FlagEnabled resolves to false (default behavior). + + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID, + TEST_METER_VALUES_INTERVAL_MS + ) + const energySamples = meterValue.sampledValue.filter( + sv => + (sv as { measurand?: MeterValueMeasurand }).measurand === + MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + assert.strictEqual( + energySamples.length, + 3, + 'when RegisterValuesWithoutPhases is absent the strategy gate must not apply suppression: all 3 configured per-phase L-N templates must emit' + ) + }) + }) }) -- 2.53.0