* @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.
*/
overrides: {
currentType?: CurrentType
evseMaxPowerW?: number
+ evseMeterValues?: SampledValueTemplate[]
+ groupUnderEvse?: boolean
numberOfPhases?: number
+ siblingConnectorMeterValues?: SampledValueTemplate[]
voltageOut?: number
} = {}
): {
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]',
}
})
})
+
+ 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'
+ )
+ })
+ })
})