]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(meter-values): honor RegisterValuesWithoutPhases in coherent path (issue #1936...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sat, 4 Jul 2026 22:21:37 +0000 (00:21 +0200)
committerGitHub <noreply@github.com>
Sat, 4 Jul 2026 22:21:37 +0000 (00:21 +0200)
* 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
src/charging-station/meter-values/CoherentMeterValueBuilder.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/types/ocpp/2.0/Variables.ts
tests/charging-station/meter-values/CoherentMeterValues.test.ts
tests/charging-station/meter-values/StrategyDispatch.test.ts

index ad7804b44a38aba326c8719fda01fb46babaacb3..07a69c396cd9ddaf2be8a6537125c981e23e1015 100644 (file)
--- 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
index da822aefd8491a0a7c7eeb43cbc491643b40273f..cb92a545c50cbe405b4747bb7472349f209b0e4c 100644 (file)
@@ -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<MeterValueMeasurand, SampledValueTemplate[]>
+): 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<MeterValueMeasurand>
+  enabledMeasurands?: ReadonlySet<MeterValueMeasurand>,
+  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)
index b2b9b74b95fd2ef0207cfb79b478b7b6b440687b..4e88b242ce4cc0f63b7378513e4120226c3f95f6 100644 (file)
@@ -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)
index 2b226a7cc5a03f7074c29be9e391cbcec13ffc36..9b7f5fde66f1861d5ea0461fcfada2fa20fbf9ec 100644 (file)
@@ -43,6 +43,7 @@ export enum OCPP20OptionalVariableName {
   MaxEnergyOnInvalidId = 'MaxEnergyOnInvalidId',
   NonEvseSpecific = 'NonEvseSpecific',
   PublicKeyWithSignedMeterValue = 'PublicKeyWithSignedMeterValue',
+  RegisterValuesWithoutPhases = 'RegisterValuesWithoutPhases',
   ReportingValueSize = 'ReportingValueSize',
   RetryBackOffRandomRange = 'RetryBackOffRandomRange',
   RetryBackOffRepeatTimes = 'RetryBackOffRepeatTimes',
index 0bb99c708c3cf299d430920cbe2a8c9607681ae2..41b0b5acc6895f153a534548888e6a561229ed19 100644 (file)
@@ -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)'
+        )
+      }
+    })
   })
 })
index 4d1d3ecc2d62befff9b6fac8ce3a3bed82a55cdb..9d42e4a871e56b9ebd3feb45c7f9746cbe4b7d17 100644 (file)
@@ -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'
+      )
+    })
+  })
 })