]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(meter-values): evse-level MeterValues template fallback in coherent path (issue...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 5 Jul 2026 00:06:37 +0000 (02:06 +0200)
committerGitHub <noreply@github.com>
Sun, 5 Jul 2026 00:06:37 +0000 (02:06 +0200)
* feat(meter-values): evse-level MeterValues template fallback in coherent path (issue #1936 g)

The coherent path's `resolveTemplates` at `CoherentMeterValueBuilder.ts`
read connector-level `MeterValues` only. A station that defined
`MeterValues` at EVSE level (via the `Evses` template section) and
enabled `coherentMeterValues=true` would emit an empty MeterValue
because the EVSE-level array was never consulted — a pre-existing TODO
documented in the prior `resolveTemplates` JSDoc: "Adding EVSE-level
template inheritance to the coherent path requires extending the
context interface with `getEvseStatus`."

Fix: mirror the random/fixed path's `getSampledValueTemplate` semantics
(README section "MeterValues at EVSE level": "EVSE-level definitions
apply to all connectors of the EVSE and override connector-level
definitions"). EVSE-level `MeterValues`, when defined and non-empty,
override connector-level `MeterValues` for every connector under the
EVSE; connector-level `MeterValues` are used when the connector is not
grouped under an EVSE (flat `Connectors` map station layout) or when
the EVSE-level array is empty.

- Extend `ICoherentContext` with two accessors mirroring the existing
  `ChargingStation` public API: `getEvseIdByConnectorId(connectorId)`
  and `getEvseStatus(evseId)`. Requires importing `EvseStatus` from
  the shared types barrel.
- Rewrite `resolveTemplates` in `CoherentMeterValueBuilder.ts` to
  perform the EVSE-first lookup and fall back to connector-level when
  the EVSE is undefined or its `MeterValues` array is missing/empty.
  JSDoc updated to reflect the new contract.
- Extend the in-file `buildContext` test factory with an optional
  `evseMeterValues` override (implies `getEvseIdByConnectorId`
  resolves to EVSE id 1 and `getEvseStatus(1)` returns
  `{ MeterValues: overrides.evseMeterValues, ... }`; falsy override
  leaves `getEvseIdByConnectorId` returning `undefined`).
- Add an `EVSE-level template fallback` describe block with two
  tests: (a) EVSE-level MeterValues override connector-level when
  defined, (b) connector-level MeterValues used when no EVSE
  grouping exists.
- Update README section "Template resolution scope" to remove the
  "connector-level definition only" caveat and document the new
  precedence rules.

The shared mock in `tests/charging-station/helpers/StationHelpers.ts`
already exposes both accessors; downstream test files routing through
the mock require no change.

Test invariant `fail: 0, skipped: 6` preserved.

Closes issue #1936 item (g).

* docs(meter-values): clarify EVSE fallback semantics + add empty-EVSE test (issue #1936 g)

R1 Lane A (Oracle) surfaced two follow-ups on the initial R1 rebase
implementation of the EVSE-first template lookup:

1. The `resolveTemplates` JSDoc and README wording claimed "the same
   precedence as ... `getSampledValueTemplate`". That claim was
   inaccurate on a subtle edge case: the random/fixed reference at
   `OCPPServiceUtils.ts:1546-1563` aggregates `MeterValues` across all
   sibling connectors under the same EVSE when `EvseStatus.MeterValues`
   is empty. The coherent path deliberately does NOT do this - a
   connector without its own `MeterValues` returns `undefined` under
   the coherent path, whereas the reference would leak sibling
   templates. This stricter isolation is desirable design (per-connector
   ownership), but the parity claim overstated the match.

   Wording weakened accordingly: JSDoc + README now explicitly state
   that the coherent generator emits templates from exactly one source
   (EVSE-level or the queried connector) and does not aggregate across
   sibling connectors, calling out the divergence from the reference
   path.

2. The initial R1 rebase covered "EVSE-level overrides connector-level
   when defined" and "connector-level used when no EVSE grouping" but
   left the third semantic branch untested: EVSE grouping present +
   `EvseStatus.MeterValues` is an empty array. The `resolveTemplates`
   guard `evseTemplates != null && evseTemplates.length > 0` explicitly
   handles this case by falling back to connector-level, but no test
   asserted the branch.

   New test added: `should fall back to connector-level MeterValues
   when EVSE grouping exists but EVSE-level MeterValues array is
   empty`. Configures `evseMeterValues: []` in `buildContext` (which
   makes `getEvseIdByConnectorId` return `1` and
   `getEvseStatus(1).MeterValues = []`), asserts connector-level
   template emits.

No code change to `resolveTemplates`; the empty-EVSE branch was
already correct.

Test invariant `fail: 0, skipped: 6` preserved.

* test(meter-values): cover EVSE-grouped + undefined MeterValues fallback (issue #1936 g)

R2 Lane B (adversarial) noted that the previous 3-test coverage in the
`EVSE-level template fallback` describe block conflated two distinct
cases: "no EVSE grouping" and "EVSE grouping present with
`MeterValues` undefined". Both eventually fall back to connector-level,
but through different code paths in `resolveTemplates`:

- "No EVSE grouping": `getEvseIdByConnectorId` returns `undefined` -
  the outer `if (evseId != null)` guard fails, fallback immediate.
- "EVSE grouping + MeterValues undefined": `getEvseIdByConnectorId`
  returns `1`, `getEvseStatus(1).MeterValues` is `undefined`, so the
  inner `evseTemplates != null && evseTemplates.length > 0` guard
  fails (both undefined and empty-array branches share the fallback).

The prior mock factory heuristic `overrides.evseMeterValues != null ?
1 : undefined` could express the first two cases plus "grouping +
empty array" but NOT "grouping + undefined MeterValues" - those
required distinct mock states.

Redesigned factory:
- Add an explicit `groupUnderEvse?: boolean` knob that overrides the
  `evseMeterValues != null` heuristic when set.
- `groupUnderEvse === true` forces `getEvseIdByConnectorId` to return
  `1` regardless of `evseMeterValues`, enabling the "grouping +
  undefined MeterValues" state via `groupUnderEvse: true` alone.
- `groupUnderEvse === false` forces flat-connector layout even with
  `evseMeterValues` defined (fully explicit override).
- `groupUnderEvse === undefined` preserves the prior heuristic
  (backward-compat for existing tests).

New test added: `should fall back to connector-level MeterValues when
EVSE grouping exists but EVSE-level MeterValues is undefined`. Uses
`groupUnderEvse: true` without `evseMeterValues` to exercise the
distinct-from-empty-array branch.

Test invariant `fail: 0, skipped: 6` preserved.

* docs(meter-values): sharpen resolveTemplates JSDoc + adopt isNotEmptyArray helper (issue #1936 g)

R8 (comprehensive final review, user-directed expanded criteria) surfaced
2 tightly related follow-ups on the R1-established `resolveTemplates`
implementation:

1. Lane C MINOR (JSDoc precision drift): the divergence NOTE narrowed
   the condition under which the random/fixed path aggregates
   sibling-connector templates to "both EVSE-level and the queried
   connector's `MeterValues` are empty". The reference
   `getSampledValueTemplate` (`OCPPServiceUtils.ts:1546-1559`) actually
   aggregates whenever `isNotEmptyArray(evseStatus.MeterValues)` is
   false, which covers `undefined` and `[]` regardless of the queried
   connector's state. The NOTE was strictly narrower than reality; the
   README and PR body already used the broader wording, leaving only
   the JSDoc drifted.

   NOTE rewritten: "when EVSE-level `MeterValues` is undefined or
   empty" (matches reference guard exactly), with the source clause
   correspondingly rephrased "EVSE-level when non-empty, otherwise the
   queried connector".

2. Lane A NIT (harmonization with cross-linked sibling): `resolveTemplates`
   used inline `evseTemplates != null && evseTemplates.length > 0`; the
   explicitly cross-linked sibling `getSampledValueTemplate` uses
   `isNotEmptyArray(evseStatus.MeterValues)` for the identical guard.
   `isNotEmptyArray` is a repo-wide type guard (`Utils.ts:411`,
   `value is NonEmptyArray<T> | ReadonlyNonEmptyArray<T>`) that also
   narrows the return type. Behavioral equivalence, better locality
   with sibling code, one less inline predicate.

   Guard rewritten to `if (isNotEmptyArray(evseTemplates))`; the utils
   barrel import now includes `isNotEmptyArray`.

No behavioral change. Test invariant `fail: 0, skipped: 6` preserved.

* test(meter-values): lock non-aggregation divergence + terminology hyphenation (issue #1936 g)

R9 (post-R8-fix comprehensive re-review, user-directed expanded criteria)
surfaced 3 follow-ups on the R8-committed branch state:

1. Lane B MINOR (regression test gap): the documented divergence from
   `getSampledValueTemplate` (coherent path does NOT aggregate
   sibling-connector `MeterValues` when EVSE-level is undefined or
   empty) was described in JSDoc, README, and PR body but not
   contractually locked by a test. The prior test harness had a
   single-connector EVSE (`connectors: Map([[1, connectorStatus]])`),
   which precluded exercising the sibling-aggregation branch.

   `buildContext` extended with `siblingConnectorMeterValues?:
   SampledValueTemplate[]` knob: when defined, the mock's
   `getEvseStatus(1).connectors` gains a second connector (id 2) with
   the given `MeterValues`. New test
   `should NOT aggregate sibling-connector MeterValues when EVSE-level
   MeterValues is undefined or empty` asserts the coherent path emits
   zero samples when queried connector's `MeterValues` is `[]`,
   EVSE-level `MeterValues` is `[]`, and a sibling connector carries a
   Power template. The reference path would emit that Power template;
   the coherent path must not.

2. Lane B NIT (stale test comment): the comment at
   `CoherentMeterValues.test.ts:1500-1503` still named the pre-R8
   inline guard `evseTemplates != null && length > 0`. Updated to
   reference `isNotEmptyArray(evseTemplates)` matching the current
   implementation at `CoherentMeterValueBuilder.ts:335-337`.

3. Lane B SUB-THRESHOLD (README hyphenation): `README.md:367,369,409`
   used `EVSE level` / `connector level` unhyphenated, drifting from
   the hyphenated `EVSE-level` / `connector-level` used at
   `README.md:489` and throughout the JSDoc/PR body/tests. Normalized
   the 3 remaining occurrences to the hyphenated form for repo-wide
   terminology consistency.

Local report `.omo/reviews/PR-1944.md` also refreshed post-R8 append
drift (Convergence Summary claimed "7 rounds" when 8 were now
documented; R8 consolidation arithmetic `12+ + 6 + 9 = 27+` was
written as `21+`).

Test invariant `fail: 0, skipped: 6` preserved (37/37 pass in target
file including the new sibling-aggregation regression test).

README.md
src/charging-station/meter-values/CoherentMeterValueBuilder.ts
src/charging-station/meter-values/types.ts
tests/charging-station/meter-values/CoherentMeterValues.test.ts

index 07a69c396cd9ddaf2be8a6537125c981e23e1015..cfd62f97aabe21f9588c4f4915291159407ccc9b 100644 (file)
--- a/README.md
+++ b/README.md
@@ -364,9 +364,9 @@ type AutomaticTransactionGeneratorConfiguration = {
 
 #### Evses section syntax example
 
-`MeterValues` can be defined at EVSE level or at connector level. EVSE-level definitions apply to all connectors of the EVSE and override connector-level definitions.
+`MeterValues` can be defined at EVSE-level or at connector-level. EVSE-level definitions apply to all connectors of the EVSE and override connector-level definitions.
 
-##### MeterValues at EVSE level
+##### MeterValues at EVSE-level
 
 ```json
   "Evses": {
@@ -406,7 +406,7 @@ type AutomaticTransactionGeneratorConfiguration = {
   },
 ```
 
-##### MeterValues at connector level
+##### MeterValues at connector-level
 
 ```json
   "Evses": {
@@ -486,7 +486,7 @@ The EV profile file is a JSON file referenced by the `evProfilesFile` template f
 
 A template file is available at [src/assets/ev-profiles-template.json](./src/assets/ev-profiles-template.json).
 
-**Template resolution scope.** When `coherentMeterValues` is `true`, the coherent generator reads `MeterValues` templates from the connector-level definition only. EVSE-level `MeterValues` inheritance is not applied, so define per-connector `MeterValues` inside each connector entry.
+**Template resolution scope.** When `coherentMeterValues` is `true`, EVSE-level `MeterValues` (when defined and non-empty) override connector-level definitions for every connector under that EVSE; connector-level `MeterValues` are used when the connector is not grouped under an EVSE (flat `Connectors` map station layout) or when the EVSE-level array is undefined or empty. Note: the coherent generator emits templates from exactly one source (EVSE-level or the queried connector) - unlike the random/fixed path's `getSampledValueTemplate`, it does not aggregate `MeterValues` across sibling connectors under the same EVSE.
 
 **Phase-qualified measurands.** When a connector template carries a `phase` field, the coherent generator emits one `SampledValue` per matching template with phase-aware values:
 
index cb92a545c50cbe405b4747bb7472349f209b0e4c..01f2f61eccd7c7300eaa0c387a024eadcd8aad38 100644 (file)
@@ -35,7 +35,7 @@ import {
   MeterValuePhase,
   MeterValueUnit,
 } from '../../types/index.js'
-import { Constants, logger, roundTo } from '../../utils/index.js'
+import { Constants, isNotEmptyArray, logger, roundTo } from '../../utils/index.js'
 import {
   advanceEnergyRegister,
   computeCoherentSample,
@@ -307,14 +307,21 @@ const resolveUnitDivider = (
   unit != null && KILO_UNIT_BY_MEASURAND.get(measurand) === unit ? Constants.UNIT_DIVIDER_KILO : 1
 
 /**
- * Returns the SampledValueTemplate array configured on the given connector.
+ * Resolves `MeterValues` templates for a connector. EVSE-level
+ * `MeterValues` (when defined and non-empty) override connector-level
+ * definitions for every connector under that EVSE; connector-level
+ * `MeterValues` are used when the connector is not grouped under an
+ * EVSE (flat `Connectors` map station layout) or when the EVSE-level
+ * array is undefined or empty.
  *
- * Reads the connector-level `MeterValues` templates only. Unlike
- * {@link ../ocpp/OCPPServiceUtils.getSampledValueTemplate}, this does NOT
- * fall back to EVSE-level `MeterValues` templates: the {@link ICoherentContext}
- * surface exposes `getConnectorStatus` but not `getEvseStatus`. Adding
- * EVSE-level template inheritance to the coherent path requires extending
- * the context interface with `getEvseStatus`.
+ * NOTE: Unlike
+ * {@link ../ocpp/OCPPServiceUtils.getSampledValueTemplate}, this does
+ * NOT aggregate `MeterValues` across sibling connectors under the
+ * same EVSE when EVSE-level `MeterValues` is undefined or empty. The
+ * coherent path emits templates from exactly one source (EVSE-level
+ * when non-empty, otherwise the queried connector), keeping
+ * per-connector template ownership isolated; the random/fixed path's
+ * cross-connector aggregation is intentionally not replicated.
  * @param context - Charging-station context.
  * @param connectorId - Connector identifier.
  * @returns Templates or `undefined`.
@@ -323,6 +330,13 @@ const resolveTemplates = (
   context: ICoherentContext,
   connectorId: number
 ): SampledValueTemplate[] | undefined => {
+  const evseId = context.getEvseIdByConnectorId(connectorId)
+  if (evseId != null) {
+    const evseTemplates = context.getEvseStatus(evseId)?.MeterValues
+    if (isNotEmptyArray(evseTemplates)) {
+      return evseTemplates
+    }
+  }
   return context.getConnectorStatus(connectorId)?.MeterValues
 }
 
index 63f01ed45b560c2bff208cabb01b4f8def088a28..bd028fe3dc633ec4ec02fa24521cc6cd1b6e744d 100644 (file)
@@ -14,6 +14,7 @@ import type {
   ChargingStationInfo,
   ConnectorStatus,
   CurrentType,
+  EvseStatus,
   Voltage,
 } from '../../types/index.js'
 
@@ -119,6 +120,8 @@ export interface CoherentSession {
 export interface ICoherentContext {
   getConnectorMaximumAvailablePower: (connectorId: number) => number
   getConnectorStatus: (connectorId: number) => ConnectorStatus | undefined
+  getEvseIdByConnectorId: (connectorId: number) => number | undefined
+  getEvseStatus: (evseId: number) => EvseStatus | undefined
   getNumberOfPhases: () => number
   getVoltageOut: () => Voltage
   logPrefix: () => string
index 41b0b5acc6895f153a534548888e6a561229ed19..618f5756cb2cc3c6b7b66a64f1f72be051feb3f8 100644 (file)
@@ -65,7 +65,20 @@ const baseProfile: EvProfile = {
  * @param overrides - Optional overrides bag for context knobs.
  * @param overrides.currentType - `CurrentType.AC` or `CurrentType.DC`.
  * @param overrides.evseMaxPowerW - EVSE cap returned by `getConnectorMaximumAvailablePower`.
+ * @param overrides.evseMeterValues - When defined and `groupUnderEvse !== false`, exposed via
+ *   `getEvseStatus(evseId).MeterValues`. When omitted with `groupUnderEvse === true`,
+ *   `getEvseStatus(evseId).MeterValues` is `undefined` (EVSE grouping present with no
+ *   EVSE-level MeterValues configured).
+ * @param overrides.groupUnderEvse - Explicit EVSE grouping flag. When `true`,
+ *   `getEvseIdByConnectorId` returns 1 regardless of `evseMeterValues`. When
+ *   `undefined`, grouping is implied by `evseMeterValues != null` (backward-compat).
+ *   Set to `false` to force flat-connector layout even with `evseMeterValues` defined.
  * @param overrides.numberOfPhases - Number of AC phases (ignored for DC).
+ * @param overrides.siblingConnectorMeterValues - When defined and grouping is active,
+ *   adds a sibling connector (id 2) under EVSE 1 with the given `MeterValues`. Lets
+ *   tests verify that the coherent path does NOT aggregate sibling-connector
+ *   templates when EVSE-level MeterValues is undefined or empty (documented
+ *   divergence from `getSampledValueTemplate`).
  * @param overrides.voltageOut - Nominal phase voltage.
  * @returns Bundle exposing context, sessions map, station info, and connector status.
  */
@@ -73,7 +86,10 @@ const buildContext = (
   overrides: {
     currentType?: CurrentType
     evseMaxPowerW?: number
+    evseMeterValues?: SampledValueTemplate[]
+    groupUnderEvse?: boolean
     numberOfPhases?: number
+    siblingConnectorMeterValues?: SampledValueTemplate[]
     voltageOut?: number
   } = {}
 ): {
@@ -114,6 +130,34 @@ const buildContext = (
   const context: ICoherentContext = {
     getConnectorMaximumAvailablePower: () => evseMax,
     getConnectorStatus: () => connectorStatus,
+    getEvseIdByConnectorId: () => {
+      // groupUnderEvse takes precedence over evseMeterValues heuristic.
+      // Undefined groupUnderEvse falls back to evseMeterValues != null.
+      if (overrides.groupUnderEvse === true) return 1
+      if (overrides.groupUnderEvse === false) return undefined
+      return overrides.evseMeterValues != null ? 1 : undefined
+    },
+    getEvseStatus: (evseId: number) => {
+      const grouped =
+        overrides.groupUnderEvse === true ||
+        (overrides.groupUnderEvse !== false && overrides.evseMeterValues != null)
+      if (!(grouped && evseId === 1)) return undefined
+      const connectors = new Map<number, ConnectorStatus>([[1, connectorStatus]])
+      if (overrides.siblingConnectorMeterValues != null) {
+        connectors.set(2, {
+          availability: AvailabilityType.Operative,
+          energyActiveImportRegisterValue: 0,
+          MeterValues: overrides.siblingConnectorMeterValues,
+          transactionEnergyActiveImportRegisterValue: 0,
+          transactionId: 2,
+        })
+      }
+      return {
+        availability: AvailabilityType.Operative,
+        connectors,
+        MeterValues: overrides.evseMeterValues,
+      }
+    },
     getNumberOfPhases: () => numberOfPhases,
     getVoltageOut: () => voltageOut,
     logPrefix: () => '[test]',
@@ -1339,4 +1383,212 @@ await describe('CoherentMeterValues', async () => {
       }
     })
   })
+
+  await describe('EVSE-level template fallback', async () => {
+    await it('should emit EVSE-level MeterValues when the connector is grouped under an EVSE with a non-empty MeterValues array', () => {
+      const evseTemplate: SampledValueTemplate = {
+        measurand: MeterValueMeasurand.STATE_OF_CHARGE,
+        unit: MeterValueUnit.PERCENT,
+      } as unknown as SampledValueTemplate
+      const { connectorStatus, context, sessions } = buildContext({
+        currentType: CurrentType.AC,
+        evseMaxPowerW: 22000,
+        evseMeterValues: [evseTemplate],
+        numberOfPhases: 3,
+        voltageOut: 230,
+      })
+      const session = createSessionOrFail(context, {
+        connectorId: 1,
+        now: 0,
+        profiles: [baseProfile],
+        rampUpDurationMs: 0,
+        rootSeed: 42,
+        transactionId: 1,
+      })
+      sessions.set(1, session)
+      // Connector-level templates ignored when EVSE-level is defined and non-empty.
+      connectorStatus.MeterValues = [
+        {
+          measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+          unit: MeterValueUnit.WATT_HOUR,
+        } 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,
+      })
+      const measurands = mv.sampledValue.map(
+        sv => (sv as { measurand?: MeterValueMeasurand }).measurand
+      )
+      assert.deepStrictEqual(
+        measurands,
+        [MeterValueMeasurand.STATE_OF_CHARGE],
+        'EVSE-level MeterValues (SoC only) must override connector-level (Energy only); connector Energy template must not emit'
+      )
+    })
+
+    await it('should fall back to connector-level MeterValues when no EVSE grouping exists', () => {
+      const connectorTemplate: SampledValueTemplate = {
+        measurand: MeterValueMeasurand.STATE_OF_CHARGE,
+        unit: MeterValueUnit.PERCENT,
+      } as unknown as SampledValueTemplate
+      // No evseMeterValues override => getEvseIdByConnectorId returns 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.MeterValues = [connectorTemplate]
+      const mv = buildCoherentMeterValue(context, session, passThroughBuilder, {
+        intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+        nowMs: TEST_METER_VALUES_INTERVAL_MS,
+        rootSeed: 42,
+        voltageNoise: false,
+      })
+      const measurands = mv.sampledValue.map(
+        sv => (sv as { measurand?: MeterValueMeasurand }).measurand
+      )
+      assert.deepStrictEqual(
+        measurands,
+        [MeterValueMeasurand.STATE_OF_CHARGE],
+        'connector-level MeterValues must emit when the connector is not grouped under an EVSE'
+      )
+    })
+
+    await it('should fall back to connector-level MeterValues when EVSE grouping exists but EVSE-level MeterValues array is empty', () => {
+      const connectorTemplate: SampledValueTemplate = {
+        measurand: MeterValueMeasurand.STATE_OF_CHARGE,
+        unit: MeterValueUnit.PERCENT,
+      } as unknown as SampledValueTemplate
+      // evseMeterValues=[] => getEvseIdByConnectorId returns 1, getEvseStatus(1).MeterValues = [].
+      // resolveTemplates must skip the empty EVSE array and fall back to connector-level.
+      const { connectorStatus, context, sessions } = buildContext({
+        currentType: CurrentType.AC,
+        evseMaxPowerW: 22000,
+        evseMeterValues: [],
+        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 = [connectorTemplate]
+      const mv = buildCoherentMeterValue(context, session, passThroughBuilder, {
+        intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+        nowMs: TEST_METER_VALUES_INTERVAL_MS,
+        rootSeed: 42,
+        voltageNoise: false,
+      })
+      const measurands = mv.sampledValue.map(
+        sv => (sv as { measurand?: MeterValueMeasurand }).measurand
+      )
+      assert.deepStrictEqual(
+        measurands,
+        [MeterValueMeasurand.STATE_OF_CHARGE],
+        'connector-level MeterValues must emit when EVSE grouping exists but the EVSE-level MeterValues array is empty'
+      )
+    })
+
+    await it('should fall back to connector-level MeterValues when EVSE grouping exists but EVSE-level MeterValues is undefined', () => {
+      const connectorTemplate: SampledValueTemplate = {
+        measurand: MeterValueMeasurand.STATE_OF_CHARGE,
+        unit: MeterValueUnit.PERCENT,
+      } as unknown as SampledValueTemplate
+      // groupUnderEvse=true + no evseMeterValues => getEvseIdByConnectorId returns 1,
+      // getEvseStatus(1).MeterValues = undefined. resolveTemplates must fall back
+      // to connector-level (guard: isNotEmptyArray(evseTemplates) narrows undefined
+      // and empty-array to false uniformly).
+      const { connectorStatus, context, sessions } = buildContext({
+        currentType: CurrentType.AC,
+        evseMaxPowerW: 22000,
+        groupUnderEvse: true,
+        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 = [connectorTemplate]
+      const mv = buildCoherentMeterValue(context, session, passThroughBuilder, {
+        intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+        nowMs: TEST_METER_VALUES_INTERVAL_MS,
+        rootSeed: 42,
+        voltageNoise: false,
+      })
+      const measurands = mv.sampledValue.map(
+        sv => (sv as { measurand?: MeterValueMeasurand }).measurand
+      )
+      assert.deepStrictEqual(
+        measurands,
+        [MeterValueMeasurand.STATE_OF_CHARGE],
+        'connector-level MeterValues must emit when EVSE grouping exists but EVSE-level MeterValues is undefined (both undefined and empty-array branches must fall back)'
+      )
+    })
+
+    await it('should NOT aggregate sibling-connector MeterValues when EVSE-level MeterValues is undefined or empty (documented divergence from getSampledValueTemplate)', () => {
+      // Sibling connector under the same EVSE has POWER_ACTIVE_IMPORT template.
+      // Queried connector has empty MeterValues. EVSE-level MeterValues is empty.
+      // Reference `getSampledValueTemplate` would aggregate: emit Power from sibling.
+      // Coherent `resolveTemplates` deliberately does NOT (per-connector template
+      // ownership) so nothing emits.
+      const siblingTemplate: SampledValueTemplate = {
+        measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+        unit: MeterValueUnit.WATT,
+      } as unknown as SampledValueTemplate
+      const { connectorStatus, context, sessions } = buildContext({
+        currentType: CurrentType.AC,
+        evseMaxPowerW: 22000,
+        evseMeterValues: [],
+        groupUnderEvse: true,
+        numberOfPhases: 3,
+        siblingConnectorMeterValues: [siblingTemplate],
+        voltageOut: 230,
+      })
+      const session = createSessionOrFail(context, {
+        connectorId: 1,
+        now: 0,
+        profiles: [baseProfile],
+        rampUpDurationMs: 0,
+        rootSeed: 42,
+        transactionId: 1,
+      })
+      sessions.set(1, session)
+      connectorStatus.MeterValues = []
+      const mv = buildCoherentMeterValue(context, session, passThroughBuilder, {
+        intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+        nowMs: TEST_METER_VALUES_INTERVAL_MS,
+        rootSeed: 42,
+        voltageNoise: false,
+      })
+      assert.strictEqual(
+        mv.sampledValue.length,
+        0,
+        'coherent path must NOT aggregate sibling-connector templates under the same EVSE; reference getSampledValueTemplate would emit sibling Power, coherent must emit nothing per documented one-source-per-connector divergence'
+      )
+    })
+  })
 })