From a0cdcfc95958293619e3d7a4028f2e05b0cd6cca Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 5 Jul 2026 02:06:37 +0200 Subject: [PATCH] feat(meter-values): evse-level MeterValues template fallback in coherent path (issue #1936 g) (#1944) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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 | ReadonlyNonEmptyArray`) 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 | 8 +- .../meter-values/CoherentMeterValueBuilder.ts | 30 ++- src/charging-station/meter-values/types.ts | 3 + .../meter-values/CoherentMeterValues.test.ts | 252 ++++++++++++++++++ 4 files changed, 281 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 07a69c39..cfd62f97 100644 --- 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: diff --git a/src/charging-station/meter-values/CoherentMeterValueBuilder.ts b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts index cb92a545..01f2f61e 100644 --- a/src/charging-station/meter-values/CoherentMeterValueBuilder.ts +++ b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts @@ -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 } diff --git a/src/charging-station/meter-values/types.ts b/src/charging-station/meter-values/types.ts index 63f01ed4..bd028fe3 100644 --- a/src/charging-station/meter-values/types.ts +++ b/src/charging-station/meter-values/types.ts @@ -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 diff --git a/tests/charging-station/meter-values/CoherentMeterValues.test.ts b/tests/charging-station/meter-values/CoherentMeterValues.test.ts index 41b0b5ac..618f5756 100644 --- a/tests/charging-station/meter-values/CoherentMeterValues.test.ts +++ b/tests/charging-station/meter-values/CoherentMeterValues.test.ts @@ -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([[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' + ) + }) + }) }) -- 2.53.0