**src/assets/station-templates/\<name\>.json**:
-| Key | Value(s) | Default Value | Value type | Description |
-| ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| supervisionUrls | | [] | string \| string[] | string or strings array containing connection URIs to OCPP-J servers |
-| supervisionUser | | undefined | string | basic HTTP authentication user to OCPP-J server |
-| supervisionPassword | | undefined | string | basic HTTP authentication password to OCPP-J server |
-| supervisionUrlOcppConfiguration | true/false | false | boolean | enable supervision URL configuration via a vendor OCPP parameter key |
-| supervisionUrlOcppKey | | 'ConnectionUrl' | string | the vendor string that will be used as a vendor OCPP parameter key to set the supervision URL |
-| autoStart | true/false | true | boolean | enable automatic start of added charging station from template |
-| ocppVersion | 1.6/2.0/2.0.1 | 1.6 | string | OCPP version |
-| ocppProtocol | json | json | string | OCPP protocol |
-| ocppStrictCompliance | true/false | true | boolean | enable strict adherence to the OCPP version and protocol specifications with OCPP commands PDU validation against [OCA](https://www.openchargealliance.org/) JSON schemas |
-| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) |
-| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) |
-| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) |
-| wsOptions | | {} | ClientOptions & ClientRequestArgs | [ws](https://github.com/websockets/ws) and node.js [http](https://nodejs.org/api/http.html) clients options intersection |
-| idTagsFile | | undefined | string | RFID tags list file relative to [src/assets](./src/assets) path |
-| iccid | | undefined | string | SIM card ICCID |
-| imsi | | undefined | string | SIM card IMSI |
-| baseName | | undefined | string | base name to build charging stations id |
-| nameSuffix | | undefined | string | name suffix to build charging stations id |
-| fixedName | true/false | false | boolean | use the 'baseName' as the charging stations unique name |
-| chargePointModel | | undefined | string | charging stations model |
-| chargePointVendor | | undefined | string | charging stations vendor |
-| chargePointSerialNumberPrefix | | undefined | string | charge point serial number prefix |
-| chargeBoxSerialNumberPrefix | | undefined | string | charge box serial number prefix (deprecated since OCPP 1.6) |
-| meterSerialNumberPrefix | | undefined | string | meter serial number prefix |
-| meterType | | undefined | string | meter type |
-| firmwareVersionPattern | | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string | charging stations firmware version pattern |
-| firmwareVersion | | undefined | string | charging stations firmware version |
-| power | | | float \| float[] | charging stations maximum power value(s) |
-| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors. When true, any single connector can draw up to the full station power; when false, each connector is allocated an equal share |
-| powerUnit | W/kW | W | string | charging stations power unit |
-| currentOutType | AC/DC | AC | string | charging stations current out type |
-| voltageOut | | AC:230/DC:400 | integer | charging stations voltage out |
-| numberOfPhases | 0/1/3 | AC:3/DC:0 | integer | charging stations number of phase(s) |
-| numberOfConnectors | | | integer \| integer[] | charging stations number of connector(s) |
-| useConnectorId0 | true/false | true | boolean | use connector id 0 definition from the charging station configuration template |
-| randomConnectors | true/false | false | boolean | randomize runtime connector id affectation from the connector id definition in charging station configuration template |
-| resetTime | | 60 | integer | seconds to wait before the charging stations come back at reset |
-| autoRegister | true/false | false | boolean | set charging stations as registered at boot notification for testing purpose |
-| autoReconnectMaxRetries | | -1 (unlimited) | integer | connection retries to the OCPP-J server |
-| reconnectExponentialDelay | true/false | false | boolean | connection delay retry to the OCPP-J server |
-| registrationMaxRetries | | -1 (unlimited) | integer | charging stations boot notification retries |
-| amperageLimitationOcppKey | | undefined | string | charging stations OCPP parameter key used to set the amperage limit, per phase for each connector on AC and global for DC |
-| amperageLimitationUnit | A/cA/dA/mA | A | string | charging stations amperage limit unit |
-| enableStatistics | true/false | false | boolean | enable charging stations statistics |
-| remoteAuthorization | true/false | true | boolean | enable RFID tags remote authorization |
-| forceTransactionOnInvalidIdToken | true/false | false | boolean | continue station-initiated transactions when CSMS rejects the IdToken (`idTagInfo.status` ≠ Accepted in 1.6; `idTokenInfo.status` ≠ Accepted on `eventType=Started` in 2.0.1; mid-tx revocation on `Updated`/`Ended` still tears down). Non-spec-compliant when true (violates OCPP 2.0.1 E05.FR.09 / E05.FR.10 / E06.FR.04); independent of `ocppStrictCompliance`; distinct from OCPP variables `StopTransactionOnInvalidId` / `StopTxOnInvalidId` (mid-tx stop control) |
-| beginEndMeterValues | true/false | false | boolean | enable Transaction.{Begin,End} MeterValues |
-| outOfOrderEndMeterValues | true/false | false | boolean | send Transaction.End MeterValues out of order. Need to relax OCPP specifications strict compliance ('ocppStrictCompliance' parameter) |
-| meteringPerTransaction | true/false | true | boolean | enable metering history on a per transaction basis |
-| transactionDataMeterValues | true/false | false | boolean | enable transaction data MeterValues at stop transaction |
-| stopTransactionsOnStopped | true/false | true | boolean | enable stop transactions on charging station stop |
-| postTransactionDelay | ≥ 0 | 0 | integer | seconds to wait after transaction stop before transitioning connector to Available. Simulates cable-unplug delay. In OCPP 1.6 the connector stays in Finishing state; in OCPP 2.0.x it stays Occupied. 0 = immediate Available (default behavior) |
-| mainVoltageMeterValues | true/false | true | boolean | include charging stations main voltage MeterValues on three phased charging stations |
-| phaseLineToLineVoltageMeterValues | true/false | false | boolean | include charging stations line to line voltage MeterValues on three phased charging stations |
-| customValueLimitationMeterValues | true/false | true | boolean | enable limitation on custom fluctuated value in MeterValues |
-| firmwareUpgrade | | {<br />"versionUpgrade": {<br />"step": 1<br />},<br />"reset": true<br />} | {<br />versionUpgrade?: {<br />patternGroup?: number;<br />step?: number;<br />};<br />reset?: boolean;<br />failureStatus?: 'DownloadFailed' \| 'InstallationFailed';<br />} | Configuration section for simulating firmware upgrade support. |
-| commandsSupport | | {<br />"incomingCommands": {},<br />"outgoingCommands": {}<br />} | {<br /> incomingCommands: Record<IncomingRequestCommand, boolean>;<br />outgoingCommands?: Record<RequestCommand, boolean>;<br />} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported |
-| messageTriggerSupport | | {} | Record<MessageTrigger, boolean> | Configuration section for OCPP commands trigger support. Empty section means all implemented OCPP trigger commands are supported |
-| Configuration | | | ChargingStationOcppConfiguration | charging stations OCPP parameters configuration section |
-| AutomaticTransactionGenerator | | | AutomaticTransactionGeneratorConfiguration | charging stations ATG configuration section |
-| Connectors | | | Record<string, ConnectorStatus> | charging stations connectors configuration section |
-| Evses | | | Record<string, EvseTemplate> | charging stations EVSEs configuration section |
+| Key | Value(s) | Default Value | Value type | Description |
+| ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| supervisionUrls | | [] | string \| string[] | string or strings array containing connection URIs to OCPP-J servers |
+| supervisionUser | | undefined | string | basic HTTP authentication user to OCPP-J server |
+| supervisionPassword | | undefined | string | basic HTTP authentication password to OCPP-J server |
+| supervisionUrlOcppConfiguration | true/false | false | boolean | enable supervision URL configuration via a vendor OCPP parameter key |
+| supervisionUrlOcppKey | | 'ConnectionUrl' | string | the vendor string that will be used as a vendor OCPP parameter key to set the supervision URL |
+| autoStart | true/false | true | boolean | enable automatic start of added charging station from template |
+| ocppVersion | 1.6/2.0/2.0.1 | 1.6 | string | OCPP version |
+| ocppProtocol | json | json | string | OCPP protocol |
+| ocppStrictCompliance | true/false | true | boolean | enable strict adherence to the OCPP version and protocol specifications with OCPP commands PDU validation against [OCA](https://www.openchargealliance.org/) JSON schemas |
+| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) |
+| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) |
+| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) |
+| wsOptions | | {} | ClientOptions & ClientRequestArgs | [ws](https://github.com/websockets/ws) and node.js [http](https://nodejs.org/api/http.html) clients options intersection |
+| idTagsFile | | undefined | string | RFID tags list file relative to [src/assets](./src/assets) path |
+| iccid | | undefined | string | SIM card ICCID |
+| imsi | | undefined | string | SIM card IMSI |
+| baseName | | undefined | string | base name to build charging stations id |
+| nameSuffix | | undefined | string | name suffix to build charging stations id |
+| fixedName | true/false | false | boolean | use the 'baseName' as the charging stations unique name |
+| chargePointModel | | undefined | string | charging stations model |
+| chargePointVendor | | undefined | string | charging stations vendor |
+| chargePointSerialNumberPrefix | | undefined | string | charge point serial number prefix |
+| chargeBoxSerialNumberPrefix | | undefined | string | charge box serial number prefix (deprecated since OCPP 1.6) |
+| meterSerialNumberPrefix | | undefined | string | meter serial number prefix |
+| meterType | | undefined | string | meter type |
+| firmwareVersionPattern | | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string | charging stations firmware version pattern |
+| firmwareVersion | | undefined | string | charging stations firmware version |
+| power | | | float \| float[] | charging stations maximum power value(s) |
+| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors. When true, any single connector can draw up to the full station power; when false, each connector is allocated an equal share |
+| powerUnit | W/kW | W | string | charging stations power unit |
+| currentOutType | AC/DC | AC | string | charging stations current out type |
+| voltageOut | | AC:230/DC:400 | integer | charging stations voltage out |
+| numberOfPhases | 0/1/3 | AC:3/DC:0 | integer | charging stations number of phase(s) |
+| numberOfConnectors | | | integer \| integer[] | charging stations number of connector(s) |
+| useConnectorId0 | true/false | true | boolean | use connector id 0 definition from the charging station configuration template |
+| randomConnectors | true/false | false | boolean | randomize runtime connector id affectation from the connector id definition in charging station configuration template |
+| resetTime | | 60 | integer | seconds to wait before the charging stations come back at reset |
+| autoRegister | true/false | false | boolean | set charging stations as registered at boot notification for testing purpose |
+| autoReconnectMaxRetries | | -1 (unlimited) | integer | connection retries to the OCPP-J server |
+| reconnectExponentialDelay | true/false | false | boolean | connection delay retry to the OCPP-J server |
+| registrationMaxRetries | | -1 (unlimited) | integer | charging stations boot notification retries |
+| amperageLimitationOcppKey | | undefined | string | charging stations OCPP parameter key used to set the amperage limit, per phase for each connector on AC and global for DC |
+| amperageLimitationUnit | A/cA/dA/mA | A | string | charging stations amperage limit unit |
+| enableStatistics | true/false | false | boolean | enable charging stations statistics |
+| remoteAuthorization | true/false | true | boolean | enable RFID tags remote authorization |
+| forceTransactionOnInvalidIdToken | true/false | false | boolean | continue station-initiated transactions when CSMS rejects the IdToken (`idTagInfo.status` ≠ Accepted in 1.6; `idTokenInfo.status` ≠ Accepted on `eventType=Started` in 2.0.1; mid-tx revocation on `Updated`/`Ended` still tears down). Non-spec-compliant when true (violates OCPP 2.0.1 E05.FR.09 / E05.FR.10 / E06.FR.04); independent of `ocppStrictCompliance`; distinct from OCPP variables `StopTransactionOnInvalidId` / `StopTxOnInvalidId` (mid-tx stop control) |
+| beginEndMeterValues | true/false | false | boolean | enable Transaction.{Begin,End} MeterValues |
+| outOfOrderEndMeterValues | true/false | false | boolean | send Transaction.End MeterValues out of order. Need to relax OCPP specifications strict compliance ('ocppStrictCompliance' parameter) |
+| meteringPerTransaction | true/false | true | boolean | enable metering history on a per transaction basis |
+| transactionDataMeterValues | true/false | false | boolean | enable transaction data MeterValues at stop transaction |
+| stopTransactionsOnStopped | true/false | true | boolean | enable stop transactions on charging station stop |
+| postTransactionDelay | ≥ 0 | 0 | integer | seconds to wait after transaction stop before transitioning connector to Available. Simulates cable-unplug delay. In OCPP 1.6 the connector stays in Finishing state; in OCPP 2.0.x it stays Occupied. 0 = immediate Available (default behavior) |
+| mainVoltageMeterValues | true/false | true | boolean | include charging stations main voltage MeterValues on three-phase charging stations |
+| phaseLineToLineVoltageMeterValues | true/false | false | boolean | include charging stations line-to-line voltage MeterValues on three-phase charging stations |
+| customValueLimitationMeterValues | true/false | true | boolean | enable limitation on custom fluctuated value in MeterValues |
+| coherentMeterValues | true/false | false | boolean | enable physics-based coherent MeterValues generation. When `true`, emitted voltage, current, power, imported-energy register, and SoC are derived from a single physics chain. INV-1 is `P = V·I·phases` for AC and `P = V·I` for DC; INV-2 is monotone non-decreasing SoC; INV-3 is `ΔE = P·Δt/3.6e6` with a non-decreasing energy register. `ΔE` is an internal per-sample intermediate, not an emitted measurand. Requires `evProfilesFile` to be set; falls back to the default random/fixed generation with a warning if the file is absent or malformed |
+| evProfilesFile | | undefined | string | EV profile file relative to [src/assets](./src/assets) path. Consumed only when `coherentMeterValues` is `true`. See [EV profile file format](#ev-profile-file-format) |
+| randomSeed | | undefined (derived from `hashId` via FNV-1a) | integer | optional 32-bit unsigned integer seed for the coherent MeterValues PRNG. When set, identical `(randomSeed, transactionId)` inputs produce identical PRNG streams (and therefore identical per-measurand noise sequences); emitted energy and SoC additionally depend on `intervalMs` and elapsed session time. When absent, the seed is derived deterministically from the station `hashId`. Ignored when `coherentMeterValues` is `false` or absent |
+| firmwareUpgrade | | {<br />"versionUpgrade": {<br />"step": 1<br />},<br />"reset": true<br />} | {<br />versionUpgrade?: {<br />patternGroup?: number;<br />step?: number;<br />};<br />reset?: boolean;<br />failureStatus?: 'DownloadFailed' \| 'InstallationFailed';<br />} | Configuration section for simulating firmware upgrade support. |
+| commandsSupport | | {<br />"incomingCommands": {},<br />"outgoingCommands": {}<br />} | {<br /> incomingCommands: Record<IncomingRequestCommand, boolean>;<br />outgoingCommands?: Record<RequestCommand, boolean>;<br />} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported |
+| messageTriggerSupport | | {} | Record<MessageTrigger, boolean> | Configuration section for OCPP commands trigger support. Empty section means all implemented OCPP trigger commands are supported |
+| Configuration | | | ChargingStationOcppConfiguration | charging stations OCPP parameters configuration section |
+| AutomaticTransactionGenerator | | | AutomaticTransactionGeneratorConfiguration | charging stations ATG configuration section |
+| Connectors | | | Record<string, ConnectorStatus> | charging stations connectors configuration section |
+| Evses | | | Record<string, EvseTemplate> | charging stations EVSEs configuration section |
#### Configuration section syntax example
},
```
+#### EV profile file format
+
+The EV profile file is a JSON file referenced by the `evProfilesFile` template field. It defines a pool of EV charging profiles from which one is selected per transaction (weighted random, seeded deterministically). Consumed only when `coherentMeterValues` is `true`.
+
+**Schema** (`src/assets/<name>.json`):
+
+```json
+{
+ "profiles": [
+ {
+ "id": "city-ev-40kWh",
+ "weight": 3,
+ "batteryCapacityWh": 40000,
+ "maxPowerW": 11000,
+ "initialSocPercentMin": 15,
+ "initialSocPercentMax": 55,
+ "chargingCurve": [
+ { "socPercent": 0, "powerFraction": 1.0 },
+ { "socPercent": 75, "powerFraction": 1.0 },
+ { "socPercent": 90, "powerFraction": 0.4 },
+ { "socPercent": 100, "powerFraction": 0.08 }
+ ]
+ }
+ ]
+}
+```
+
+| Field | Type | Constraints | Description |
+| ---------------------- | --------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `id` | string | non-empty | Unique profile identifier (used in logs) |
+| `batteryCapacityWh` | number | > 0 | Battery capacity in Wh. Bounds ΔSoC per ΔE sample |
+| `maxPowerW` | number | > 0 | Maximum EV acceptance power in W at SoC = 0 |
+| `weight` | number | ≥ 0 | Relative selection weight. Higher weight increases the probability this profile is chosen for a transaction. A weight of 0 disables the profile |
+| `initialSocPercentMin` | number | [0, 100] | Minimum initial SoC (%) at transaction start. If greater than `initialSocPercentMax`, the two values are swapped and a warning is logged |
+| `initialSocPercentMax` | number | [0, 100] | Maximum initial SoC (%) at transaction start. See `initialSocPercentMin` for swap-and-warn behavior when bounds are inverted |
+| `chargingCurve` | `{ socPercent, powerFraction }[]` | non-empty, sorted | Piecewise-linear taper of EV acceptance power as a fraction of `maxPowerW`. Must be sorted non-decreasing by `socPercent`. `powerFraction` in [0, 1] |
+
+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.
+
+**Phase-qualified measurands.** When a connector template carries a `phase` field, the coherent generator emits one `SampledValue` per matching template with phase-aware values:
+
+- `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.
+- `SoC`: aggregate scalar; phase-qualified templates are skipped with a warning.
+
### Charging station configuration
**dist/assets/configurations/\<hashId\>.json**:
from: ['./src/assets/idtags!(-template)*.json'],
to: ['./assets'],
},
+ {
+ from: ['./src/assets/ev-profiles!(-template)*.json'],
+ to: ['./assets'],
+ },
{
from: ['./src/assets/json-schemas/**/*.json'],
to: ['./assets/json-schemas'],
--- /dev/null
+{
+ "profiles": [
+ {
+ "id": "city-ev-40kWh",
+ "weight": 3,
+ "batteryCapacityWh": 40000,
+ "maxPowerW": 11000,
+ "initialSocPercentMin": 15,
+ "initialSocPercentMax": 55,
+ "chargingCurve": [
+ { "socPercent": 0, "powerFraction": 1.0 },
+ { "socPercent": 75, "powerFraction": 1.0 },
+ { "socPercent": 90, "powerFraction": 0.4 },
+ { "socPercent": 100, "powerFraction": 0.08 }
+ ]
+ },
+ {
+ "id": "long-range-ev-77kWh",
+ "weight": 2,
+ "batteryCapacityWh": 77000,
+ "maxPowerW": 22000,
+ "initialSocPercentMin": 10,
+ "initialSocPercentMax": 60,
+ "chargingCurve": [
+ { "socPercent": 0, "powerFraction": 1.0 },
+ { "socPercent": 70, "powerFraction": 0.85 },
+ { "socPercent": 90, "powerFraction": 0.35 },
+ { "socPercent": 100, "powerFraction": 0.05 }
+ ]
+ },
+ {
+ "id": "dc-fast-ev-90kWh",
+ "weight": 1,
+ "batteryCapacityWh": 90000,
+ "maxPowerW": 150000,
+ "initialSocPercentMin": 10,
+ "initialSocPercentMax": 40,
+ "chargingCurve": [
+ { "socPercent": 0, "powerFraction": 1.0 },
+ { "socPercent": 50, "powerFraction": 0.85 },
+ { "socPercent": 80, "powerFraction": 0.45 },
+ { "socPercent": 100, "powerFraction": 0.05 }
+ ]
+ }
+ ]
+}
getConnectorChargingProfilesLimit,
getDefaultConnectorMaximumPower,
getDefaultVoltageOut,
+ getEvProfilesFile,
getHashId,
getIdTagsFile,
getMaxNumberOfConnectors,
validateStationInfo,
} from './Helpers.js'
import { IdTagsCache } from './IdTagsCache.js'
+import { disposeCoherentSessionRuntime } from './meter-values/CoherentMeterValuesGenerator.js'
+import {
+ type CoherentSession,
+ createCoherentSession,
+ type EvProfilesFile,
+ loadEvProfilesFile,
+ resolveRootSeed,
+} from './meter-values/index.js'
import {
buildBootNotificationRequest,
createOCPPServices,
private automaticTransactionGeneratorConfiguration?: AutomaticTransactionGeneratorConfiguration
private readonly chargingStationWorkerBroadcastChannel: ChargingStationWorkerBroadcastChannel
+ private coherentEvProfiles?: EvProfilesFile
+ private readonly coherentSessions: Map<number | string, CoherentSession>
private configurationFile!: string
private configurationFileHash!: string
private configuredSupervisionUrl!: URL
this.sharedLRUCache = SharedLRUCache.getInstance()
this.idTagsCache = IdTagsCache.getInstance()
this.chargingStationWorkerBroadcastChannel = new ChargingStationWorkerBroadcastChannel(this)
+ this.coherentSessions = new Map<number | string, CoherentSession>()
this.on(ChargingStationEvents.added, () => {
parentPort?.postMessage(buildAddedMessage(this))
}
}
+ /**
+ * Injects a pre-built coherent session directly into the session store.
+ * **Test seam only** — never call from production code; enforced at
+ * runtime by a `NODE_ENV === 'production'` guard that throws
+ * {@link BaseError}.
+ * @param transactionId - Transaction identifier.
+ * @param session - Pre-built session.
+ * @throws {BaseError} When invoked in a production build.
+ */
+ public __injectCoherentSession (transactionId: number | string, session: CoherentSession): void {
+ if (process.env.NODE_ENV === 'production') {
+ throw new BaseError(
+ `${this.logPrefix()} ${moduleName}.__injectCoherentSession: test-only seam called in production build`
+ )
+ }
+ this.coherentSessions.set(transactionId, session)
+ }
+
/**
* Adds a reservation to the specified connector.
* @param reservation - The reservation to add
}
}
+ /**
+ * Creates or returns the coherent MeterValues session for a transaction.
+ * Idempotent. Returns `undefined` when coherent mode is disabled or no
+ * valid EV profile file is loaded.
+ * @param transactionId - Transaction identifier from the CSMS.
+ * @param connectorId - Connector on which the transaction is running.
+ * @returns The active or newly-created session, or `undefined` when
+ * coherent mode is not usable.
+ */
+ public createCoherentSession (
+ transactionId: number | string,
+ connectorId: number
+ ): CoherentSession | undefined {
+ const existing = this.coherentSessions.get(transactionId)
+ if (existing != null) {
+ return existing
+ }
+ if (this.stationInfo?.coherentMeterValues !== true) {
+ return undefined
+ }
+ if (this.coherentEvProfiles == null || this.coherentEvProfiles.profiles.length === 0) {
+ return undefined
+ }
+ const rootSeed = resolveRootSeed(this.stationInfo)
+ const session = createCoherentSession(this, {
+ connectorId,
+ profiles: this.coherentEvProfiles.profiles,
+ rootSeed,
+ transactionId,
+ })
+ if (session != null) {
+ this.coherentSessions.set(transactionId, session)
+ }
+ return session
+ }
+
/**
* Deletes the charging station instance and optionally its persisted configuration.
* @param deleteConfiguration - Whether to delete the persisted configuration file
this.removeAllListeners()
}
+ /**
+ * Removes the coherent session for a transaction. Idempotent — safe to
+ * call from every reset/stop/disconnect path. Also disposes the module-scope
+ * per-session runtime state (voltage-noise PRNG closure).
+ * @param transactionId - Transaction identifier.
+ * @returns `true` when a session was removed, `false` otherwise.
+ */
+ public destroyCoherentSession (transactionId: number | string | undefined): boolean {
+ if (transactionId == null) {
+ return false
+ }
+ disposeCoherentSessionRuntime(this.coherentSessions.get(transactionId))
+ return this.coherentSessions.delete(transactionId)
+ }
+
/**
* Emit a ChargingStation event only if there are listeners registered for it.
* This optimizes performance by avoiding unnecessary event emission.
return this.getConfigurationFromFile()?.automaticTransactionGeneratorStatuses
}
+ /**
+ * Retrieves the coherent session for a transaction, if any.
+ * @param transactionId - Transaction identifier.
+ * @returns The session or `undefined` when none exists.
+ */
+ public getCoherentSession (transactionId: number | string): CoherentSession | undefined {
+ return this.coherentSessions.get(transactionId)
+ }
+
public getConnectionTimeout (): number {
if (getConfigurationKey(this, StandardParametersKey.ConnectionTimeOut) != null) {
return convertToInt(
this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash)
this.emitChargingStationEvent(ChargingStationEvents.stopped)
} finally {
+ // Drop any coherent sessions still tracked at shutdown so a
+ // subsequent restart cannot resurrect stale state or leak
+ // module-scope runtime PRNG closures.
+ for (const session of this.coherentSessions.values()) {
+ disposeCoherentSessionRuntime(session)
+ }
+ this.coherentSessions.clear()
this.stopping = false
}
} else {
}
}
this.saveStationInfo()
+ this.initializeCoherentEvProfiles()
this.configuredSupervisionUrl = this.getConfiguredSupervisionUrl()
if (this.stationInfo.enableStatistics === true) {
this.performanceStatistics = PerformanceStatistics.getInstance(
}
}
+ /**
+ * Loads and validates the EV profile file when coherent MeterValues are
+ * enabled. Fail-soft: any error disables coherent mode for this station
+ * (createCoherentSession then becomes a no-op).
+ */
+ private initializeCoherentEvProfiles (): void {
+ this.coherentEvProfiles = undefined
+ if (this.stationInfo?.coherentMeterValues !== true) {
+ return
+ }
+ const evProfilesFile = getEvProfilesFile(this.stationInfo)
+ if (evProfilesFile == null) {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: coherentMeterValues=true but no evProfilesFile is configured, coherent MeterValues disabled`
+ )
+ return
+ }
+ const loaded = loadEvProfilesFile(evProfilesFile, this.logPrefix())
+ if (loaded == null) {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: EV profiles could not be loaded, coherent MeterValues disabled`
+ )
+ return
+ }
+ this.coherentEvProfiles = loaded
+ }
+
private initializeConnectorsFromTemplate (stationTemplate: ChargingStationTemplate): void {
if (stationTemplate.Connectors == null && isEmpty(this.connectors)) {
const errorMsg = `No already defined connectors and charging station information from template ${this.templateFile} with no connectors configuration defined`
: undefined
}
+export const getEvProfilesFile = (stationInfo: ChargingStationInfo): string | undefined => {
+ return stationInfo.evProfilesFile != null
+ ? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.evProfilesFile))
+ : undefined
+}
+
export const waitChargingStationEvents = async (
emitter: EventEmitter,
event: ChargingStationWorkerMessageEvents,
chargePointModel: z.string().min(1),
chargePointSerialNumberPrefix: z.string().optional(),
chargePointVendor: z.string().min(1),
+ coherentMeterValues: z.boolean().optional(),
commandsSupport: CommandsSupportSchema.optional(),
Configuration: OcppConfigurationSchema.optional(),
Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema).optional(),
currentOutType: z.string().optional(),
customValueLimitationMeterValues: z.boolean().optional(),
enableStatistics: z.boolean().optional(),
+ evProfilesFile: z.string().optional(),
Evses: z.record(z.string().regex(/^\d+$/), EvseTemplateSchema).optional(),
firmwareUpgrade: FirmwareUpgradeSchema.optional(),
firmwareVersion: z.string().optional(),
powerSharedByConnectors: z.boolean().optional(),
powerUnit: z.string().optional(),
randomConnectors: z.boolean().optional(),
+ randomSeed: z.number().int().optional(),
reconnectExponentialDelay: z.boolean().optional(),
registrationMaxRetries: z.number().optional(),
remoteAuthorization: z.boolean().optional(),
} from './Helpers.js'
export type { IBootstrap } from './IBootstrap.js'
export { IdTagsCache } from './IdTagsCache.js'
+export type { CoherentSession } from './meter-values/index.js'
export { SharedLRUCache } from './SharedLRUCache.js'
export { applyMigration, coerceVersion, CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
export { StrictTemplateSchema, TemplateSchema } from './TemplateSchema.js'
--- /dev/null
+// Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+/**
+ * @file Physics-based coherent MeterValues generator.
+ * @description Constructs a coherent {@link MeterValue} in which every
+ * emitted measurand is derived from a single physics chain
+ * (V → P → I → ΔE → SoC) rather than sampled independently.
+ *
+ * Invariants (enforced by construction):
+ * - **INV-1**: AC: `P = V × I × phases`; DC: `P = V × I`. Emitted `powerW`
+ * is recomputed from the rounded emitted current and voltage so
+ * `|P - V·I·phases|` stays within the `ROUNDING_SCALE` half-width
+ * (≤ 0.005 W scalar bound) regardless of V or phases. Per-phase L-N
+ * `Power.Active.Import` emission is derived as
+ * `round(aggregate_P / phases, 2)`; the per-phase identity
+ * `|P_LxN - V_LxN · I_Lx|` therefore holds within `2 × ROUNDING_SCALE`
+ * half-width (≤ 0.01 W) — one half-width for the aggregate emit and
+ * one for the per-phase division.
+ * - **INV-2**: `SoC(t+1) ≥ SoC(t)` and `ΔSoC = ΔE / batteryCapacityWh × 100`.
+ * SoC monotone non-decreasing during charging and saturates at 100 %.
+ * - **INV-3**: `ΔE = P_clamped × Δt / MS_PER_HOUR` where `P_clamped` is the
+ * pre-emit-rounding capacity-clamped power. `E(t+1) ≥ E(t)`. A consumer
+ * integrating the post-rounding emitted `powerW` samples may diverge
+ * from the register by at most `ROUNDING_SCALE half-width × N × Δt /
+ * MS_PER_HOUR` Wh over `N` samples — bounded and invisible at
+ * `ROUNDING_SCALE` (~0.12 Wh over 24 h at 1 Hz).
+ * - `P ≤ min(EVSE_max, EV_acceptance(SoC))`.
+ * - `SoC ≥ 100 ⇒ P = 0, I = 0, ΔE = 0`.
+ *
+ * The generator owns the connector energy register update: it advances the
+ * register exactly once per sample, unconditionally, so `meterStop` is
+ * correct even when `Energy.Active.Import.Register` is not in the
+ * configured MeterValues.
+ *
+ * TODO(#1936): file size exceeds the 250 LOC ceiling documented in
+ * AGENTS.md. Modular split (CoherentSampleComputer.ts + CoherentMeterValueBuilder.ts,
+ * keeping session lifecycle helpers in this entry) tracked as follow-up.
+ */
+
+import type {
+ ConnectorStatus,
+ MeterValue,
+ MeterValueContext,
+ SampledValue,
+ SampledValueTemplate,
+} from '../../types/index.js'
+import type { CoherentSession, EvProfile, ICoherentContext } from './types.js'
+
+import {
+ CurrentType,
+ MeterValueMeasurand,
+ MeterValuePhase,
+ MeterValueUnit,
+ Voltage,
+} from '../../types/index.js'
+import { Constants, logger, roundTo } from '../../utils/index.js'
+import { interpolateChargingCurve, selectEvProfile } from './EvProfiles.js'
+import { deriveSeed, hashLabel, mulberry32 } from './Prng.js'
+
+const moduleName = 'CoherentMeterValuesGenerator'
+
+/**
+ * Decimal places for all physics-quantity rounding (V, A, W, Wh, SoC).
+ * The `roundTo` half-width bound is `0.5 × 10^-ROUNDING_SCALE = 0.005` on
+ * each rounded quantity; INV-1 residual is bounded by this scalar.
+ */
+const ROUNDING_SCALE = 2
+
+/**
+ * Signature of the versioned SampledValue builder returned by the
+ * OCPP-version dispatcher in {@link ../ocpp/OCPPServiceUtils.buildMeterValue}.
+ * Kept structurally compatible so a coherent generator can emit SampledValues
+ * in either OCPP 1.6 or 2.0 formats without knowing the version.
+ */
+export type BuildVersionedSampledValue = (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ context?: MeterValueContext,
+ phase?: MeterValuePhase
+) => SampledValue
+
+/**
+ * Runtime-only per-session state. Kept in a module-scope WeakMap keyed by
+ * the {@link CoherentSession} object (rather than by transactionId) so
+ * runtime state is scoped to the session's identity — no cross-station
+ * coupling when two stations happen to share a transactionId — and is
+ * auto-collected when the session becomes unreachable.
+ */
+interface SessionRuntime {
+ voltagePrng?: () => number
+}
+
+const sessionRuntimes = new WeakMap<CoherentSession, SessionRuntime>()
+
+/**
+ * Retrieves the runtime bag for a session, creating it on first access.
+ * Not exported: only the generator reads or writes runtime state.
+ * @param session - Coherent session.
+ * @returns Live runtime bag (mutated in place).
+ */
+const getSessionRuntime = (session: CoherentSession): SessionRuntime => {
+ let runtime = sessionRuntimes.get(session)
+ if (runtime == null) {
+ runtime = {}
+ sessionRuntimes.set(session, runtime)
+ }
+ return runtime
+}
+
+/**
+ * Disposes runtime state for a session. Call from every session-teardown
+ * path (stop/reset/disconnect) to release cached PRNG state eagerly.
+ * The WeakMap makes eager disposal optional — unreachable sessions are
+ * collected automatically — but eager disposal preserves determinism
+ * across sequential transactions that reuse the same session identity.
+ * Idempotent.
+ * @param session - Coherent session (or `undefined` when the caller has
+ * no session at hand).
+ * @returns `true` when runtime was removed, `false` otherwise.
+ */
+export const disposeCoherentSessionRuntime = (session: CoherentSession | undefined): boolean => {
+ if (session == null) {
+ return false
+ }
+ return sessionRuntimes.delete(session)
+}
+
+/**
+ * Deterministic per-transaction stream splitter. Combines the station
+ * `randomSeed` (or a stable fallback), the transactionId, and a label so
+ * that adding a new consumer never shifts an existing stream's sequence.
+ * @param rootSeed - Root 32-bit seed for the station.
+ * @param transactionId - Transaction identifier.
+ * @param label - Stream label (`'VOLTAGE_NOISE'`, `'POWER_NOISE'`, ...).
+ * @returns PRNG function producing [0, 1) floats.
+ */
+export const createStreamPrng = (
+ rootSeed: number,
+ transactionId: number | string,
+ label: string
+): (() => number) => {
+ // Namespace the transactionId leg with a `tx:` prefix so
+ // `String(transactionId) === label` cannot trigger the XOR self-inverse
+ // `deriveSeed(deriveSeed(r, X), X) === r`. Labels never start with `tx:`
+ // by construction (`VOLTAGE_NOISE`, `POWER_NOISE`, ...).
+ const txSeed = deriveSeed(rootSeed, `tx:${String(transactionId)}`)
+ return mulberry32(deriveSeed(txSeed, label))
+}
+
+/**
+ * Symmetric fluctuation helper: draws a uniform sample and maps it into
+ * `[base * (1 - percent), base * (1 + percent))`.
+ * @param base - Nominal value.
+ * @param percent - Half-width of the symmetric interval (e.g. 0.01 = ±1 %).
+ * @param prng - Seeded PRNG stream.
+ * @returns Fluctuated value.
+ */
+const fluctuate = (base: number, percent: number, prng: () => number): number => {
+ return base * (1 + (prng() * 2 - 1) * percent)
+}
+
+/**
+ * Determines whether coherent mode is enabled on the given context and a
+ * session exists for the transaction. Returned by the strategy gate to
+ * decide dispatch.
+ * @param context - Charging-station context (subset of `ChargingStation`).
+ * @param transactionId - Transaction identifier.
+ * @returns `true` if coherent mode should own MeterValue construction.
+ */
+export const isCoherentModeActive = (
+ context: ICoherentContext,
+ transactionId: number | string
+): boolean => {
+ if (context.stationInfo?.coherentMeterValues !== true) {
+ return false
+ }
+ return context.getCoherentSession(transactionId) != null
+}
+
+/**
+ * Unconditionally advances the connector energy registers by `deltaEnergyWh`.
+ * The coherent path owns register updates so `meterStop` stays correct even
+ * when the `Energy.Active.Import.Register` measurand is not configured.
+ * Negative or nullish register starting values are clamped to zero before
+ * accrual.
+ * @param connectorStatus - Target connector status (in-place update).
+ * @param deltaEnergyWh - Energy delta (Wh).
+ */
+export const advanceEnergyRegister = (
+ connectorStatus: ConnectorStatus | undefined,
+ deltaEnergyWh: number
+): void => {
+ if (connectorStatus == null) {
+ return
+ }
+ connectorStatus.energyActiveImportRegisterValue =
+ Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0) + deltaEnergyWh
+ connectorStatus.transactionEnergyActiveImportRegisterValue =
+ Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + deltaEnergyWh
+}
+
+/**
+ * Coherent physics sample. All fields follow the `<quantity><Unit>` naming
+ * convention (`currentA`, `powerW`, `voltageV`, ...).
+ */
+export interface CoherentSample {
+ currentA: number
+ deltaEnergyWh: number
+ energyRegisterWh: number
+ powerW: number
+ socPercent: number
+ voltageV: number
+}
+
+/**
+ * Options for {@link computeCoherentSample}.
+ */
+export interface ComputeSampleOptions {
+ /**
+ * Sample interval in milliseconds. Drives energy accrual and the
+ * remaining-capacity clamp. Non-positive/non-finite triggers the
+ * zero-sample defensive branch.
+ */
+ intervalMs: number
+ /**
+ * Sample timestamp in milliseconds (typically `Date.now()`); combined
+ * with `session.sessionStartMs` for ramp-up progress. Non-finite
+ * triggers the zero-sample defensive branch.
+ */
+ nowMs: number
+ /**
+ * Root 32-bit seed for stream splitting; combined with
+ * `session.transactionId` and per-measurand labels to derive
+ * independent PRNG streams via FNV-1a stream splitting.
+ */
+ rootSeed: number
+ /**
+ * Enable or disable per-sample voltage noise. When `false`, `voltageV`
+ * is exactly the nominal voltage with no PRNG-derived fluctuation.
+ * Intended for deterministic unit tests. Defaults to `true` when
+ * omitted (the `options.voltageNoise !== false` guard).
+ */
+ voltageNoise?: boolean
+}
+
+const buildZeroSample = (
+ socPercent: number,
+ voltageV: number,
+ energyRegisterWh: number
+): CoherentSample => ({
+ currentA: 0,
+ deltaEnergyWh: 0,
+ energyRegisterWh,
+ powerW: 0,
+ socPercent: roundTo(socPercent, ROUNDING_SCALE),
+ voltageV: voltageV > 0 && Number.isFinite(voltageV) ? roundTo(voltageV, ROUNDING_SCALE) : 0,
+})
+
+/**
+ * Computes a single coherent sample and mutates the caller-owned
+ * `session.socPercent`. The energy register is NOT advanced here; the
+ * caller (`buildCoherentMeterValue`) invokes {@link advanceEnergyRegister}
+ * once per emitted sample so the semantics match the OCPP energy meter
+ * model.
+ *
+ * Physics chain (INV-1/INV-2/INV-3 hold by construction):
+ * 1. `rampFactor = min(1, elapsed / rampUp)` — immutable session start.
+ * 2. `V` — nominal ± small seed-derived noise.
+ * 3. `evAcceptanceW = curve(SoC) × profile.maxPowerW`.
+ * 4. `powerW = rampFactor × min(EVSE_max, evAcceptance) × socCap`.
+ * EVSE cap already folds in charging profiles via
+ * {@link ICoherentContext.getConnectorMaximumAvailablePower}.
+ * 5. `powerW` is then clamped to remaining battery capacity so a sample
+ * crossing 100 % SoC never over-charges the register.
+ * 6. `currentAExact = powerW / (V_round · phases)` — exact fraction
+ * (phases=1 for DC). Emitted current is rounded to `ROUNDING_SCALE`.
+ * 7. Emitted `powerW = round(V_round × currentA_round × phases)`;
+ * INV-1 holds within `ROUNDING_SCALE` half-width (≤ 0.005 W).
+ * 8. `ΔE = P_clamped × Δt / MS_PER_HOUR` — uses the pre-emit-rounding
+ * `powerW` so the register integrates the capacity-clamped power
+ * exactly (INV-3).
+ * 9. `ΔSoC = ΔE / capacity × 100`; `socPercent = min(100, soc + ΔSoC)`.
+ * @param context - Charging-station context.
+ * @param connectorStatus - Connector status.
+ * @param session - Active coherent session (resolved by caller).
+ * @param options - Per-sample parameters (interval, seed material, ...).
+ * @returns The computed sample. `energyRegisterWh` reflects the projected
+ * register value AFTER `advanceEnergyRegister` is applied by the caller.
+ */
+export const computeCoherentSample = (
+ context: ICoherentContext,
+ connectorStatus: ConnectorStatus,
+ session: CoherentSession,
+ options: ComputeSampleOptions
+): CoherentSample => {
+ const transactionId = session.transactionId
+
+ // Defensive guard bundle covering NaN/incoherence sources:
+ // - intervalMs ≤ 0 or non-finite: divide-by-zero, negative Δt, or NaN/Infinity
+ // propagates through `maxPowerFromCapacityW = remainingWh · MS_PER_HOUR /
+ // intervalMs` and permanently poisons session.socPercent. `Number.isFinite`
+ // covers the NaN/±Infinity paths since `NaN <= 0 === false`.
+ // - batteryCapacityWh ≤ 0 or non-finite: Zod (`EvProfileSchema`) enforces
+ // `.positive()` at file load, but `injectCoherentSession` bypasses Zod;
+ // `deltaSocPercent = ΔE / batteryCapacityWh × 100 = NaN` would poison SoC.
+ // - nominal voltage ≤ 0 or non-finite: `Voltage` enum values are all-positive,
+ // but a template override or future dynamic supply could return 0.
+ // - nowMs non-finite: pushes elapsedMs to NaN and destabilizes rampFactor.
+ // - AC with numberOfPhases ≤ 0: divisor collapses to 0 (`V · 0 = 0`),
+ // currentA is guarded to zero, and P = 0 silently — a misconfigured
+ // multi-phase station would emit zeros for the whole session. Fail
+ // loudly with a zero sample so operators notice the misconfiguration.
+ const batteryCapacityWh = session.profile.batteryCapacityWh
+ const nominalV: number = session.voltageOutNominal
+ const currentType = session.currentType
+ const numberOfPhases = session.numberOfPhases
+ if (
+ options.intervalMs <= 0 ||
+ !Number.isFinite(options.intervalMs) ||
+ batteryCapacityWh <= 0 ||
+ !Number.isFinite(batteryCapacityWh) ||
+ nominalV <= 0 ||
+ !Number.isFinite(nominalV) ||
+ !Number.isFinite(options.nowMs) ||
+ (currentType === CurrentType.AC && numberOfPhases <= 0)
+ ) {
+ const safeV = nominalV > 0 && Number.isFinite(nominalV) ? nominalV : 0
+ return buildZeroSample(
+ session.socPercent,
+ safeV,
+ Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0)
+ )
+ }
+
+ const elapsedMs = Math.max(0, options.nowMs - session.sessionStartMs)
+ // Non-positive or non-finite rampUpDurationMs would either divide by zero
+ // (NaN) or produce rampFactor > 1 / negative. Treat any invalid value as
+ // "no ramp" (immediate full-power), matching the existing semantic where
+ // rampUpDurationMs = 0 means immediate full-power.
+ // Sub-millisecond values (e.g. Number.EPSILON) pass the guard but yield
+ // elapsedMs / rampUpDurationMs >> 1 for any real elapsed time, so
+ // Math.min(1, …) = 1 — semantically equivalent to rampUpDurationMs = 0.
+ const rampFactor =
+ session.rampUpDurationMs > 0 && Number.isFinite(session.rampUpDurationMs)
+ ? Math.min(1, elapsedMs / session.rampUpDurationMs)
+ : 1
+
+ // Voltage: nominal ± small seed-derived noise. The voltage PRNG lives on
+ // module-scope runtime state (not on the serializable session) so its
+ // stream advances across samples; constructing a new PRNG per sample
+ // would restart from the same seed each draw and produce a stalled
+ // (non-advancing) sequence.
+ let sampledV = nominalV
+ if (options.voltageNoise !== false) {
+ const runtime = getSessionRuntime(session)
+ runtime.voltagePrng ??= createStreamPrng(options.rootSeed, transactionId, 'VOLTAGE_NOISE')
+ sampledV = fluctuate(
+ nominalV,
+ Constants.DEFAULT_COHERENT_VOLTAGE_NOISE_PERCENT,
+ runtime.voltagePrng
+ )
+ }
+ const roundedV = roundTo(sampledV, ROUNDING_SCALE)
+
+ // EV acceptance from the curve at running SoC.
+ const acceptanceFraction = interpolateChargingCurve(
+ session.profile.chargingCurve,
+ session.socPercent
+ )
+ const evAcceptanceW = acceptanceFraction * session.profile.maxPowerW
+
+ // EVSE cap (already includes hardware/charging-profile clamps via ChargingStation).
+ const evseLimitW = context.getConnectorMaximumAvailablePower(session.connectorId)
+
+ const socCap = session.socPercent >= 100 ? 0 : 1
+ const targetPowerW = rampFactor * Math.min(evseLimitW, evAcceptanceW) * socCap
+ let powerW = Math.max(0, targetPowerW)
+
+ // Clamp powerW to whatever the remaining battery capacity accepts over
+ // this interval so a sample that crosses 100 % SoC cannot over-charge the
+ // register. INV-3 is preserved because ΔE is computed from the clamped
+ // power below.
+ const remainingWh = Math.max(
+ 0,
+ ((100 - session.socPercent) / 100) * session.profile.batteryCapacityWh
+ )
+ const maxPowerFromCapacityW = (remainingWh * Constants.MS_PER_HOUR) / options.intervalMs
+ powerW = Math.min(powerW, maxPowerFromCapacityW)
+
+ // Physics: derive per-phase current as an exact fraction so
+ // V_round · currentAExact · phases = powerW
+ // holds identically. `numberOfPhases` is 1 for DC (line above) so a
+ // single branch covers both currents. Using integer-rounded amps here
+ // would inflate V·I·phases above the capacity-clamped powerW by up to
+ // V·phases·0.5 W, breaking INV-1.
+ const divisor = roundedV * numberOfPhases
+ const currentAExact = divisor > 0 ? powerW / divisor : 0
+
+ // Emission: round current to `ROUNDING_SCALE`, then derive emitted power
+ // from the rounded current so INV-1 (P = V·I·phases) holds within
+ // `ROUNDING_SCALE` half-width (≤ 0.005 W) regardless of V or phases.
+ const roundedCurrent = roundTo(currentAExact, ROUNDING_SCALE)
+ const roundedPower = roundTo(roundedV * roundedCurrent * numberOfPhases, ROUNDING_SCALE)
+
+ // Energy accounting uses the clamped (pre-rounding) `powerW` so INV-3
+ // holds within floating-point ε and the capacity budget is respected
+ // exactly. `Math.max(0, ...)` on `preRegisterWh` mirrors the clamp
+ // applied by `advanceEnergyRegister` so the reported `energyRegisterWh`
+ // and the post-advance persisted state agree even if the persisted
+ // register is corrupted to a negative value.
+ const deltaEnergyWh = (powerW * options.intervalMs) / Constants.MS_PER_HOUR
+ const preRegisterWh = Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0)
+ const projectedRegisterWh = preRegisterWh + deltaEnergyWh
+
+ // INV-2: SoC(t+1) ≥ SoC(t); ΔSoC = ΔE / batteryCapacityWh × 100. Saturates at 100 %.
+ const deltaSocPercent = (deltaEnergyWh / session.profile.batteryCapacityWh) * 100
+ session.socPercent = Math.min(100, session.socPercent + deltaSocPercent)
+
+ return {
+ currentA: roundedCurrent,
+ deltaEnergyWh,
+ energyRegisterWh: projectedRegisterWh,
+ powerW: roundedPower,
+ socPercent: roundTo(session.socPercent, ROUNDING_SCALE),
+ voltageV: roundedV,
+ }
+}
+
+/**
+ * Phase family classifier lookup for coherent emission. `satisfies Record<...>`
+ * gates compile-time exhaustiveness so a new `MeterValuePhase` value fails
+ * compile until classified. `Aggregate` is applied when `phase` is `undefined`
+ * (the sentinel handled by `phaseFamily` outside the table).
+ * - `LineToNeutral`: bare `L1`/`L2`/`L3` and `L1-N`/`L2-N`/`L3-N`
+ * (line-current or phase-voltage measurements).
+ * - `LineToLine`: `L1-L2`/`L2-L3`/`L3-L1` (line-to-line voltage; not
+ * defined for current or power in the coherent model).
+ * - `Neutral`: `N` (physically 0 for balanced 3-phase Y).
+ */
+const PHASE_FAMILY = {
+ [MeterValuePhase.L1]: 'LineToNeutral',
+ [MeterValuePhase.L1_L2]: 'LineToLine',
+ [MeterValuePhase.L1_N]: 'LineToNeutral',
+ [MeterValuePhase.L2]: 'LineToNeutral',
+ [MeterValuePhase.L2_L3]: 'LineToLine',
+ [MeterValuePhase.L2_N]: 'LineToNeutral',
+ [MeterValuePhase.L3]: 'LineToNeutral',
+ [MeterValuePhase.L3_L1]: 'LineToLine',
+ [MeterValuePhase.L3_N]: 'LineToNeutral',
+ [MeterValuePhase.N]: 'Neutral',
+} as const satisfies Record<MeterValuePhase, 'LineToLine' | 'LineToNeutral' | 'Neutral'>
+
+const phaseFamily = (
+ phase: MeterValuePhase | undefined
+): 'Aggregate' | 'LineToLine' | 'LineToNeutral' | 'Neutral' =>
+ phase == null ? 'Aggregate' : PHASE_FAMILY[phase]
+
+/**
+ * Emit order across measurands, mirroring the `getSampledValueTemplate`
+ * path (SoC → Voltage → Power → Current → Energy). Preserved so downstream
+ * consumers relying on OCPP MeterValue ordering keep working.
+ */
+const MEASURAND_EMIT_ORDER = [
+ MeterValueMeasurand.STATE_OF_CHARGE,
+ MeterValueMeasurand.VOLTAGE,
+ MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+ MeterValueMeasurand.CURRENT_IMPORT,
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+] as const
+
+/**
+ * Within-measurand phase order for deterministic per-phase emission:
+ * no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N.
+ * Lower rank emits first. `satisfies Record<...>` gates exhaustiveness
+ * so a new `MeterValuePhase` value fails compile until ranked.
+ */
+const PHASE_RANK = {
+ [MeterValuePhase.L1]: 1,
+ [MeterValuePhase.L1_L2]: 4,
+ [MeterValuePhase.L1_N]: 1,
+ [MeterValuePhase.L2]: 2,
+ [MeterValuePhase.L2_L3]: 5,
+ [MeterValuePhase.L2_N]: 2,
+ [MeterValuePhase.L3]: 3,
+ [MeterValuePhase.L3_L1]: 6,
+ [MeterValuePhase.L3_N]: 3,
+ [MeterValuePhase.N]: 7,
+} as const satisfies Record<MeterValuePhase, number>
+
+/**
+ * Groups templates by measurand and sorts each bucket by phase rank.
+ * Templates without an explicit `measurand` default to
+ * `Energy.Active.Import.Register`, mirroring the existing convention.
+ * @param templates - Templates configured on the connector (or `undefined`).
+ * @returns Grouped, phase-ordered templates.
+ */
+const groupTemplatesByMeasurand = (
+ templates: SampledValueTemplate[] | undefined
+): Map<MeterValueMeasurand, SampledValueTemplate[]> => {
+ const groups = Map.groupBy(
+ templates ?? [],
+ t => t.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ for (const bucket of groups.values()) {
+ bucket.sort(
+ (a, b) =>
+ (a.phase == null ? 0 : PHASE_RANK[a.phase]) - (b.phase == null ? 0 : PHASE_RANK[b.phase])
+ )
+ }
+ return groups
+}
+
+/**
+ * Resolves the exact physical value to emit for a template given the
+ * coherent sample. Returns `undefined` for unsupported `(measurand, phase)`
+ * pairs so the caller can log-and-skip. Rounding is deferred to the emit
+ * site so unit-conversion divisions round once.
+ *
+ * Per-phase resolution (balanced 3-phase Y assumption):
+ * - Voltage: L-N ⇒ `sample.voltageV`; L-L ⇒ `√phases × sample.voltageV`
+ * (`√phases` collapses to 1 on single-phase, in which case L-L has no
+ * physical meaning and the template is skipped); N ⇒ 0.
+ * - Power.Active.Import: aggregate ⇒ total P; L-N ⇒ `P / phases`;
+ * L-L undefined; N undefined (neutral carries no active power in
+ * balanced 3-φ Y).
+ * - Current.Import: any line phase ⇒ `sample.currentA` (line current);
+ * L-L undefined; N ⇒ 0 (balanced 3-φ Y neutral current is zero).
+ * - SoC: aggregate scalar; phase-qualified templates rejected.
+ * - Energy.Active.Import.Register: aggregate ⇒ total register; L-N ⇒
+ * `register / phases` (per-phase energy contribution under balanced
+ * 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.
+ * @param measurand - Target measurand.
+ * @param phase - Template `phase` field (may be `undefined`).
+ * @param sample - Coherent sample (source of aggregate values).
+ * @param numberOfPhases - Session phase count.
+ * @param connectorStatus - Connector status (for the energy register).
+ * @returns Value to emit, or `undefined` if the combination is unsupported.
+ */
+const resolvePhasedValue = (
+ measurand: MeterValueMeasurand,
+ phase: MeterValuePhase | undefined,
+ sample: CoherentSample,
+ numberOfPhases: number,
+ connectorStatus: ConnectorStatus
+): number | undefined => {
+ const family = phaseFamily(phase)
+ switch (measurand) {
+ case MeterValueMeasurand.CURRENT_IMPORT:
+ if (family === 'LineToLine') return undefined
+ if (family === 'Neutral') return 0
+ return sample.currentA
+ case MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER: {
+ if (family === 'LineToLine' || family === 'Neutral') return undefined
+ const register = Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0)
+ if (family === 'LineToNeutral') {
+ if (numberOfPhases <= 0) return undefined
+ return register / numberOfPhases
+ }
+ return register
+ }
+ case MeterValueMeasurand.POWER_ACTIVE_IMPORT:
+ if (family === 'LineToLine' || family === 'Neutral') return undefined
+ if (family === 'LineToNeutral') {
+ if (numberOfPhases <= 0) return undefined
+ return sample.powerW / numberOfPhases
+ }
+ return sample.powerW
+ case MeterValueMeasurand.STATE_OF_CHARGE:
+ if (family !== 'Aggregate') return undefined
+ return sample.socPercent
+ case MeterValueMeasurand.VOLTAGE:
+ if (family === 'Neutral') return 0
+ if (family === 'LineToLine') {
+ if (numberOfPhases <= 1) return undefined
+ return Math.sqrt(numberOfPhases) * sample.voltageV
+ }
+ return sample.voltageV
+ default:
+ return undefined
+ }
+}
+
+/**
+ * Measurand → matching kilo-prefixed unit lookup. Populated only for the
+ * measurands whose `SampledValueTemplate.unit` may legitimately carry a
+ * kilo-scaled value (kW / kWh). Any other `(measurand, unit)` pair
+ * emits at unit scale (divider = 1).
+ */
+const KILO_UNIT_BY_MEASURAND: ReadonlyMap<MeterValueMeasurand, MeterValueUnit> = new Map<
+ MeterValueMeasurand,
+ MeterValueUnit
+>([
+ [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, MeterValueUnit.KILO_WATT_HOUR],
+ [MeterValueMeasurand.POWER_ACTIVE_IMPORT, MeterValueUnit.KILO_WATT],
+])
+
+/**
+ * Returns the unit divider for a `(measurand, unit)` pair: the kilo divider
+ * when the template's unit is the kilo-prefixed variant of the measurand's
+ * base unit (kW for Power, kWh for Energy register), otherwise 1.
+ * @param measurand - Target measurand.
+ * @param unit - Template unit (may be `undefined`).
+ * @returns `Constants.UNIT_DIVIDER_KILO` or `1`.
+ */
+const resolveUnitDivider = (
+ measurand: MeterValueMeasurand,
+ unit: MeterValueUnit | undefined
+): number =>
+ unit != null && KILO_UNIT_BY_MEASURAND.get(measurand) === unit ? Constants.UNIT_DIVIDER_KILO : 1
+
+/**
+ * Returns the SampledValueTemplate array configured on the given connector.
+ *
+ * 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 and is tracked as a follow-up in issue #1936.
+ * @param context - Charging-station context.
+ * @param connectorId - Connector identifier.
+ * @returns Templates or `undefined`.
+ */
+const resolveTemplates = (
+ context: ICoherentContext,
+ connectorId: number
+): SampledValueTemplate[] | undefined => {
+ return context.getConnectorStatus(connectorId)?.MeterValues
+}
+
+/**
+ * Builds a complete OCPP {@link MeterValue} from a coherent sample.
+ *
+ * Emission order:
+ * - Across measurands: `SoC → Voltage → Power → Current → Energy`.
+ * - Within a measurand with multiple phase-qualified templates: no-phase
+ * first, then `L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N`.
+ *
+ * Per-phase resolution — see {@link resolvePhasedValue}. Unsupported
+ * `(measurand, phase)` combinations are logged and skipped.
+ *
+ * Only measurands enabled by the caller-resolved allow-list are emitted.
+ * The energy register is advanced unconditionally by
+ * {@link advanceEnergyRegister} independent of whether the Energy
+ * measurand is emitted.
+ * @param context - Charging-station context.
+ * @param transactionId - Active transaction identifier.
+ * @param buildVersionedSampledValue - Versioned SampledValue builder from
+ * the OCPP dispatcher in `OCPPServiceUtils.buildMeterValue`.
+ * @param options - Per-sample parameters (interval, seed material, timestamp).
+ * @param mvContext - Optional MeterValue reading context.
+ * @param enabledMeasurands - Optional allow-list resolved from the
+ * version-appropriate OCPP variable at the `buildMeterValue` boundary.
+ * 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`.
+ * @returns MeterValue with sampled values and current timestamp.
+ */
+export const buildCoherentMeterValue = (
+ context: ICoherentContext,
+ transactionId: number | string,
+ buildVersionedSampledValue: BuildVersionedSampledValue,
+ options: ComputeSampleOptions,
+ mvContext?: MeterValueContext,
+ enabledMeasurands?: ReadonlySet<MeterValueMeasurand>
+): MeterValue => {
+ const session = context.getCoherentSession(transactionId)
+ const connectorStatus =
+ session != null ? context.getConnectorStatus(session.connectorId) : undefined
+ if (session == null || connectorStatus == null) {
+ logger.warn(
+ `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: missing session or connector for transaction ${String(transactionId)}`
+ )
+ return { sampledValue: [], timestamp: new Date() }
+ }
+
+ const sample = computeCoherentSample(context, connectorStatus, session, options)
+ // Own the register update: happens once per sample, unconditionally, so
+ // meterStop is correct even when Energy.Active.Import.Register is not in
+ // the configured MeterValues.
+ advanceEnergyRegister(connectorStatus, sample.deltaEnergyWh)
+
+ const templates = resolveTemplates(context, session.connectorId)
+ const groups = groupTemplatesByMeasurand(templates)
+ const sampledValue: SampledValue[] = []
+ const isEnabled = (measurand: MeterValueMeasurand): boolean =>
+ enabledMeasurands == null || enabledMeasurands.has(measurand)
+ const numberOfPhases = session.numberOfPhases
+
+ for (const measurand of MEASURAND_EMIT_ORDER) {
+ if (!isEnabled(measurand)) continue
+ const bucket = groups.get(measurand)
+ if (bucket == null) continue
+ for (const template of bucket) {
+ const raw = resolvePhasedValue(
+ measurand,
+ template.phase,
+ sample,
+ numberOfPhases,
+ connectorStatus
+ )
+ if (raw == null) {
+ logger.warn(
+ `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: unsupported (${measurand}, phase=${String(template.phase)}) — template skipped`
+ )
+ continue
+ }
+ // Narrow the OCPP 2.0 `SampledValueTemplate.unit` open-string branch
+ // to the closed `MeterValueUnit` union for the Map lookup below; any
+ // string outside the enum returns `undefined` from the Map and falls
+ // through to divider = 1 (unit-scale emission).
+ const unitDivider = resolveUnitDivider(measurand, template.unit as MeterValueUnit | undefined)
+ const scaled = roundTo(raw / unitDivider, ROUNDING_SCALE)
+ sampledValue.push(buildVersionedSampledValue(template, scaled, mvContext))
+ }
+ }
+
+ // MeterValue = OCPP16MeterValue | OCPP20MeterValue is a discriminated
+ // union that diverges on the SampledValue.context enum. Coherent path
+ // produces version-appropriate SampledValues via the injected
+ // buildVersionedSampledValue callback, but the compile-time union of
+ // SampledValue[] cannot be narrowed here — a boundary cast is required.
+ return { sampledValue, timestamp: new Date() } as MeterValue
+}
+
+/**
+ * Options for {@link createCoherentSession}.
+ */
+export interface CreateSessionOptions {
+ /** Target connector id. */
+ connectorId: number
+ /** Session start timestamp in milliseconds. Defaults to `Date.now()`. */
+ now?: number
+ /** Non-empty EV profile pool; one profile is picked via seeded weighted random selection. */
+ profiles: EvProfile[]
+ /**
+ * Optional ramp-up duration in milliseconds. Defaults to
+ * `Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS`.
+ */
+ rampUpDurationMs?: number
+ /** Root 32-bit seed for stream splitting. */
+ rootSeed: number
+ /** Transaction identifier. */
+ transactionId: number | string
+}
+
+/**
+ * Builds a {@link CoherentSession} deterministically from the profile pool
+ * and per-transaction seed material. Weight-based profile selection uses a
+ * dedicated `'PROFILE_PICK'` stream and initial SoC uses `'INITIAL_SOC'`,
+ * so adding one consumer does not shift any other stream's sequence
+ * (stream-splitting via FNV-1a label hashing — see `deriveSeed` in `Prng.ts`).
+ *
+ * The nominal AC voltage is treated as phase voltage (line-to-neutral) per
+ * {@link ../../utils/ElectricUtils.ACElectricUtils}. If the station is AC
+ * and `voltageOut` is 400 V or 800 V, a warning is logged since those
+ * values are line-to-line in most catalogs and the utilities would compute
+ * physically implausible power.
+ * @param context - Charging-station context.
+ * @param options - Session parameters.
+ * @returns Fully initialized session, or `undefined` when profiles is empty.
+ */
+export const createCoherentSession = (
+ context: ICoherentContext,
+ options: CreateSessionOptions
+): CoherentSession | undefined => {
+ if (options.profiles.length === 0) {
+ return undefined
+ }
+ const now = options.now ?? Date.now()
+ const profilePickPrng = createStreamPrng(options.rootSeed, options.transactionId, 'PROFILE_PICK')
+ const socPrng = createStreamPrng(options.rootSeed, options.transactionId, 'INITIAL_SOC')
+ const profile = selectEvProfile(options.profiles, profilePickPrng())
+ const socRange = Math.max(0, profile.initialSocPercentMax - profile.initialSocPercentMin)
+ const initialSoc = profile.initialSocPercentMin + socPrng() * socRange
+
+ const currentType = context.stationInfo?.currentOutType ?? CurrentType.AC
+ const voltageOutNominal = context.getVoltageOut()
+ if (
+ currentType === CurrentType.AC &&
+ (voltageOutNominal === Voltage.VOLTAGE_400 || voltageOutNominal === Voltage.VOLTAGE_800)
+ ) {
+ logger.warn(
+ `${context.logPrefix()} ${moduleName}.createCoherentSession: AC voltageOut=${voltageOutNominal.toString()}V is treated as line-to-neutral (phase voltage) by ACElectricUtils. If this value is meant as line-to-line, coherent power/current will be physically implausible.`
+ )
+ }
+
+ return {
+ connectorId: options.connectorId,
+ currentType,
+ numberOfPhases: currentType === CurrentType.AC ? context.getNumberOfPhases() : 1,
+ profile,
+ rampUpDurationMs: options.rampUpDurationMs ?? Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS,
+ sessionStartMs: now,
+ socPercent: initialSoc,
+ transactionId: options.transactionId,
+ voltageOutNominal,
+ }
+}
+
+/**
+ * Resolves the root PRNG seed for a station. Prefers the template
+ * `randomSeed` and falls back to a FNV-1a hash of `hashId`, ensuring
+ * determinism across stations without accidentally sharing streams.
+ * @param stationInfo - Station info (`randomSeed` and `hashId`).
+ * @returns 32-bit unsigned root seed.
+ */
+export const resolveRootSeed = (
+ stationInfo: undefined | { hashId?: string; randomSeed?: number }
+): number => {
+ if (stationInfo?.randomSeed != null && Number.isFinite(stationInfo.randomSeed)) {
+ return stationInfo.randomSeed >>> 0
+ }
+ return hashLabel(stationInfo?.hashId ?? '')
+}
--- /dev/null
+// Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+/**
+ * @file EV profile file loader and selection helpers.
+ * @description Loads and validates the on-disk `evProfilesFile` referenced
+ * by a station template. Loading is fail-soft: malformed or missing files
+ * return `undefined` so the caller disables coherent generation for that
+ * station rather than crashing station startup.
+ */
+
+import { readFileSync } from 'node:fs'
+import { z } from 'zod'
+
+import { BaseError } from '../../exception/index.js'
+import { getErrorMessage, logger } from '../../utils/index.js'
+import { type EvProfile, type EvProfilesFile, EvProfilesFileSchema } from './types.js'
+
+const moduleName = 'EvProfiles'
+
+/**
+ * Loads, parses, validates, and normalizes an EV profile file. Sorting the
+ * charging curve by `socPercent` in-place makes downstream interpolation
+ * assume a monotone x-axis without repeating the sort at every sample.
+ *
+ * Fail-soft: any error (missing file, invalid JSON, schema violation) is
+ * logged and returns `undefined`.
+ * @param filePath - Absolute path to the EV profile JSON file.
+ * @param logPrefix - Log prefix for warnings; typically `chargingStation.logPrefix()`.
+ * @returns Parsed {@link EvProfilesFile} or `undefined` on failure.
+ */
+export const loadEvProfilesFile = (
+ filePath: string,
+ logPrefix: string
+): EvProfilesFile | undefined => {
+ try {
+ const raw = readFileSync(filePath, 'utf8')
+ const json = JSON.parse(raw) as unknown
+ const parsed = EvProfilesFileSchema.parse(json)
+ for (const profile of parsed.profiles) {
+ profile.chargingCurve.sort((a, b) => a.socPercent - b.socPercent)
+ if (profile.initialSocPercentMin > profile.initialSocPercentMax) {
+ logger.warn(
+ `${logPrefix} ${moduleName}.loadEvProfilesFile: profile '${profile.id}' has initialSocPercentMin > initialSocPercentMax, swapping bounds`
+ )
+ const tmp = profile.initialSocPercentMin
+ profile.initialSocPercentMin = profile.initialSocPercentMax
+ profile.initialSocPercentMax = tmp
+ }
+ }
+ return parsed
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ logger.warn(
+ `${logPrefix} ${moduleName}.loadEvProfilesFile: EV profile file '${filePath}' failed validation: ${getErrorMessage(error)}`
+ )
+ } else {
+ logger.warn(
+ `${logPrefix} ${moduleName}.loadEvProfilesFile: EV profile file '${filePath}' could not be loaded: ${getErrorMessage(error)}`
+ )
+ }
+ return undefined
+ }
+}
+
+/**
+ * Weight-based profile selection. Chooses a profile from `profiles` using
+ * the supplied random number in [0, 1). If total weight is zero, falls back
+ * to the first profile.
+ * @param profiles - Non-empty array of EV profiles.
+ * @param random - Uniform float in [0, 1) (typically from a seeded stream).
+ * @returns Selected profile.
+ */
+export const selectEvProfile = (profiles: EvProfile[], random: number): EvProfile => {
+ const totalWeight = profiles.reduce((sum, profile) => sum + profile.weight, 0)
+ if (totalWeight <= 0) {
+ return profiles[0]
+ }
+ const target = random * totalWeight
+ let cumulative = 0
+ for (const profile of profiles) {
+ cumulative += profile.weight
+ if (target < cumulative) {
+ return profile
+ }
+ }
+ return profiles[profiles.length - 1]
+}
+
+/**
+ * Piecewise-linear interpolation of `chargingCurve` at `socPercent`. The
+ * curve must be sorted non-decreasing by `socPercent` (`loadEvProfilesFile`
+ * guarantees this). At an interior curve node the left segment's endpoint
+ * is returned (equivalent for continuous curves; matters only for duplicate
+ * `socPercent` where `a.powerFraction` wins).
+ * @param curve - Sorted-by-`socPercent` curve.
+ * @param socPercent - Query point in [0, 100].
+ * @returns `powerFraction` in [0, 1] (clamped to the endpoints outside the
+ * curve range).
+ * @throws {BaseError} In non-production environments when `curve` is not
+ * sorted non-decreasing by `socPercent`. Production path is
+ * `loadEvProfilesFile` which sorts in place; test seams
+ * (`__injectCoherentSession`) that bypass Zod are the only reachable
+ * source of an unsorted curve.
+ */
+export const interpolateChargingCurve = (
+ curve: { powerFraction: number; socPercent: number }[],
+ socPercent: number
+): number => {
+ if (curve.length === 0) {
+ return 1
+ }
+ if (process.env.NODE_ENV !== 'production') {
+ for (let i = 1; i < curve.length; i++) {
+ if (curve[i].socPercent < curve[i - 1].socPercent) {
+ throw new BaseError(
+ `interpolateChargingCurve: chargingCurve must be sorted non-decreasing by socPercent (index ${i.toString()})`
+ )
+ }
+ }
+ }
+ if (socPercent <= curve[0].socPercent) {
+ return curve[0].powerFraction
+ }
+ if (socPercent >= curve[curve.length - 1].socPercent) {
+ return curve[curve.length - 1].powerFraction
+ }
+ for (let index = 0; index < curve.length - 1; index++) {
+ const a = curve[index]
+ const b = curve[index + 1]
+ if (socPercent >= a.socPercent && socPercent <= b.socPercent) {
+ const span = b.socPercent - a.socPercent
+ if (span === 0) {
+ return a.powerFraction
+ }
+ const t = (socPercent - a.socPercent) / span
+ return a.powerFraction + t * (b.powerFraction - a.powerFraction)
+ }
+ }
+ return curve[curve.length - 1].powerFraction
+}
--- /dev/null
+// Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+/**
+ * @file Deterministic seeded PRNG for coherent MeterValues.
+ * @description Mulberry32 core PRNG with FNV-1a label hashing for
+ * independent per-stream seed derivation. No runtime dependency; kept
+ * intentionally small.
+ *
+ * Stream splitting: `deriveSeed(rootSeed, label)` XORs a stable FNV-1a
+ * 32-bit hash of the label into the root seed so adding one consumer
+ * (e.g. a new `POWER_NOISE` stream) does not shift any other stream's
+ * sequence.
+ */
+
+/**
+ * Mulberry32 PRNG. Returns a function producing uniform floats in [0, 1).
+ * Same seed twice ⇒ identical infinite sequence.
+ * @param seed - 32-bit unsigned integer seed.
+ * @returns Function `() => number` producing the next float in [0, 1).
+ */
+export const mulberry32 = (seed: number): (() => number) => {
+ let state = seed >>> 0
+ return () => {
+ state = (state + 0x6d2b79f5) >>> 0
+ let t = state
+ t = Math.imul(t ^ (t >>> 15), t | 1)
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ }
+}
+
+/**
+ * Stable 32-bit FNV-1a hash of a UTF-16 string. Used to derive per-stream
+ * seed offsets so labelled streams stay independent.
+ * @param label - Stream label (e.g. `'VOLTAGE_NOISE'`).
+ * @returns Unsigned 32-bit hash.
+ */
+export const hashLabel = (label: string): number => {
+ let hash = 0x811c9dc5
+ for (let i = 0; i < label.length; i++) {
+ hash ^= label.charCodeAt(i)
+ hash = Math.imul(hash, 0x01000193) >>> 0
+ }
+ return hash >>> 0
+}
+
+/**
+ * Derive a per-stream seed from a root seed and a stable label.
+ * The XOR mix keeps `deriveSeed(root, 'A') !== deriveSeed(root, 'B')`
+ * as long as `hashLabel('A') !== hashLabel('B')`, and adding a new
+ * consumer never shifts an existing stream's sequence.
+ *
+ * Chained derivations reduce to `deriveSeed(deriveSeed(r, x), y) = r ^ H(x) ^ H(y)`,
+ * so two chains collide when `H(x1) ^ H(y1) === H(x2) ^ H(y2)`. Birthday
+ * bound on the 32-bit hash space is negligible at simulator scale
+ * (expected collisions ≈ N²/2^33; ≈ 0.3 at N = 5×10⁴). The deterministic
+ * self-inverse `H(x) ^ H(x) === 0` is neutralized by
+ * {@link ./CoherentMeterValuesGenerator.createStreamPrng} namespacing the
+ * transactionId leg with a `tx:` prefix labels never carry.
+ * @param rootSeed - Root 32-bit seed.
+ * @param label - Stable stream label.
+ * @returns Derived 32-bit unsigned seed.
+ */
+export const deriveSeed = (rootSeed: number, label: string): number => {
+ return ((rootSeed >>> 0) ^ hashLabel(label)) >>> 0
+}
--- /dev/null
+// Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+/**
+ * @file Coherent MeterValues module barrel.
+ * @description Physics-based coherent MeterValues generation for the
+ * OCPP simulator. Opt-in via the template flag `coherentMeterValues`;
+ * when disabled or absent, the random/fixed measurand generation is used
+ * unchanged (see `../ocpp/OCPPServiceUtils.ts`).
+ *
+ * Internal helpers are intentionally not re-exported; tests and internal
+ * callers should import them directly from the owning sub-module.
+ */
+
+export {
+ buildCoherentMeterValue,
+ createCoherentSession,
+ isCoherentModeActive,
+ resolveRootSeed,
+} from './CoherentMeterValuesGenerator.js'
+export type { BuildVersionedSampledValue } from './CoherentMeterValuesGenerator.js'
+export { loadEvProfilesFile } from './EvProfiles.js'
+export type {
+ ChargingCurvePoint,
+ CoherentSession,
+ EvProfile,
+ EvProfilesFile,
+ ICoherentContext,
+} from './types.js'
--- /dev/null
+// Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+/**
+ * @file Coherent MeterValues shared types and validation schemas.
+ * @description Types and Zod schemas for physics-based coherent MeterValues.
+ * The `ICoherentContext` interface is a minimal structural subset of
+ * {@link ../ChargingStation.ChargingStation}, keeping the coherent
+ * generator decoupled from the full class.
+ */
+
+import { z } from 'zod'
+
+import type {
+ ChargingStationInfo,
+ ConnectorStatus,
+ CurrentType,
+ Voltage,
+} from '../../types/index.js'
+
+/**
+ * A single point on the piecewise-linear charging power curve.
+ * `powerFraction` is the fraction of `maxPowerW` accepted at `socPercent`.
+ */
+export interface ChargingCurvePoint {
+ powerFraction: number
+ socPercent: number
+}
+
+/**
+ * A weighted EV profile. `weight` biases random per-transaction selection.
+ * `batteryCapacityWh` bounds ΔSoC per ΔE. `maxPowerW` bounds acceptance.
+ * `chargingCurve` is a sorted-by-`socPercent` piecewise-linear taper.
+ */
+export interface EvProfile {
+ batteryCapacityWh: number
+ chargingCurve: ChargingCurvePoint[]
+ id: string
+ initialSocPercentMax: number
+ initialSocPercentMin: number
+ maxPowerW: number
+ weight: number
+}
+
+/**
+ * On-disk EV profiles file schema (mirrors `evProfilesFile` template field).
+ */
+export interface EvProfilesFile {
+ profiles: EvProfile[]
+}
+
+/**
+ * Zod schema for {@link ChargingCurvePoint}. `socPercent` in [0, 100],
+ * `powerFraction` in [0, 1].
+ */
+export const ChargingCurvePointSchema = z.object({
+ powerFraction: z.number().min(0).max(1),
+ socPercent: z.number().min(0).max(100),
+})
+
+/**
+ * Zod schema for {@link EvProfile}. `chargingCurve` must be non-empty; the
+ * on-disk loader (`loadEvProfilesFile`) sorts by `socPercent` in-place
+ * after parse. Programmatic constructors that bypass the loader (e.g.
+ * `__injectCoherentSession` in tests) are responsible for providing a
+ * sorted curve — `interpolateChargingCurve` assumes a monotone x-axis
+ * to bracket in O(n) without repeated sorts.
+ *
+ * Monotone-non-increasing `powerFraction` (physical taper) is a caller
+ * responsibility and is NOT enforced by the schema: real EV curves
+ * typically hold `powerFraction` flat at 1.0 through the CC region before
+ * tapering, so strict monotonicity would over-constrain valid profiles.
+ * Callers requiring a monotone taper should validate at load time.
+ */
+export const EvProfileSchema = z.object({
+ batteryCapacityWh: z.number().positive(),
+ chargingCurve: z.array(ChargingCurvePointSchema).min(1),
+ id: z.string().min(1),
+ initialSocPercentMax: z.number().min(0).max(100),
+ initialSocPercentMin: z.number().min(0).max(100),
+ maxPowerW: z.number().positive(),
+ weight: z.number().nonnegative(),
+})
+
+/**
+ * Zod schema for {@link EvProfilesFile}. `profiles` must be non-empty.
+ */
+export const EvProfilesFileSchema = z.object({
+ profiles: z.array(EvProfileSchema).min(1),
+})
+
+/**
+ * A per-transaction coherent session. Created after StartTransaction is
+ * accepted (or forced), destroyed at every reset/stop/disconnect path.
+ * `sessionStartMs` is immutable so SetChargingProfile cannot reset the ramp.
+ * All non-mutable fields are `readonly` to enforce write-once semantics
+ * outside {@link createCoherentSession}; only `socPercent` is mutated
+ * (once per sample, monotone non-decreasing per INV-2).
+ */
+export interface CoherentSession {
+ readonly connectorId: number
+ readonly currentType: CurrentType
+ readonly numberOfPhases: number
+ readonly profile: EvProfile
+ readonly rampUpDurationMs: number
+ readonly sessionStartMs: number
+ socPercent: number
+ readonly transactionId: number | string
+ readonly voltageOutNominal: Voltage
+}
+
+/**
+ * Minimal structural interface consumed by the coherent generator. Only
+ * fields/methods actually used are declared to break any potential type
+ * cycle back to `ChargingStation`.
+ */
+export interface ICoherentContext {
+ getCoherentSession: (transactionId: number | string) => CoherentSession | undefined
+ getConnectorMaximumAvailablePower: (connectorId: number) => number
+ getConnectorStatus: (connectorId: number) => ConnectorStatus | undefined
+ getNumberOfPhases: () => number
+ getVoltageOut: () => Voltage
+ logPrefix: () => string
+ stationInfo?: ChargingStationInfo
+}
ReservationTerminationReason,
type ResponseHandler,
} from '../../../types/index.js'
-import { Constants, convertToInt, logger, sleep, truncateId } from '../../../utils/index.js'
+import {
+ Constants,
+ convertToInt,
+ isNotEmptyArray,
+ logger,
+ sleep,
+ truncateId,
+} from '../../../utils/index.js'
import {
restoreConnectorStatus,
sendAndSetConnectorStatus,
}
const finalizeTransactionConnectorStatus = (
+ chargingStation: ChargingStation,
connectorStatus: ConnectorStatus | undefined,
requestPayload: OCPP16StopTransactionRequest
): string | undefined => {
const transactionIdTag = requestPayload.idTag ?? connectorStatus?.transactionIdTag
+ // resetConnectorStatus deletes transactionId (Helpers.ts:508). Destroy the
+ // coherent session using requestPayload.transactionId, which is always
+ // present in the StopTransaction request and unaffected by the reset.
resetConnectorStatus(connectorStatus)
+ chargingStation.destroyCoherentSession(requestPayload.transactionId)
if (connectorStatus != null) {
connectorStatus.locked = false
}
connectorStatus.transactionIdTag = requestPayload.idTag
connectorStatus.transactionEnergyActiveImportRegisterValue = 0
connectorStatus.locked = true
+ // Session must exist before buildTransactionBeginMeterValue so the
+ // coherent-mode branch inside it can route through buildMeterValue.
+ // No-op when coherent mode is off or the EV profile pool is absent.
+ chargingStation.createCoherentSession(payload.transactionId, connectorId)
connectorStatus.transactionBeginMeterValue =
OCPP16ServiceUtils.buildTransactionBeginMeterValue(
chargingStation,
)
}
}
- chargingStation.stationInfo?.beginEndMeterValues === true &&
- (await chargingStation.ocppRequestService.requestHandler<
+ // Whitepaper §3.3.4 composition: send only when the built begin
+ // MeterValue carries at least one SampledValue. When
+ // `StartTxnSampledData` is set, `buildTransactionBeginMeterValue`
+ // uses it; when absent it falls back to `MeterValuesSampledData`.
+ // Both empty ⇒ no send (avoids empty sampledValue array in
+ // `MeterValues.req`).
+ if (
+ chargingStation.stationInfo?.beginEndMeterValues === true &&
+ isNotEmptyArray(connectorStatus.transactionBeginMeterValue.sampledValue)
+ ) {
+ await chargingStation.ocppRequestService.requestHandler<
OCPP16MeterValuesRequest,
OCPP16MeterValuesResponse
>(chargingStation, OCPP16RequestCommand.METER_VALUES, {
connectorId,
meterValue: [connectorStatus.transactionBeginMeterValue],
transactionId: payload.transactionId,
- } satisfies OCPP16MeterValuesRequest))
+ } satisfies OCPP16MeterValuesRequest)
+ }
await sendAndSetConnectorStatus(chargingStation, {
connectorId,
status: OCPP16ChargePointStatus.Charging,
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseStopTransaction: Trying to stop a non-existent transaction with id ${requestPayload.transactionId.toString()}`
)
+ // Destroy any lingering coherent session on this transaction so the
+ // Map does not leak entries when the connector-side state has already
+ // been reset.
+ chargingStation.destroyCoherentSession(requestPayload.transactionId)
return
}
- chargingStation.stationInfo?.beginEndMeterValues === true &&
+ if (
+ chargingStation.stationInfo?.beginEndMeterValues === true &&
chargingStation.stationInfo.ocppStrictCompliance === false &&
- chargingStation.stationInfo.outOfOrderEndMeterValues === true &&
- (await chargingStation.ocppRequestService.requestHandler<
- OCPP16MeterValuesRequest,
- OCPP16MeterValuesResponse
- >(chargingStation, OCPP16RequestCommand.METER_VALUES, {
- connectorId: transactionConnectorId,
- meterValue: [
- OCPP16ServiceUtils.buildTransactionEndMeterValue(
- chargingStation,
- transactionConnectorId,
- requestPayload.meterStop
- ),
- ],
- transactionId: requestPayload.transactionId,
- }))
+ chargingStation.stationInfo.outOfOrderEndMeterValues === true
+ ) {
+ const endMeterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(
+ chargingStation,
+ transactionConnectorId,
+ requestPayload.meterStop
+ )
+ if (isNotEmptyArray(endMeterValue.sampledValue)) {
+ await chargingStation.ocppRequestService.requestHandler<
+ OCPP16MeterValuesRequest,
+ OCPP16MeterValuesResponse
+ >(chargingStation, OCPP16RequestCommand.METER_VALUES, {
+ connectorId: transactionConnectorId,
+ meterValue: [endMeterValue],
+ transactionId: requestPayload.transactionId,
+ })
+ }
+ }
const postTransactionDelay = chargingStation.stationInfo?.postTransactionDelay ?? 0
let transactionIdTag: string | undefined
if (postTransactionDelay > 0) {
if (transactionConnectorStatus != null) {
delete transactionConnectorStatus.transactionId
}
+ // Destroy the coherent session BEFORE sleeping so an intervening
+ // stop cannot leak it. `destroyCoherentSession` is idempotent so a
+ // subsequent call from `finalizeTransactionConnectorStatus` post-sleep
+ // is a no-op.
+ chargingStation.destroyCoherentSession(requestPayload.transactionId)
await sleep(secondsToMilliseconds(postTransactionDelay))
if (!chargingStation.started) {
return
}
transactionIdTag = finalizeTransactionConnectorStatus(
+ chargingStation,
transactionConnectorStatus,
requestPayload
)
decrementPowerDivider(chargingStation)
const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId)
transactionIdTag = finalizeTransactionConnectorStatus(
+ chargingStation,
transactionConnectorStatus,
requestPayload
)
): Promise<void> {
OCPP16ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId)
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ // Snapshot transactionId BEFORE resetConnectorStatus deletes it.
+ const txId = connectorStatus?.transactionId
resetConnectorStatus(connectorStatus)
+ chargingStation.destroyCoherentSession(txId)
await restoreConnectorStatus(chargingStation, connectorId, connectorStatus)
}
}
roundTo,
truncateId,
} from '../../../utils/index.js'
+import { isCoherentModeActive } from '../../meter-values/index.js'
import { mapOCPP16Status, OCPPAuthServiceFactory } from '../auth/index.js'
import { sendAndSetConnectorStatus } from '../OCPPConnectorStatusOperations.js'
import {
connectorId: number,
meterStart: number | undefined
): OCPP16MeterValue {
+ // Coherent path: when an active coherent session exists for this
+ // transaction, route through `buildMeterValue` so the begin MeterValue
+ // is drawn from the same physics chain as subsequent samples (SoC in
+ // the profile's initial band, energy=0, V=nominal, P=I=0). Vendor
+ // parameter `StartTxnSampledData` (per OCPP 1.6 Signed Meter Values
+ // whitepaper) overrides `MeterValuesSampledData` for this MeterValue
+ // when configured; `resolveEnabledMeasurands` falls back to
+ // `MeterValuesSampledData` when the vendor key is absent.
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const transactionId = connectorStatus?.transactionId
+ if (transactionId != null && isCoherentModeActive(chargingStation, transactionId)) {
+ const startTxnSampledDataKey = OCPP16VendorParametersKey.StartTxnSampledData
+ const measurandsKey =
+ getConfigurationKey(chargingStation, startTxnSampledDataKey)?.value != null
+ ? startTxnSampledDataKey
+ : undefined
+ return buildMeterValue(
+ chargingStation,
+ transactionId,
+ 0,
+ measurandsKey,
+ OCPP16MeterValueContext.TRANSACTION_BEGIN
+ ) as OCPP16MeterValue
+ }
const meterValue = buildEmptyMeterValue() as OCPP16MeterValue
// Energy.Active.Import.Register measurand (default)
const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId)
type OCPP20NotifyReportResponse,
OCPP20OperationalStatusEnumType,
OCPP20OptionalVariableName,
+ OCPP20ReadingContextEnumType,
OCPP20ReasonEnumType,
OCPP20RequestCommand,
type OCPP20RequestStartTransactionRequest,
if (response.status === RequestStartStopStatusEnumType.Accepted) {
const connectorId = chargingStation.getConnectorIdByTransactionId(response.transactionId)
if (connectorId != null && response.transactionId != null) {
+ const txId = response.transactionId
+ chargingStation.createCoherentSession(txId, connectorId)
const startedMeterValues = OCPP20ServiceUtils.buildTransactionStartedMeterValues(
chargingStation,
- response.transactionId
+ txId
)
OCPP20ServiceUtils.sendTransactionEvent(
chargingStation,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.RemoteStart,
connectorId,
- response.transactionId,
+ txId,
{
...(isNotEmptyArray(startedMeterValues) && { meterValue: startedMeterValues }),
remoteStartId: request.remoteStartId,
}
).catch((error: unknown) => {
+ // Session was created for this fire-and-forget send. If the
+ // send rejects, destroy it here — no other cleanup path
+ // covers this branch. `destroyCoherentSession` is idempotent.
+ chargingStation.destroyCoherentSession(txId)
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.constructor: TransactionEvent(Started) error:`,
error
const meterValue = buildMeterValue(
chargingStation,
connector.transactionId,
- txUpdatedInterval
+ txUpdatedInterval,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ ),
+ OCPP20ReadingContextEnumType.TRIGGER
) as OCPP20MeterValue
+ // OCPP 2.0.1 MeterValueType.sampledValue cardinality is 1..*, while
+ // TransactionEventRequest.meterValue is 0..*: when TxUpdatedMeasurands
+ // yields no sampled values, omit the meterValue field entirely rather
+ // than send an empty-wrapper schema violation.
+ const eventPayload = isNotEmptyArray(meterValue.sampledValue)
+ ? { meterValue: [meterValue] }
+ : {}
OCPP20ServiceUtils.sendTransactionEvent(
chargingStation,
OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.Trigger,
cId,
connector.transactionId as string,
- { meterValue: [meterValue] }
+ eventPayload
).catch(errorHandler)
}
}
): Promise<void> {
OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId)
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ // Snapshot transactionId BEFORE resetConnectorStatus deletes it.
+ const txId = connectorStatus?.transactionId
resetConnectorStatus(connectorStatus)
+ chargingStation.destroyCoherentSession(txId)
await restoreConnectorStatus(chargingStation, connectorId, connectorStatus)
}
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} ENDED on connector ${connectorId.toString()}`
)
+ } else {
+ // connectorId is unknown (e.g. connector state already reset before
+ // the CSMS response arrived). Destroy any lingering coherent session
+ // so the Map does not leak entries — symmetric with the OCPP 1.6
+ // defensive destroy in handleResponseStopTransaction.
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Ending transaction ${requestPayload.transactionInfo.transactionId} on unknown connector — connector state already reset`
+ )
+ chargingStation.destroyCoherentSession(requestPayload.transactionInfo.transactionId)
}
break
case OCPP20TransactionEventEnumType.Started:
)
const txEndedInterval = OCPP20ServiceUtils.getTxEndedInterval(chargingStation)
OCPP20ServiceUtils.startEndedMeterValues(chargingStation, connectorId, txEndedInterval)
+ // Create coherent MeterValues session after transactionId is known.
+ // No-op when the feature flag or the EV profile file is not
+ // configured (see ChargingStation.createCoherentSession).
+ chargingStation.createCoherentSession(
+ requestPayload.transactionInfo.transactionId,
+ connectorId
+ )
}
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} STARTED on connector ${String(connectorId)}`
) {
return
}
+ // Snapshot transactionId BEFORE any mutation below deletes it, so the
+ // coherent session (if any) can still be destroyed after the reset.
+ const txId = connectorStatus.transactionId
OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId)
const postTransactionDelay = chargingStation.stationInfo?.postTransactionDelay ?? 0
if (postTransactionDelay > 0) {
delete connectorStatus.transactionId
+ // Destroy the coherent session BEFORE sleeping so an intervening
+ // stop cannot leak it. `destroyCoherentSession` is idempotent so the
+ // post-sleep call remains valid.
+ chargingStation.destroyCoherentSession(txId)
await sleep(secondsToMilliseconds(postTransactionDelay))
if (!chargingStation.started) {
return
}
}
resetConnectorStatus(connectorStatus)
+ chargingStation.destroyCoherentSession(txId)
connectorStatus.locked = false
await sendPostTransactionStatus(chargingStation, connectorId)
}
}
OCPP20ServiceUtils.resetTransactionSequenceNumber(chargingStation, connectorId)
}
+ // Create coherent session BEFORE building the Transaction.Started MeterValue
+ // so the coherent gate in `buildMeterValue` runs against a live session
+ // (E02.FR.09 measurands from a physics-consistent initial state).
+ // Idempotent — a duplicate call from the response handler is a no-op.
+ chargingStation.createCoherentSession(transactionId, connectorId)
const startedMeterValues = OCPP20ServiceUtils.buildTransactionStartedMeterValues(
chargingStation,
transactionId
if (isNotEmptyArray(startedMeterValues) && connectorStatus != null) {
connectorStatus.transactionBeginMeterValue = startedMeterValues[0] as MeterValue
}
- const response = await OCPP20ServiceUtils.sendTransactionEvent(
- chargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
- connectorId,
- transactionId,
- {
- idToken:
- idTag != null ? { idToken: idTag, type: OCPP20IdTokenEnumType.ISO14443 } : undefined,
- ...(isNotEmptyArray(startedMeterValues) && { meterValue: startedMeterValues }),
- }
- )
+ let response
+ try {
+ response = await OCPP20ServiceUtils.sendTransactionEvent(
+ chargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ {
+ idToken:
+ idTag != null ? { idToken: idTag, type: OCPP20IdTokenEnumType.ISO14443 } : undefined,
+ ...(isNotEmptyArray(startedMeterValues) && { meterValue: startedMeterValues }),
+ }
+ )
+ } catch (error) {
+ // Symmetric with OCPP 1.6 `resetConnectorOnStartTransactionError`: if
+ // the Started TransactionEvent fails to send, the coherent session
+ // (created at line 898 for the physics-consistent begin MeterValue)
+ // must not leak. `destroyCoherentSession` is idempotent.
+ chargingStation.destroyCoherentSession(transactionId)
+ throw error
+ }
return {
accepted:
response.idTokenInfo == null ||
const meterValue = buildMeterValue(
chargingStation,
connectorStatus.transactionId,
- interval
+ interval,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ )
) as OCPP20MeterValue
+ // OCPP 2.0.1 `MeterValueType.sampledValue` cardinality is `1..*`, while
+ // `TransactionEventRequest.meterValue` is `0..*`: when `TxUpdatedMeasurands`
+ // yields no sampled values, omit the `meterValue` field entirely rather
+ // than send an empty-wrapper schema violation.
+ const eventPayload = isNotEmptyArray(meterValue.sampledValue)
+ ? { meterValue: [meterValue] }
+ : {}
OCPP20ServiceUtils.sendTransactionEvent(
chargingStation,
OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
connectorStatus.transactionId as string,
- { meterValue: [meterValue] }
+ eventPayload
).catch((error: unknown) => {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: Error sending periodic TransactionEvent:`,
min,
roundTo,
} from '../../utils/index.js'
+import {
+ buildCoherentMeterValue,
+ type BuildVersionedSampledValue,
+ isCoherentModeActive,
+ resolveRootSeed,
+} from '../meter-values/index.js'
import {
buildOCPP16BootNotificationRequest,
buildOCPP16SampledValue,
const moduleName = 'OCPPServiceUtils'
const SOC_MAXIMUM_VALUE = 100
-const UNIT_DIVIDER_KILO = 1000
-const MS_PER_HOUR = 3_600_000
const isOCPP20FlagEnabled = (
chargingStation: ChargingStation,
}
checkMeasurandPowerDivider(chargingStation, energyTemplate.measurand)
- const unitDivider = energyTemplate.unit === MeterValueUnit.KILO_WATT_HOUR ? UNIT_DIVIDER_KILO : 1
+ const unitDivider =
+ energyTemplate.unit === MeterValueUnit.KILO_WATT_HOUR ? Constants.UNIT_DIVIDER_KILO : 1
const connectorMaximumAvailablePower =
chargingStation.getConnectorMaximumAvailablePower(connectorId)
const connectorMaximumEnergyRounded = roundTo(
- (connectorMaximumAvailablePower * interval) / MS_PER_HOUR,
+ (connectorMaximumAvailablePower * interval) / Constants.MS_PER_HOUR,
2
)
const connectorMinimumEnergyRounded = roundTo(energyTemplate.minimumValue ?? 0, 2)
checkMeasurandPowerDivider(chargingStation, powerTemplate.measurand)
const powerValues: MeasurandValues = {} as MeasurandValues
- const unitDivider = powerTemplate.unit === MeterValueUnit.KILO_WATT ? UNIT_DIVIDER_KILO : 1
+ const unitDivider =
+ powerTemplate.unit === MeterValueUnit.KILO_WATT ? Constants.UNIT_DIVIDER_KILO : 1
const connectorMaximumAvailablePower =
chargingStation.getConnectorMaximumAvailablePower(connectorId)
const connectorMaximumPower = Math.round(connectorMaximumAvailablePower)
})
/**
- * Builds a complete MeterValue with all configured measurands for a transaction.
- * @param chargingStation - Target charging station
- * @param transactionId - Active transaction identifier
- * @param interval - Meter value sampling interval in milliseconds
- * @param measurandsKey - Configuration key for the sampled measurands list
- * @param context - Meter value reading context
- * @param debug - Enable debug logging for measurand validation
- * @returns Populated MeterValue object
+ * Internal dispatch bag returned by {@link createVersionedSampledValueDispatcher}
+ * and consumed by {@link buildMeterValue}. Not exported: kept out of the
+ * module surface so external callers rely on the higher-level entry point.
*/
-export const buildMeterValue = (
+interface VersionedSampledValueDispatch {
+ buildVersionedSampledValue: BuildVersionedSampledValue
+ connectorId: number
+ evseId: number | undefined
+ signingConfig: SampledValueSigningConfig | undefined
+ /**
+ * Passed by reference; the closure assigned to `buildVersionedSampledValue`
+ * mutates its `publicKeyIncluded` flag when a signed OCPP 2.0 SampledValue
+ * is emitted. Callers rely on the reference identity to detect the mutation.
+ */
+ signingState: { publicKeyIncluded: boolean }
+}
+
+/**
+ * Resolves the connector/EVSE ids and constructs the OCPP-version dispatcher
+ * used by {@link buildMeterValue}. Extracted verbatim from the original
+ * `buildMeterValue` switch so behavior is preserved exactly.
+ * @param chargingStation - Target charging station.
+ * @param transactionId - Active transaction identifier.
+ * @param context - Optional MeterValue reading context (drives signing
+ * configuration for OCPP 2.0).
+ * @returns The dispatch bundle.
+ */
+const createVersionedSampledValueDispatcher = (
chargingStation: ChargingStation,
- transactionId: number | string | undefined,
- interval: number,
- measurandsKey?: ConfigurationKeyType,
- context?: MeterValueContext,
- debug = false
-): MeterValue => {
- if (transactionId == null) {
- return buildEmptyMeterValue()
- }
+ transactionId: number | string,
+ context?: MeterValueContext
+): VersionedSampledValueDispatch => {
const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId)
let evseId: number | undefined
- let buildVersionedSampledValue: (
- sampledValueTemplate: SampledValueTemplate,
- value: number,
- context?: MeterValueContext,
- phase?: MeterValuePhase
- ) => SampledValue
+ let buildVersionedSampledValue: BuildVersionedSampledValue
let signingConfig: SampledValueSigningConfig | undefined
const signingState = { publicKeyIncluded: false }
switch (chargingStation.stationInfo?.ocppVersion) {
RequestCommand.METER_VALUES
)
}
+ return { buildVersionedSampledValue, connectorId, evseId, signingConfig, signingState }
+}
+
+// Module-scope keyed by `ChargingStation` instance (auto-collected on GC).
+// Kept off the class API to avoid touching `ChargingStation` for a
+// warn-once diagnostic; the WeakMap's semantics match the intent — one
+// bag of already-warned entries per station, freed with the station.
+const warnedInvalidMeasurands = new WeakMap<ChargingStation, Set<string>>()
+const KNOWN_MEASURANDS: ReadonlySet<string> = new Set<string>(Object.values(MeterValueMeasurand))
+
+/**
+ * Resolves the set of measurands enabled by the configured OCPP variable.
+ *
+ * Presence-aware semantics:
+ * - No key resolves ⇒ returns `undefined` (no filter — all templates emit,
+ * preserving the default behavior).
+ * - Key resolves but the configuration variable is **absent** (never
+ * written) ⇒ returns `{Energy.Active.Import.Register}` (default measurand,
+ * ergonomic parity with a station that never set the variable).
+ * - Key resolves and the configuration variable is **present** ⇒ the CSV
+ * is honored verbatim, including an explicit empty value which yields an
+ * empty allow-list (spec-compliant suppression per OCPP 2.0.1 J02.FR.11).
+ *
+ * Governs OCPP 2.0.1 J02.FR.11 (`TxUpdatedMeasurands`), E02.FR.09
+ * (`TxStartedMeasurands`), E06.FR.11 (`TxEndedMeasurands`), and OCPP 1.6
+ * `MeterValuesSampledData`.
+ * @param chargingStation - Target charging station.
+ * @param measurandsKey - Configuration key threaded from the caller. When
+ * `undefined` (or omitted), defaults to `StandardParametersKey.MeterValuesSampledData`
+ * for OCPP 1.6 stations and returns `undefined` (no filter) for all
+ * other versions.
+ * @returns Enabled measurand set, or `undefined` for no filter.
+ */
+const resolveEnabledMeasurands = (
+ chargingStation: ChargingStation,
+ measurandsKey: ConfigurationKeyType | undefined
+): ReadonlySet<MeterValueMeasurand> | undefined => {
+ const effectiveKey =
+ measurandsKey ??
+ (chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_16
+ ? StandardParametersKey.MeterValuesSampledData
+ : undefined)
+ if (effectiveKey == null) {
+ return undefined
+ }
+ const rawValue = getConfigurationKey(chargingStation, effectiveKey)?.value
+ if (rawValue == null) {
+ return new Set<MeterValueMeasurand>([MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER])
+ }
+ const enabled = new Set<MeterValueMeasurand>()
+ for (const entry of rawValue.split(',')) {
+ const trimmed = entry.trim()
+ if (trimmed.length === 0) {
+ continue
+ }
+ if (KNOWN_MEASURANDS.has(trimmed)) {
+ enabled.add(trimmed as MeterValueMeasurand)
+ continue
+ }
+ let warned = warnedInvalidMeasurands.get(chargingStation)
+ if (warned == null) {
+ warned = new Set<string>()
+ warnedInvalidMeasurands.set(chargingStation, warned)
+ }
+ if (!warned.has(trimmed)) {
+ warned.add(trimmed)
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.resolveEnabledMeasurands: unknown measurand '${trimmed}' in ${effectiveKey} — ignored`
+ )
+ }
+ }
+ return enabled
+}
+
+/**
+ * Builds a complete MeterValue with all configured measurands for a transaction.
+ * @param chargingStation - Target charging station
+ * @param transactionId - Active transaction identifier
+ * @param interval - Meter value sampling interval in milliseconds
+ * @param measurandsKey - Configuration key for the sampled measurands list
+ * @param context - Meter value reading context
+ * @param debug - Enable debug logging for measurand validation
+ * @returns Populated MeterValue object
+ */
+export const buildMeterValue = (
+ chargingStation: ChargingStation,
+ transactionId: number | string | undefined,
+ interval: number,
+ measurandsKey?: ConfigurationKeyType,
+ context?: MeterValueContext,
+ debug = false
+): MeterValue => {
+ if (transactionId == null) {
+ return buildEmptyMeterValue()
+ }
+ const { buildVersionedSampledValue, connectorId, evseId, signingConfig, signingState } =
+ createVersionedSampledValueDispatcher(chargingStation, transactionId, context)
+ // Coherent MeterValues strategy gate. Placed AFTER the versioned dispatcher
+ // is available (so the coherent path can emit versioned SampledValues) and
+ // BEFORE the random/fixed measurand generation runs. When coherent mode
+ // is not active for this station or no session exists for the transaction,
+ // this is a no-op and the random/fixed code path is unchanged.
+ if (isCoherentModeActive(chargingStation, transactionId)) {
+ const rootSeed = resolveRootSeed(chargingStation.stationInfo)
+ return buildCoherentMeterValue(
+ chargingStation,
+ transactionId,
+ buildVersionedSampledValue,
+ {
+ intervalMs: interval,
+ nowMs: Date.now(),
+ rootSeed,
+ },
+ context,
+ resolveEnabledMeasurands(chargingStation, measurandsKey)
+ )
+ }
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
const meterValue: { sampledValue: SampledValue[]; timestamp: Date } = buildEmptyMeterValue()
if (signingConfig != null) {
context,
voltageMeasurand.value
)
- if (chargingStation.stationInfo.phaseLineToLineVoltageMeterValues === true) {
+ if (chargingStation.stationInfo?.phaseLineToLineVoltageMeterValues === true) {
const nextPhase =
(phase + 1) % chargingStation.getNumberOfPhases() !== 0
? ((phase + 1) % chargingStation.getNumberOfPhases()).toString()
)
if (powerMeasurand?.values.allPhases != null) {
const unitDivider =
- powerMeasurand.template.unit === MeterValueUnit.KILO_WATT ? UNIT_DIVIDER_KILO : 1
+ powerMeasurand.template.unit === MeterValueUnit.KILO_WATT ? Constants.UNIT_DIVIDER_KILO : 1
const connectorMaximumAvailablePower =
chargingStation.getConnectorMaximumAvailablePower(connectorId)
const connectorMaximumPower = Math.round(connectorMaximumAvailablePower)
const connectorMaximumAvailablePower =
chargingStation.getConnectorMaximumAvailablePower(connectorId)
const connectorMaximumAmperage =
- chargingStation.stationInfo.currentOutType === CurrentType.AC
+ chargingStation.stationInfo?.currentOutType === CurrentType.AC
? ACElectricUtils.amperagePerPhaseFromPower(
chargingStation.getNumberOfPhases(),
connectorMaximumAvailablePower,
if (energyMeasurand != null) {
updateConnectorEnergyValues(connectorStatus, energyMeasurand.value)
const unitDivider =
- energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? UNIT_DIVIDER_KILO : 1
+ energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR
+ ? Constants.UNIT_DIVIDER_KILO
+ : 1
const energySampledValue = buildVersionedSampledValue(
energyMeasurand.template,
roundTo(
const connectorMaximumAvailablePower =
chargingStation.getConnectorMaximumAvailablePower(connectorId)
const connectorMaximumEnergyRounded = roundTo(
- (connectorMaximumAvailablePower * interval) / MS_PER_HOUR,
+ (connectorMaximumAvailablePower * interval) / Constants.MS_PER_HOUR,
2
)
const connectorMinimumEnergyRounded = roundTo(energyMeasurand.template.minimumValue ?? 0, 2)
chargePointModel: string
chargePointSerialNumberPrefix?: string
chargePointVendor: string
+ /**
+ * Enable physics-based coherent MeterValues generation (opt-in, default `false`).
+ * When `true`, MeterValues are derived from a single physics chain
+ * (V → P → I → ΔE → SoC) using a deterministic seeded PRNG and an
+ * optional {@link evProfilesFile}. When `false` or absent, the random/fixed
+ * per-measurand generation is used and behavior is unchanged.
+ */
+ coherentMeterValues?: boolean
commandsSupport?: CommandsSupport
Configuration?: ChargingStationOcppConfiguration
Connectors?: Record<string, ConnectorStatus>
currentOutType?: CurrentType
customValueLimitationMeterValues?: boolean
enableStatistics?: boolean
+ /**
+ * Optional EV profile file (same asset pattern as {@link idTagsFile}).
+ * Consumed only when `coherentMeterValues` is `true`. Malformed or
+ * missing files disable coherent generation for the station and log
+ * a warning; the station still starts.
+ */
+ evProfilesFile?: string
Evses?: Record<string, EvseTemplate>
firmwareUpgrade?: FirmwareUpgrade
firmwareVersion?: string
powerSharedByConnectors?: boolean
powerUnit?: PowerUnits
randomConnectors?: boolean
+ /**
+ * Optional deterministic seed for coherent MeterValues generation.
+ * When set together with `coherentMeterValues=true`, the physics-based
+ * generator produces reproducible MeterValues sequences for identical
+ * inputs (same seed + same transactionId + same interval). Ignored when
+ * `coherentMeterValues` is `false` or absent.
+ */
+ randomSeed?: number
reconnectExponentialDelay?: boolean
registrationMaxRetries?: number
remoteAuthorization?: boolean
static readonly DEFAULT_CIRCULAR_BUFFER_CAPACITY = 386
+ /** Default coherent MeterValues ramp-up duration in milliseconds. Non-positive values disable the ramp. */
+ static readonly DEFAULT_COHERENT_RAMP_UP_DURATION_MS = 5000
+
+ /** Default coherent MeterValues voltage-noise symmetric half-width (0.01 = ±1 %). */
+ static readonly DEFAULT_COHERENT_VOLTAGE_NOISE_PERCENT = 0.01
+
static readonly DEFAULT_EV_CONNECTION_TIMEOUT_SECONDS = 180
static readonly DEFAULT_FLUCTUATION_PERCENT = 5
// Values exceeding this limit cause Node.js to reset the delay to 1ms
static readonly MAX_SETINTERVAL_DELAY_MS = 2147483647
+ /** Milliseconds per hour; conversion factor for `Wh` accrual from `W·ms`. */
+ static readonly MS_PER_HOUR = 3_600_000
+
static readonly PERFORMANCE_RECORDS_TABLE = 'performance_records'
static readonly STOP_CHARGING_STATIONS_TIMEOUT_MS = 60000
static readonly STOP_MESSAGE_SEQUENCE_TIMEOUT_MS = 30000
+
+ /** Divider between base units (W, Wh) and kilo units (kW, kWh). */
+ static readonly UNIT_DIVIDER_KILO = 1000
}
/**
- * Common test constants for charging station tests across all OCPP versions
- *
- * This file serves as the single source of truth for test constants used across
- * charging station test suites. Constants are organized by functional area and
- * follow naming conventions: UPPERCASE_WITH_UNDERSCORES.
- * @see tests/charging-station/ocpp/2.0/OCPP20TestConstants.ts for OCPP 2.0 specific constants
- * @see tests/charging-station/OCPPSpecRequirements.md for OCPP specification requirements
+ * @file Common test constants for charging station tests.
+ * @description Single source of truth for test constants shared across
+ * charging station test suites, organized by functional area. Naming
+ * convention: `TEST_UPPERCASE_WITH_UNDERSCORES`. OCPP-2.0-specific
+ * constants live in `OCPP20TestConstants.ts`; specification requirements
+ * live in `OCPPSpecRequirements.md`.
*/
/**
/**
* Timer Intervals
- * Test values for timing-related configuration and expectations
+ * Test values for timing-related configuration and expectations.
+ * The `_MS` intervals are intentionally half of their production
+ * `Constants.DEFAULT_*_INTERVAL_MS` counterparts to keep test wall-clock
+ * time bounded without changing the invariants under test.
*/
export const TEST_HEARTBEAT_INTERVAL_SECONDS = 60
export const TEST_HEARTBEAT_INTERVAL_MS = 30000
export const TEST_AUTHORIZATION_TIMEOUT_MS = 30000
+export const TEST_METER_VALUES_INTERVAL_MS = 30_000
export const TEST_ONE_HOUR_SECONDS = 3600
export const TEST_ONE_HOUR_MS = TEST_ONE_HOUR_SECONDS * 1000
* Station helper functions for testing
*
* Factory functions to create mock ChargingStation instances with isolated dependencies.
+ *
+ * TODO(#1936): file size (≈ 950 LOC) exceeds the 250 LOC ceiling documented
+ * in AGENTS.md. Modular refactor (StationHelpers.types / .cleanup / .connector /
+ * .template / .factory) tracked as follow-up; 30+ test files import from
+ * this path so the split must preserve the public re-export surface.
*/
-import type { ChargingStation } from '../../../src/charging-station/index.js'
+import type { ChargingStation, CoherentSession } from '../../../src/charging-station/index.js'
import type {
ChargingStationInfo,
ChargingStationOcppConfiguration,
const station = {
// Reservation methods (mock implementations - eslint disabled for test utilities)
+ __injectCoherentSession (transactionId: number | string, session: CoherentSession): void {
+ this.coherentSessions.set(transactionId, session)
+ },
addReservation (reservation: Record<string, unknown>): void {
// Check if reservation with same ID exists and remove it
const existingReservation = this.getReservationBy(
}
},
automaticTransactionGenerator: undefined,
+
bootNotificationRequest: undefined,
bootNotificationResponse: {
interval: number
status: RegistrationStatusEnumType
},
-
bufferMessage (message: string): void {
this.messageQueue.push(message)
},
this.wsConnection = null
}
},
- connectors,
+ // Coherent MeterValues session store (real class uses a private Map).
+ coherentSessions: new Map<number | string, CoherentSession>(),
+ connectors,
+ createCoherentSession (
+ _transactionId: number | string,
+ _connectorId: number
+ ): CoherentSession | undefined {
+ // Mock: never auto-create; tests inject sessions directly when needed.
+ return undefined
+ },
async delete (deleteConfiguration = true): Promise<void> {
if (this.started) {
await this.stop()
// Note: deleteConfiguration controls file deletion in real implementation
// Mock doesn't have file system access, so parameter is unused
},
+
+ destroyCoherentSession (transactionId: number | string | undefined): boolean {
+ if (transactionId == null) {
+ return false
+ }
+ return this.coherentSessions.delete(transactionId)
+ },
// Event emitter methods (minimal implementation)
emit: () => true,
// Empty implementations for interface compatibility
getAuthorizeRemoteTxRequests (): boolean {
return false // Default to false in mock
},
+ getCoherentSession (transactionId: number | string): CoherentSession | undefined {
+ return this.coherentSessions.get(transactionId)
+ },
getConnectionTimeout (): number {
return connectionTimeout
},
getWebSocketPingInterval (): number {
return websocketPingInterval
},
+
hasConnector (connectorId: number): boolean {
return this.iterateConnectors().some(({ connectorId: id }) => id === connectorId)
},
// Core properties
index,
-
inPendingState (): boolean {
return this.bootNotificationResponse?.status === RegistrationStatusEnumType.PENDING
},
+
inRejectedState (): boolean {
return this.bootNotificationResponse?.status === RegistrationStatusEnumType.REJECTED
},
--- /dev/null
+/**
+ * @file Tests for CoherentMeterValuesGenerator physics.
+ * @description Verifies invariants (P=V·I·phases, ΔE=P·Δt/3.6e6, SoC monotone,
+ * saturation at 100 %), Wh/kWh unit conversion, energy-register ownership,
+ * and same-seed determinism across AC 1-phase, AC 3-phase, and DC modes.
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import type { BuildVersionedSampledValue } from '../../../src/charging-station/meter-values/CoherentMeterValuesGenerator.js'
+import type {
+ CoherentSession,
+ EvProfile,
+ ICoherentContext,
+} from '../../../src/charging-station/meter-values/types.js'
+import type {
+ ChargingStationInfo,
+ ConnectorStatus,
+ SampledValue,
+ SampledValueTemplate,
+} from '../../../src/types/index.js'
+
+import {
+ buildCoherentMeterValue,
+ computeCoherentSample,
+ createCoherentSession,
+ disposeCoherentSessionRuntime,
+ resolveRootSeed,
+} from '../../../src/charging-station/meter-values/CoherentMeterValuesGenerator.js'
+import { hashLabel } from '../../../src/charging-station/meter-values/Prng.js'
+import {
+ AvailabilityType,
+ CurrentType,
+ MeterValueMeasurand,
+ MeterValuePhase,
+ MeterValueUnit,
+ OCPPVersion,
+} from '../../../src/types/index.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import { TEST_METER_VALUES_INTERVAL_MS } from '../ChargingStationTestConstants.js'
+
+const baseProfile: EvProfile = {
+ batteryCapacityWh: 40000,
+ chargingCurve: [
+ { powerFraction: 1, socPercent: 0 },
+ { powerFraction: 1, socPercent: 80 },
+ { powerFraction: 0.2, socPercent: 100 },
+ ],
+ id: 'unit-ev',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+}
+
+/**
+ * Builds an in-memory ICoherentContext + connector status for unit tests.
+ * @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.numberOfPhases - Number of AC phases (ignored for DC).
+ * @param overrides.voltageOut - Nominal phase voltage.
+ * @returns Bundle exposing context, sessions map, station info, and connector status.
+ */
+const buildContext = (
+ overrides: {
+ currentType?: CurrentType
+ evseMaxPowerW?: number
+ numberOfPhases?: number
+ voltageOut?: number
+ } = {}
+): {
+ connectorStatus: ConnectorStatus
+ context: ICoherentContext
+ sessions: Map<number | string, CoherentSession>
+ stationInfo: ChargingStationInfo
+} => {
+ const numberOfPhases = overrides.numberOfPhases ?? 1
+ const voltageOut = overrides.voltageOut ?? 230
+ const evseMax = overrides.evseMaxPowerW ?? 22000
+
+ const stationInfo: ChargingStationInfo = {
+ baseName: 'CS-TEST',
+ chargePointModel: 'model',
+ chargePointVendor: 'vendor',
+ coherentMeterValues: true,
+ currentOutType: overrides.currentType ?? CurrentType.AC,
+ hashId: 'hash-1',
+ numberOfPhases,
+ ocppVersion: OCPPVersion.VERSION_16,
+ randomSeed: 42,
+ templateIndex: 0,
+ templateName: 'CS-TEST',
+ voltageOut,
+ }
+
+ const connectorStatus: ConnectorStatus = {
+ availability: AvailabilityType.Operative,
+ energyActiveImportRegisterValue: 0,
+ MeterValues: [],
+ transactionEnergyActiveImportRegisterValue: 0,
+ transactionId: 1,
+ }
+
+ const sessions = new Map<number | string, CoherentSession>()
+
+ const context: ICoherentContext = {
+ getCoherentSession: (id: number | string) => sessions.get(id),
+ getConnectorMaximumAvailablePower: () => evseMax,
+ getConnectorStatus: () => connectorStatus,
+ getNumberOfPhases: () => numberOfPhases,
+ getVoltageOut: () => voltageOut,
+ logPrefix: () => '[test]',
+ stationInfo,
+ }
+ return { connectorStatus, context, sessions, stationInfo }
+}
+
+const templatesFor = (
+ measurands: MeterValueMeasurand[],
+ energyUnit: MeterValueUnit = MeterValueUnit.WATT_HOUR
+): SampledValueTemplate[] => {
+ return measurands.map(measurand => {
+ const unit =
+ measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ? energyUnit
+ : measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT
+ ? MeterValueUnit.WATT
+ : undefined
+ return (unit != null ? { measurand, unit } : { measurand }) as SampledValueTemplate
+ })
+}
+
+/**
+ * OCPP 1.6-style pass-through SampledValue builder used in unit tests. Keeps
+ * the numeric value accessible for algebraic invariant checks.
+ * @param template - SampledValueTemplate.
+ * @param value - Numeric value to be serialized.
+ * @returns Minimal SampledValue with stringified value.
+ */
+const passThroughBuilder: BuildVersionedSampledValue = (template, value): SampledValue => {
+ return {
+ ...(template.measurand != null && { measurand: template.measurand }),
+ ...(template.unit != null && { unit: template.unit as never }),
+ value: value.toString(),
+ } as SampledValue
+}
+
+/**
+ * Creates a coherent session and asserts it is defined. Encapsulates the
+ * `undefined` fallback so callers avoid non-null assertions.
+ * @param context - ICoherentContext.
+ * @param options - Session parameters (see createCoherentSession).
+ * @returns The created session.
+ */
+const createSessionOrFail = (
+ context: ICoherentContext,
+ options: Parameters<typeof createCoherentSession>[1]
+): CoherentSession => {
+ const session = createCoherentSession(context, options)
+ assert.ok(session != null, 'expected a session to be created')
+ return session
+}
+
+await describe('CoherentMeterValuesGenerator', async () => {
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await describe('resolveRootSeed', async () => {
+ await it('should prefer explicit randomSeed', () => {
+ assert.strictEqual(resolveRootSeed({ hashId: 'x', randomSeed: 12345 }), 12345)
+ })
+
+ await it('should derive from hashId when randomSeed is missing', () => {
+ const a = resolveRootSeed({ hashId: 'x' })
+ const b = resolveRootSeed({ hashId: 'x' })
+ assert.strictEqual(a, b)
+ assert.notStrictEqual(a, resolveRootSeed({ hashId: 'y' }))
+ })
+
+ await it('should be stable and non-zero for empty hashId', () => {
+ const seed = resolveRootSeed({ hashId: '' })
+ assert.strictEqual(typeof seed, 'number')
+ assert.ok(seed >>> 0 === seed, 'expected a 32-bit unsigned integer')
+ })
+ })
+
+ await describe('AC 1-phase invariants', async () => {
+ await it('should satisfy P = V·I·phases within ±1 W after rounding', () => {
+ const { connectorStatus, context, sessions } = buildContext({
+ currentType: CurrentType.AC,
+ evseMaxPowerW: 7400,
+ numberOfPhases: 1,
+ voltageOut: 230,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+
+ sessions.set(1, session)
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const expected = sample.voltageV * sample.currentA * 1
+ assert.ok(
+ Math.abs(sample.powerW - expected) <= 0.01,
+ `AC1: |P - V·I·phases|=${Math.abs(sample.powerW - expected).toString()} exceeded 0.01W tolerance`
+ )
+ })
+ })
+
+ await describe('AC 3-phase invariants', async () => {
+ await it('should satisfy P = V·I·3 within ±3 W after rounding', () => {
+ 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: 7,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: 10000,
+ nowMs: 10000,
+ rootSeed: 7,
+ voltageNoise: false,
+ })
+ const expected = sample.voltageV * sample.currentA * 3
+ assert.ok(
+ Math.abs(sample.powerW - expected) <= 0.01,
+ `AC3: |P - V·I·3|=${Math.abs(sample.powerW - expected).toString()} exceeded 0.01W tolerance`
+ )
+ })
+ })
+
+ await describe('DC invariants', async () => {
+ await it('should satisfy P = V·I within ±1 W after rounding', () => {
+ const { connectorStatus, context, sessions } = buildContext({
+ currentType: CurrentType.DC,
+ evseMaxPowerW: 50000,
+ voltageOut: 400,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 1337,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: 5000,
+ nowMs: 5000,
+ rootSeed: 1337,
+ voltageNoise: false,
+ })
+ const expected = sample.voltageV * sample.currentA
+ assert.ok(
+ Math.abs(sample.powerW - expected) <= 0.01,
+ `DC: |P - V·I|=${Math.abs(sample.powerW - expected).toString()} exceeded 0.01W tolerance`
+ )
+ })
+ })
+
+ await describe('SoC saturation', async () => {
+ await it('should produce zero power and no ΔE when SoC ≥ 100', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+
+ session.socPercent = 100
+ sessions.set(1, session)
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ assert.strictEqual(sample.powerW, 0)
+ assert.strictEqual(sample.currentA, 0)
+ assert.strictEqual(sample.deltaEnergyWh, 0)
+ })
+
+ await it('should keep SoC as a fixed point at 100 %', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+
+ session.socPercent = 100
+ sessions.set(1, session)
+ for (let i = 0; i < 5; i++) {
+ computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS * (i + 1),
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ }
+ assert.strictEqual(session.socPercent, 100)
+ })
+ })
+
+ await describe('multi-sample monotonicity', async () => {
+ await it('should keep energy and SoC monotone non-decreasing over N samples', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ let prevEnergy = 0
+ let prevSoc = 0
+ for (let i = 0; i < 30; i++) {
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: 10000,
+ nowMs: 10000 * (i + 1),
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ // The exported register is projected; caller applies advance. Simulate.
+ connectorStatus.energyActiveImportRegisterValue =
+ (connectorStatus.energyActiveImportRegisterValue ?? 0) + sample.deltaEnergyWh
+ connectorStatus.transactionEnergyActiveImportRegisterValue =
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + sample.deltaEnergyWh
+ assert.ok(sample.energyRegisterWh >= prevEnergy)
+ assert.ok(sample.socPercent >= prevSoc)
+ prevEnergy = sample.energyRegisterWh
+ prevSoc = sample.socPercent
+ }
+ })
+ })
+
+ await describe('energy register ownership', async () => {
+ await it('should advance registers even when Energy measurand is not emitted', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ // Only SoC + Voltage — no Energy template configured.
+ connectorStatus.MeterValues = templatesFor([
+ MeterValueMeasurand.STATE_OF_CHARGE,
+ MeterValueMeasurand.VOLTAGE,
+ ])
+ const before = connectorStatus.energyActiveImportRegisterValue ?? 0
+ buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const after = connectorStatus.energyActiveImportRegisterValue ?? 0
+ assert.ok(after > before, 'energy register must advance regardless of template presence')
+ })
+ })
+
+ await describe('Wh / kWh unit conversion', async () => {
+ await it('should emit Wh raw when template unit is Wh', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = templatesFor(
+ [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER],
+ MeterValueUnit.WATT_HOUR
+ )
+ const meterValue = buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ intervalMs: 3_600_000, // 1 h → 1 Wh per 1 W
+ nowMs: 3_600_000,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const registerWh = connectorStatus.energyActiveImportRegisterValue ?? 0
+ const emitted = Number(meterValue.sampledValue[0].value)
+ // Rounded to 2 decimals: emitted ≈ registerWh.
+ assert.ok(
+ Math.abs(emitted - Math.round(registerWh * 100) / 100) < 0.01,
+ `Wh mismatch: registerWh=${registerWh.toString()} emitted=${emitted.toString()}`
+ )
+ })
+
+ await it('should emit kWh divided by 1000 when template unit is kWh', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = templatesFor(
+ [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER],
+ MeterValueUnit.KILO_WATT_HOUR
+ )
+ const meterValue = buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ intervalMs: 3_600_000,
+ nowMs: 3_600_000,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const registerWh = connectorStatus.energyActiveImportRegisterValue ?? 0
+ const emitted = Number(meterValue.sampledValue[0].value)
+ const expectedKwh = Math.round((registerWh / 1000) * 100) / 100
+ assert.ok(
+ Math.abs(emitted - expectedKwh) < 0.01,
+ `kWh mismatch: registerWh=${registerWh.toString()} emitted=${emitted.toString()} expectedKwh=${expectedKwh.toString()}`
+ )
+ })
+ })
+
+ await describe('voltage noise across samples', async () => {
+ await it('should advance the voltage PRNG state across samples with voltageNoise=true', () => {
+ const { connectorStatus, context, sessions } = buildContext({
+ currentType: CurrentType.AC,
+ evseMaxPowerW: 22000,
+ numberOfPhases: 1,
+ voltageOut: 230,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ const voltages: number[] = []
+ for (let i = 0; i < 5; i++) {
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS * (i + 1),
+ rootSeed: 42,
+ })
+ voltages.push(sample.voltageV)
+ }
+ const uniqueVoltages = new Set(voltages)
+ assert.ok(
+ uniqueVoltages.size >= 2,
+ `voltage noise stagnated across samples (PRNG seed reset each call): ${voltages
+ .map(v => v.toString())
+ .join(', ')}`
+ )
+ for (const v of voltages) {
+ assert.ok(
+ v >= 230 * 0.99 - 1e-6 && v <= 230 * 1.01 + 1e-6,
+ `voltage out of ±1 % band: ${v.toString()}`
+ )
+ }
+ })
+ })
+
+ await describe('SoC cap energy coherency', async () => {
+ await it('should clamp deltaEnergyWh to remaining battery capacity when crossing 100 % SoC', () => {
+ // Taper-free profile so full power is delivered at 99.8 % SoC.
+ // 40 kWh battery, 99.8 % SoC → remaining = 0.2/100 × 40000 = 80 Wh.
+ // 11 kW × 60 s = 183 Wh would overshoot without clamping.
+ const flatProfile: EvProfile = {
+ ...baseProfile,
+ chargingCurve: [
+ { powerFraction: 1, socPercent: 0 },
+ { powerFraction: 1, socPercent: 100 },
+ ],
+ }
+ const { connectorStatus, context, sessions } = buildContext({
+ currentType: CurrentType.AC,
+ evseMaxPowerW: 22000,
+ numberOfPhases: 1,
+ voltageOut: 230,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [flatProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ session.socPercent = 99.8
+ sessions.set(1, session)
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: 60000,
+ nowMs: 60000,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const remainingWh = ((100 - 99.8) / 100) * flatProfile.batteryCapacityWh
+ assert.ok(
+ sample.deltaEnergyWh <= remainingWh + 1e-6,
+ `deltaEnergyWh=${sample.deltaEnergyWh.toString()} exceeded remaining capacity ${remainingWh.toString()} Wh`
+ )
+ assert.strictEqual(sample.socPercent, 100)
+ // INV-3 (P × Δt / 3.6e6 = ΔE): reported P must be recomputed from clamped ΔE.
+ const expectedPowerW = (sample.deltaEnergyWh * 3_600_000) / 60000
+ assert.ok(
+ Math.abs(sample.powerW - expectedPowerW) <= 1,
+ `emitted powerW=${sample.powerW.toString()} incoherent with clamped ΔE (expected ~${expectedPowerW.toString()} W)`
+ )
+ assert.ok(
+ Math.abs(sample.deltaEnergyWh - remainingWh) < 0.01,
+ `deltaEnergyWh=${sample.deltaEnergyWh.toString()} != remainingWh=${remainingWh.toString()}`
+ )
+ })
+ })
+
+ await describe('determinism', async () => {
+ await it('should produce identical sequences for identical seed + transactionId', () => {
+ const runOnce = (): number[] => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ const values: number[] = []
+ for (let i = 0; i < 20; i++) {
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: 10000,
+ nowMs: 10000 * (i + 1),
+ rootSeed: 42,
+ })
+ values.push(sample.voltageV, sample.powerW, sample.currentA, sample.socPercent)
+ }
+ return values
+ }
+ const a = runOnce()
+ const b = runOnce()
+ assert.deepStrictEqual(a, b)
+ })
+ })
+
+ await describe('INV-1 in capacity-clamp branch', async () => {
+ await it('should keep V·I·phases coherent with reported P after AC 3-phase clamp', () => {
+ const flatProfile: EvProfile = {
+ ...baseProfile,
+ chargingCurve: [
+ { powerFraction: 1, socPercent: 0 },
+ { powerFraction: 1, socPercent: 100 },
+ ],
+ }
+ const { connectorStatus, context, sessions } = buildContext({
+ currentType: CurrentType.AC,
+ evseMaxPowerW: 22000,
+ numberOfPhases: 3,
+ voltageOut: 230,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [flatProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ session.socPercent = 99.8
+ sessions.set(1, session)
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: 60000,
+ nowMs: 60000,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const viPhases = sample.voltageV * sample.currentA * 3
+ assert.ok(
+ Math.abs(sample.powerW - viPhases) <= 0.01,
+ `AC 3-phase capacity clamp: |P - V·I·phases|=${Math.abs(sample.powerW - viPhases).toString()} exceeded 2 × ROUNDING_SCALE (0.01 W)`
+ )
+ const remainingWh = ((100 - 99.8) / 100) * flatProfile.batteryCapacityWh
+ assert.ok(
+ sample.deltaEnergyWh <= remainingWh + 1e-6,
+ `AC 3-phase capacity clamp: ΔE=${sample.deltaEnergyWh.toString()} > remainingWh=${remainingWh.toString()}`
+ )
+ })
+
+ await it('should keep V·I coherent with reported P after DC clamp', () => {
+ const flatProfile: EvProfile = {
+ ...baseProfile,
+ chargingCurve: [
+ { powerFraction: 1, socPercent: 0 },
+ { powerFraction: 1, socPercent: 100 },
+ ],
+ }
+ const { connectorStatus, context, sessions } = buildContext({
+ currentType: CurrentType.DC,
+ evseMaxPowerW: 50000,
+ voltageOut: 400,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [flatProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ session.socPercent = 99.9
+ sessions.set(1, session)
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: 60000,
+ nowMs: 60000,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const vi = sample.voltageV * sample.currentA
+ assert.ok(
+ Math.abs(sample.powerW - vi) <= 0.01,
+ `DC capacity clamp: |P - V·I|=${Math.abs(sample.powerW - vi).toString()} exceeded 2 × ROUNDING_SCALE (0.01 W)`
+ )
+ })
+ })
+
+ await describe('intervalMs defensive guard', async () => {
+ await it('should not contaminate socPercent or deltaEnergyWh with NaN at saturated SoC', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ session.socPercent = 100
+ sessions.set(1, session)
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: 0,
+ nowMs: 0,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ assert.ok(!Number.isNaN(sample.powerW), 'powerW must not be NaN')
+ assert.ok(!Number.isNaN(sample.currentA), 'currentA must not be NaN')
+ assert.ok(!Number.isNaN(sample.deltaEnergyWh), 'deltaEnergyWh must not be NaN')
+ assert.ok(!Number.isNaN(sample.socPercent), 'sample.socPercent must not be NaN')
+ assert.ok(!Number.isNaN(session.socPercent), 'session.socPercent must not be NaN')
+ assert.strictEqual(sample.deltaEnergyWh, 0)
+ // Second call to confirm session state stays healthy.
+ const next = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ assert.ok(!Number.isNaN(next.powerW))
+ assert.ok(!Number.isNaN(session.socPercent))
+ assert.strictEqual(session.socPercent, 100)
+ })
+
+ await it('should short-circuit on intervalMs=0 with non-saturated SoC', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ const socBefore = session.socPercent
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: 0,
+ nowMs: 0,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ assert.strictEqual(sample.deltaEnergyWh, 0)
+ assert.strictEqual(sample.powerW, 0)
+ assert.strictEqual(session.socPercent, socBefore)
+ })
+
+ await it('should short-circuit on NaN intervalMs without poisoning session state', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ const socBefore = session.socPercent
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: Number.NaN,
+ nowMs: 0,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ assert.ok(Number.isFinite(sample.powerW), 'powerW must be finite')
+ assert.ok(Number.isFinite(sample.currentA), 'currentA must be finite')
+ assert.ok(Number.isFinite(sample.deltaEnergyWh), 'deltaEnergyWh must be finite')
+ assert.ok(Number.isFinite(sample.socPercent), 'sample.socPercent must be finite')
+ assert.ok(Number.isFinite(session.socPercent), 'session.socPercent must be finite')
+ assert.strictEqual(sample.deltaEnergyWh, 0)
+ assert.strictEqual(sample.powerW, 0)
+ assert.strictEqual(session.socPercent, socBefore)
+ })
+
+ await it('should short-circuit on +Infinity intervalMs without poisoning session state', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ const socBefore = session.socPercent
+ const sample = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: Number.POSITIVE_INFINITY,
+ nowMs: 0,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ assert.ok(Number.isFinite(sample.powerW), 'powerW must be finite')
+ assert.ok(Number.isFinite(sample.currentA), 'currentA must be finite')
+ assert.ok(Number.isFinite(sample.deltaEnergyWh), 'deltaEnergyWh must be finite')
+ assert.ok(Number.isFinite(sample.socPercent), 'sample.socPercent must be finite')
+ assert.ok(Number.isFinite(session.socPercent), 'session.socPercent must be finite')
+ assert.strictEqual(sample.deltaEnergyWh, 0)
+ assert.strictEqual(sample.powerW, 0)
+ assert.strictEqual(session.socPercent, socBefore)
+ })
+ })
+
+ await describe('runtime PRNG isolation from session', async () => {
+ await it('should not expose voltagePrng on CoherentSession', () => {
+ const { context } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ assert.ok(
+ !Object.prototype.hasOwnProperty.call(session, 'voltagePrng'),
+ 'CoherentSession must not carry voltagePrng (moved to module-scope runtime state)'
+ )
+ })
+
+ await it('should restart voltage-noise stream after dispose', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ const v1 = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS,
+ rootSeed: 42,
+ }).voltageV
+ const v2 = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: 60000,
+ rootSeed: 42,
+ }).voltageV
+ // Dispose and re-run: v3 must equal v1 (fresh PRNG from same seed).
+ assert.ok(disposeCoherentSessionRuntime(session))
+ const v3 = computeCoherentSample(context, connectorStatus, session, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: 90000,
+ rootSeed: 42,
+ }).voltageV
+ assert.strictEqual(v3, v1, 'dispose must clear cached PRNG state')
+ assert.notStrictEqual(v2, v3, 'without dispose, v2 would differ from a fresh draw')
+ })
+ })
+
+ await describe('resolveRootSeed DRY dedup', async () => {
+ await it('should match hashLabel for the hashId path', () => {
+ assert.strictEqual(resolveRootSeed({ hashId: 'abc' }), hashLabel('abc'))
+ assert.strictEqual(resolveRootSeed({ hashId: '' }), hashLabel(''))
+ assert.strictEqual(resolveRootSeed({ hashId: 'CS-1234' }), hashLabel('CS-1234'))
+ })
+ })
+
+ await describe('per-phase emission', async () => {
+ await it('should emit one SampledValue per phase-qualified template on AC 3-phase', () => {
+ 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)
+ // Phase-preserving builder: include template.phase and template.measurand
+ // so per-phase emissions can be counted and cross-checked.
+ const phaseBuilder: BuildVersionedSampledValue = (template, value): SampledValue =>
+ ({
+ ...(template.measurand != null && { measurand: template.measurand }),
+ ...(template.phase != null && { phase: template.phase as never }),
+ ...(template.unit != null && { unit: template.unit as never }),
+ value: value.toString(),
+ }) as SampledValue
+ connectorStatus.MeterValues = [
+ { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L1_N },
+ { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L2_N },
+ { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L3_N },
+ { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L1_L2 },
+ { measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT, unit: MeterValueUnit.WATT },
+ {
+ 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,
+ },
+ { measurand: MeterValueMeasurand.CURRENT_IMPORT, phase: MeterValuePhase.L1 },
+ { measurand: MeterValueMeasurand.CURRENT_IMPORT, phase: MeterValuePhase.N },
+ ] as unknown as SampledValueTemplate[]
+ const mv = buildCoherentMeterValue(context, 1, phaseBuilder, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const byPhase = (measurand: MeterValueMeasurand): Record<string, number> => {
+ const out: Record<string, number> = {}
+ for (const sv of mv.sampledValue) {
+ if ((sv as { measurand?: MeterValueMeasurand }).measurand !== measurand) continue
+ const p = (sv as { phase?: string }).phase ?? 'aggregate'
+ out[p] = Number((sv as { value: string }).value)
+ }
+ return out
+ }
+ const v = byPhase(MeterValueMeasurand.VOLTAGE)
+ const p = byPhase(MeterValueMeasurand.POWER_ACTIVE_IMPORT)
+ const i = byPhase(MeterValueMeasurand.CURRENT_IMPORT)
+ // L-N voltages carry the sampled voltage (nominal here since noise is off).
+ assert.strictEqual(v['L1-N'], 230)
+ assert.strictEqual(v['L2-N'], 230)
+ assert.strictEqual(v['L3-N'], 230)
+ // L-L voltage: √3 × V_LN ≈ 398.37.
+ assert.ok(
+ Math.abs(v['L1-L2'] - Math.sqrt(3) * 230) < 0.01,
+ `L-L voltage=${v['L1-L2'].toString()} not ≈ √3·230`
+ )
+ // Aggregate power exists and per-phase powers sum to it within tolerance.
+ const perPhaseSum = p['L1-N'] + p['L2-N'] + p['L3-N']
+ assert.ok(
+ Math.abs(perPhaseSum - p.aggregate) < 0.05,
+ `per-phase Σ=${perPhaseSum.toString()} not ≈ aggregate=${p.aggregate.toString()}`
+ )
+ // N-phase current is 0 for balanced 3-φ Y.
+ assert.strictEqual(i.N, 0)
+ // L1 current matches the aggregate line current from computeCoherentSample.
+ assert.ok(i.L1 > 0)
+ })
+
+ await it('should skip L-L voltage on 1-phase AC (log-and-skip)', () => {
+ const { connectorStatus, context, sessions } = buildContext({
+ currentType: CurrentType.AC,
+ evseMaxPowerW: 7400,
+ numberOfPhases: 1,
+ 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.VOLTAGE, phase: MeterValuePhase.L1_L2 },
+ { measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L1_N },
+ ] as unknown as SampledValueTemplate[]
+ const mv = buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const voltageSamples = mv.sampledValue.filter(
+ sv => (sv as { measurand?: MeterValueMeasurand }).measurand === MeterValueMeasurand.VOLTAGE
+ )
+ assert.strictEqual(voltageSamples.length, 1, 'L-L voltage on 1-phase must be skipped')
+ })
+
+ await it('should emit per-phase energy register as register/phases on AC 3-phase', () => {
+ 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,
+ },
+ ] as unknown as SampledValueTemplate[]
+ const mv = buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ intervalMs: 3_600_000,
+ nowMs: 3_600_000,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const emitted = Number(mv.sampledValue[0].value)
+ // register / phases; register is advanced during the call, so the emitted
+ // value reflects the post-advance state divided by 3.
+ const postRegister = connectorStatus.energyActiveImportRegisterValue ?? 0
+ assert.ok(
+ Math.abs(emitted - postRegister / 3) < 0.05,
+ `L-N register=${emitted.toString()} not ≈ ${(postRegister / 3).toString()}`
+ )
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for EV profile loader and helpers.
+ * @description Verifies profile validation, selection, and curve
+ * interpolation used by the coherent MeterValues generator.
+ *
+ * Covers:
+ * - interpolateChargingCurve — boundaries and mid-points
+ * - selectEvProfile — weight-based selection determinism
+ * - loadEvProfilesFile — fail-soft on missing/invalid files, sort by SoC
+ */
+
+import assert from 'node:assert/strict'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, it } from 'node:test'
+
+import type { EvProfile } from '../../../src/charging-station/meter-values/types.js'
+
+import {
+ interpolateChargingCurve,
+ loadEvProfilesFile,
+ selectEvProfile,
+} from '../../../src/charging-station/meter-values/EvProfiles.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+
+const midProfile: EvProfile = {
+ batteryCapacityWh: 40000,
+ chargingCurve: [
+ { powerFraction: 1, socPercent: 0 },
+ { powerFraction: 0.5, socPercent: 50 },
+ { powerFraction: 0.1, socPercent: 100 },
+ ],
+ id: 'mid',
+ initialSocPercentMax: 60,
+ initialSocPercentMin: 10,
+ maxPowerW: 11000,
+ weight: 1,
+}
+
+await describe('EvProfiles', async () => {
+ afterEach(() => {
+ standardCleanup()
+ })
+ await describe('interpolateChargingCurve', async () => {
+ await it('should return endpoint value at the lower boundary', () => {
+ assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 0), 1)
+ })
+
+ await it('should return endpoint value at the upper boundary', () => {
+ assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 100), 0.1)
+ })
+
+ await it('should clamp below the first point', () => {
+ assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, -10), 1)
+ })
+
+ await it('should clamp above the last point', () => {
+ assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 200), 0.1)
+ })
+
+ await it('should interpolate the midpoint linearly', () => {
+ // At SoC=50 the curve value is 0.5 exactly.
+ assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 50), 0.5)
+ // At SoC=25: halfway between (0,1) and (50,0.5) → 0.75.
+ assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 25), 0.75)
+ // At SoC=75: halfway between (50,0.5) and (100,0.1) → 0.30.
+ assert.strictEqual(interpolateChargingCurve(midProfile.chargingCurve, 75), 0.3)
+ })
+
+ await it('should return 1 for empty curves', () => {
+ assert.strictEqual(interpolateChargingCurve([], 50), 1)
+ })
+ })
+
+ await describe('selectEvProfile', async () => {
+ const profiles: EvProfile[] = [
+ { ...midProfile, id: 'A', weight: 1 },
+ { ...midProfile, id: 'B', weight: 3 },
+ ]
+
+ await it('should always select first profile with random=0', () => {
+ const chosen = selectEvProfile(profiles, 0)
+ assert.strictEqual(chosen.id, 'A')
+ })
+
+ await it('should select second profile when random exceeds first weight fraction', () => {
+ // totalWeight=4. At random=0.5 → target=2 → A(1) < 2, B(4) >= 2 ⇒ B.
+ assert.strictEqual(selectEvProfile(profiles, 0.5).id, 'B')
+ })
+
+ await it('should fall back to first profile when total weight is zero', () => {
+ const zero: EvProfile[] = [
+ { ...midProfile, id: 'A', weight: 0 },
+ { ...midProfile, id: 'B', weight: 0 },
+ ]
+ assert.strictEqual(selectEvProfile(zero, 0.999).id, 'A')
+ })
+ })
+
+ await describe('loadEvProfilesFile', async () => {
+ await it('should return undefined on missing file (fail-soft)', () => {
+ const result = loadEvProfilesFile('/nonexistent/path/ev-profiles.json', 'test')
+ assert.strictEqual(result, undefined)
+ })
+
+ await it('should return undefined on invalid JSON (fail-soft)', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-'))
+ const path = join(dir, 'bad.json')
+ writeFileSync(path, '{not json}')
+ try {
+ const result = loadEvProfilesFile(path, 'test')
+ assert.strictEqual(result, undefined)
+ } finally {
+ rmSync(dir, { force: true, recursive: true })
+ }
+ })
+
+ await it('should return undefined on schema violation', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-'))
+ const path = join(dir, 'bad-schema.json')
+ writeFileSync(
+ path,
+ JSON.stringify({
+ profiles: [
+ {
+ // missing batteryCapacityWh and others
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'x',
+ },
+ ],
+ })
+ )
+ try {
+ const result = loadEvProfilesFile(path, 'test')
+ assert.strictEqual(result, undefined)
+ } finally {
+ rmSync(dir, { force: true, recursive: true })
+ }
+ })
+
+ await it('should load a valid file and sort curve by socPercent', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-'))
+ const path = join(dir, 'ok.json')
+ writeFileSync(
+ path,
+ JSON.stringify({
+ profiles: [
+ {
+ batteryCapacityWh: 40000,
+ // Intentionally unordered — loader must sort in place.
+ chargingCurve: [
+ { powerFraction: 0.1, socPercent: 100 },
+ { powerFraction: 1, socPercent: 0 },
+ { powerFraction: 0.5, socPercent: 50 },
+ ],
+ id: 'test',
+ initialSocPercentMax: 60,
+ initialSocPercentMin: 10,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ ],
+ })
+ )
+ try {
+ const result = loadEvProfilesFile(path, 'test')
+ assert.ok(result != null)
+ const curve = result.profiles[0].chargingCurve
+ assert.strictEqual(curve[0].socPercent, 0)
+ assert.strictEqual(curve[1].socPercent, 50)
+ assert.strictEqual(curve[2].socPercent, 100)
+ } finally {
+ rmSync(dir, { force: true, recursive: true })
+ }
+ })
+
+ await it('should swap inverted initial SoC bounds', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-'))
+ const path = join(dir, 'inverted.json')
+ writeFileSync(
+ path,
+ JSON.stringify({
+ profiles: [
+ {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'test',
+ initialSocPercentMax: 20,
+ initialSocPercentMin: 80,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ ],
+ })
+ )
+ try {
+ const result = loadEvProfilesFile(path, 'test')
+ assert.ok(result != null)
+ assert.strictEqual(result.profiles[0].initialSocPercentMin, 20)
+ assert.strictEqual(result.profiles[0].initialSocPercentMax, 80)
+ } finally {
+ rmSync(dir, { force: true, recursive: true })
+ }
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for coherent MeterValues PRNG.
+ * @description Verifies deterministic seeded PRNG behavior and stream
+ * splitting independence for the physics-based coherent generator.
+ *
+ * Covers:
+ * - mulberry32 — determinism (same seed twice → identical sequence)
+ * - deriveSeed — label-based split streams stay independent
+ * - hashLabel — stable across runs
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import {
+ deriveSeed,
+ hashLabel,
+ mulberry32,
+} from '../../../src/charging-station/meter-values/Prng.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+
+await describe('Prng', async () => {
+ afterEach(() => {
+ standardCleanup()
+ })
+ await describe('mulberry32', async () => {
+ await it('should produce identical sequences for identical seeds', () => {
+ const a = mulberry32(42)
+ const b = mulberry32(42)
+ for (let i = 0; i < 100; i++) {
+ assert.strictEqual(a(), b())
+ }
+ })
+
+ await it('should produce different sequences for different seeds', () => {
+ const a = mulberry32(1)
+ const b = mulberry32(2)
+ let differences = 0
+ for (let i = 0; i < 100; i++) {
+ if (a() !== b()) {
+ differences++
+ }
+ }
+ assert.ok(differences > 95, `expected >95 differing samples, got ${differences.toString()}`)
+ })
+
+ await it('should produce floats in [0, 1)', () => {
+ const prng = mulberry32(1337)
+ for (let i = 0; i < 10000; i++) {
+ const v = prng()
+ assert.ok(v >= 0 && v < 1, `value ${v.toString()} outside [0, 1)`)
+ }
+ })
+ })
+
+ await describe('hashLabel', async () => {
+ await it('should hash identical labels to identical values', () => {
+ assert.strictEqual(hashLabel('VOLTAGE_NOISE'), hashLabel('VOLTAGE_NOISE'))
+ })
+
+ await it('should hash distinct labels to distinct values', () => {
+ assert.notStrictEqual(hashLabel('VOLTAGE_NOISE'), hashLabel('POWER_NOISE'))
+ assert.notStrictEqual(hashLabel('PROFILE_PICK'), hashLabel('INITIAL_SOC'))
+ })
+
+ await it('should be stable across calls (documented as such)', () => {
+ // Regression guard: if the FNV-1a constants ever change, this test
+ // catches the resulting sequence shift.
+ assert.strictEqual(hashLabel('SEED'), 0x8e37556c)
+ assert.strictEqual(hashLabel('A'), 0xc40bf6cc)
+ })
+ })
+
+ await describe('deriveSeed', async () => {
+ await it('should produce independent streams per label', () => {
+ const root = 42
+ const seedA = deriveSeed(root, 'A')
+ const seedB = deriveSeed(root, 'B')
+ assert.notStrictEqual(seedA, seedB)
+
+ const streamA = mulberry32(seedA)
+ const streamB = mulberry32(seedB)
+ // Two streams from different labels should not lock-step.
+ let same = 0
+ for (let i = 0; i < 100; i++) {
+ if (streamA() === streamB()) {
+ same++
+ }
+ }
+ assert.ok(same < 5, `expected mostly different samples, ${same.toString()} matched`)
+ })
+
+ await it('should be non-shifting: adding a new label leaves existing streams intact', () => {
+ const root = 1234
+ const seed1 = deriveSeed(root, 'STREAM_A')
+ const seed2 = deriveSeed(root, 'STREAM_B')
+ // Introduce a fictitious new consumer.
+ const seedNew = deriveSeed(root, 'STREAM_NEW')
+ // Existing seeds must be unchanged.
+ assert.strictEqual(seed1, deriveSeed(root, 'STREAM_A'))
+ assert.strictEqual(seed2, deriveSeed(root, 'STREAM_B'))
+ // The new stream must be distinct from the existing ones.
+ assert.notStrictEqual(seedNew, seed1)
+ assert.notStrictEqual(seedNew, seed2)
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for coherent MeterValues strategy dispatch.
+ * @description Verifies the strategy gate in buildMeterValue:
+ * - flag off / absent → random/fixed code path unchanged.
+ * - flag on + session → coherent path emits coherent SampledValues.
+ * - flag on + no session → falls back to the random/fixed path.
+ *
+ * Covers the strategy gate boundary: the gate runs AFTER the versioned
+ * SampledValue dispatcher is constructed and BEFORE the random/fixed
+ * measurand generation, so the coherent path can emit versioned
+ * SampledValues without duplicating the dispatcher logic.
+ */
+
+import assert from 'node:assert/strict'
+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 { buildMeterValue } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import {
+ CurrentType,
+ MeterValueMeasurand,
+ OCPPVersion,
+ StandardParametersKey,
+ Voltage,
+} from '../../../src/types/index.js'
+import { Constants } from '../../../src/utils/index.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_METER_VALUES_INTERVAL_MS,
+ TEST_TRANSACTION_ID,
+} from '../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../helpers/StationHelpers.js'
+
+const energyTemplate: SampledValueTemplate = {
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '0',
+} as unknown as SampledValueTemplate
+
+await describe('StrategyDispatch', async () => {
+ let station: ChargingStation
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await describe('flag absent (default random/fixed)', async () => {
+ beforeEach(() => {
+ const { station: s } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station = s
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = [energyTemplate]
+ connectorStatus.transactionId = TEST_TRANSACTION_ID
+ }
+ })
+
+ await it('should not create coherent sessions', () => {
+ assert.strictEqual(station.getCoherentSession(TEST_TRANSACTION_ID), undefined)
+ })
+
+ await it('should return no-op when destroying a non-existent session', () => {
+ assert.strictEqual(station.destroyCoherentSession(TEST_TRANSACTION_ID), false)
+ })
+
+ await it('should build a MeterValue via the random/fixed path', () => {
+ const meterValue = buildMeterValue(
+ station,
+ TEST_TRANSACTION_ID,
+ TEST_METER_VALUES_INTERVAL_MS
+ )
+ assert.ok(meterValue.timestamp instanceof Date)
+ assert.ok(Array.isArray(meterValue.sampledValue))
+ })
+ })
+
+ await describe('flag on + no EV profiles configured', async () => {
+ beforeEach(() => {
+ const { station: s } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ stationInfo: {
+ coherentMeterValues: true,
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station = s
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = [energyTemplate]
+ connectorStatus.transactionId = TEST_TRANSACTION_ID
+ }
+ })
+
+ await it('should not create a coherent session without profiles', () => {
+ const created = station.createCoherentSession(TEST_TRANSACTION_ID, 1)
+ assert.strictEqual(created, undefined)
+ assert.strictEqual(station.getCoherentSession(TEST_TRANSACTION_ID), undefined)
+ })
+
+ await it('should fall through to the random/fixed path in buildMeterValue', () => {
+ // Without a session buildMeterValue must NOT call coherent code.
+ // Random/fixed path returns non-empty when Energy template is present.
+ addConfigurationKey(
+ station,
+ StandardParametersKey.MeterValuesSampledData,
+ 'Energy.Active.Import.Register'
+ )
+ const meterValue = buildMeterValue(
+ station,
+ TEST_TRANSACTION_ID,
+ TEST_METER_VALUES_INTERVAL_MS
+ )
+ assert.ok(meterValue.sampledValue.length > 0)
+ })
+ })
+
+ await describe('flag on + injected session', async () => {
+ beforeEach(() => {
+ const { station: s } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ stationInfo: {
+ coherentMeterValues: true,
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station = s
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = [
+ {
+ measurand: MeterValueMeasurand.STATE_OF_CHARGE,
+ unit: 'Percent',
+ } as unknown as SampledValueTemplate,
+ ]
+ connectorStatus.transactionId = TEST_TRANSACTION_ID
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.energyActiveImportRegisterValue = 0
+ }
+ })
+
+ await it('should not create a session when profiles are missing', () => {
+ // Simulate missing profiles: createCoherentSession is a no-op.
+ assert.strictEqual(station.createCoherentSession(TEST_TRANSACTION_ID, 1), undefined)
+ })
+
+ await it('should route through the coherent path when a session is injected directly', () => {
+ const now = Date.now()
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ 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, StandardParametersKey.MeterValuesSampledData, 'SoC')
+ const registerBefore = station.getConnectorStatus(1)?.energyActiveImportRegisterValue ?? -1
+ const meterValue = buildMeterValue(
+ station,
+ TEST_TRANSACTION_ID,
+ TEST_METER_VALUES_INTERVAL_MS
+ )
+ const registerAfter = station.getConnectorStatus(1)?.energyActiveImportRegisterValue ?? -1
+
+ // Register must advance because coherent path owns updates.
+ assert.ok(registerAfter >= registerBefore)
+ // Only SoC template configured → SampledValue count matches.
+ assert.ok(meterValue.sampledValue.length <= 1)
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Integration-style test for coherent MeterValues over an OCPP 1.6 transaction.
+ * @description Simulates StartTransaction → 3 MeterValues samples → StopTransaction
+ * with `coherentMeterValues: true`, a directly-injected coherent session
+ * (bypasses on-disk profile file loading in tests), and a fixed seed.
+ *
+ * Covers: per-sample INV-1/INV-2/INV-3 invariants, `meterStop == last coherent
+ * register`, and fixed-seed determinism across a full StartTransaction →
+ * MeterValues → StopTransaction cycle.
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type { ChargingStation, CoherentSession } from '../../../../src/charging-station/index.js'
+import type { OCPP16ResponseService } from '../../../../src/charging-station/ocpp/1.6/OCPP16ResponseService.js'
+import type {
+ MeterValue,
+ OCPP16StartTransactionRequest,
+ OCPP16StartTransactionResponse,
+ OCPP16StopTransactionRequest,
+ OCPP16StopTransactionResponse,
+ SampledValueTemplate,
+} from '../../../../src/types/index.js'
+
+import { addConfigurationKey } from '../../../../src/charging-station/index.js'
+import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
+import { buildMeterValue } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import {
+ CurrentType,
+ MeterValueMeasurand,
+ OCPP16AuthorizationStatus,
+ OCPP16MeterValueUnit,
+ OCPP16RequestCommand,
+ StandardParametersKey,
+ Voltage,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import {
+ flushMicrotasks,
+ setupConnectorWithTransaction,
+ standardCleanup,
+ withMockTimers,
+} from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_ID_TAG, TEST_METER_VALUES_INTERVAL_MS } from '../../ChargingStationTestConstants.js'
+import { createOCPP16ResponseTestContext, setMockRequestHandler } from './OCPP16TestUtils.js'
+
+const CONNECTOR_ID = 1
+const TRANSACTION_ID = 4242
+
+const injectSession = (station: ChargingStation, startMs: number): CoherentSession => {
+ const session: CoherentSession = {
+ connectorId: CONNECTOR_ID,
+ currentType: CurrentType.AC,
+ numberOfPhases: 3,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [
+ { powerFraction: 1, socPercent: 0 },
+ { powerFraction: 1, socPercent: 80 },
+ { powerFraction: 0.5, socPercent: 100 },
+ ],
+ id: 'test-ev',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: startMs,
+ socPercent: 30,
+ transactionId: TRANSACTION_ID,
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ station.__injectCoherentSession(TRANSACTION_ID, session)
+ return session
+}
+
+const configureMeterValueTemplates = (station: ChargingStation): void => {
+ const templates: SampledValueTemplate[] = [
+ {
+ measurand: MeterValueMeasurand.STATE_OF_CHARGE,
+ unit: 'Percent',
+ } as unknown as SampledValueTemplate,
+ { measurand: MeterValueMeasurand.VOLTAGE, unit: 'V' } as unknown as SampledValueTemplate,
+ {
+ measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ } as unknown as SampledValueTemplate,
+ { measurand: MeterValueMeasurand.CURRENT_IMPORT, unit: 'A' } as unknown as SampledValueTemplate,
+ {
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ } as unknown as SampledValueTemplate,
+ ]
+ const connectorStatus = station.getConnectorStatus(CONNECTOR_ID)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = templates
+ }
+ addConfigurationKey(
+ station,
+ StandardParametersKey.MeterValuesSampledData,
+ 'SoC,Voltage,Power.Active.Import,Current.Import,Energy.Active.Import.Register'
+ )
+}
+
+const findValue = (mv: MeterValue, measurand: MeterValueMeasurand): number | undefined => {
+ const sv = mv.sampledValue.find(x => x.measurand === measurand)
+ if (sv == null) {
+ return undefined
+ }
+ return Number(sv.value)
+}
+
+const runTransaction = async (
+ station: ChargingStation,
+ responseService: OCPP16ResponseService,
+ seed: number,
+ startMs: number
+): Promise<{ meterValues: MeterValue[]; stopEnergyWh: number }> => {
+ // Configure feature flag + seed on the station's live stationInfo.
+ const stationInfo = station.stationInfo
+ if (stationInfo == null) {
+ throw new Error('stationInfo missing')
+ }
+ stationInfo.coherentMeterValues = true
+ stationInfo.randomSeed = seed
+
+ configureMeterValueTemplates(station)
+
+ // Dispatch StartTransaction response so the connector gets a live transaction id.
+ const request: OCPP16StartTransactionRequest = {
+ connectorId: CONNECTOR_ID,
+ idTag: TEST_ID_TAG,
+ meterStart: 0,
+ timestamp: new Date(),
+ }
+ const response: OCPP16StartTransactionResponse = {
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ transactionId: TRANSACTION_ID,
+ }
+ await responseService.responseHandler(
+ station,
+ OCPP16RequestCommand.START_TRANSACTION,
+ response,
+ request
+ )
+
+ // Inject a coherent session directly (skipping on-disk profile file
+ // loading which is validated elsewhere in EvProfiles.test.ts).
+ injectSession(station, startMs)
+
+ // Emit 3 samples. Scope `Date.now` per iteration via node:test mock so
+ // the mock is atomically restored on each loop cycle even if the buildMeterValue
+ // call throws, avoiding global mutation leaks under concurrent runners.
+ const meterValues: MeterValue[] = []
+ for (let i = 0; i < 3; i++) {
+ const nowMs = startMs + TEST_METER_VALUES_INTERVAL_MS * (i + 1)
+ const nowMock = mock.method(Date, 'now', () => nowMs)
+ try {
+ const mv = buildMeterValue(station, TRANSACTION_ID, TEST_METER_VALUES_INTERVAL_MS)
+ meterValues.push(mv)
+ } finally {
+ nowMock.mock.restore()
+ }
+ }
+
+ // StopTransaction — meterStop should equal the connector register.
+ const connectorStatus = station.getConnectorStatus(CONNECTOR_ID)
+ assert.ok(connectorStatus != null, 'connector status missing')
+ const meterStop = Math.round(connectorStatus.energyActiveImportRegisterValue ?? 0)
+ const stopRequest: OCPP16StopTransactionRequest = {
+ idTag: TEST_ID_TAG,
+ meterStop,
+ timestamp: new Date(),
+ transactionId: TRANSACTION_ID,
+ }
+ const stopResponse: OCPP16StopTransactionResponse = {
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ }
+ await responseService.responseHandler(
+ station,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ stopResponse,
+ stopRequest
+ )
+
+ return { meterValues, stopEnergyWh: meterStop }
+}
+
+await describe('OCPP16CoherentMeterValues', async () => {
+ let station: ChargingStation
+ let responseService: OCPP16ResponseService
+
+ beforeEach(() => {
+ const ctx = createOCPP16ResponseTestContext()
+ station = ctx.station
+ responseService = ctx.responseService
+ setMockRequestHandler(station, async () => Promise.resolve({}))
+ mock.method(OCPP16ServiceUtils, 'startUpdatedMeterValues', () => {
+ /* noop */
+ })
+ mock.method(OCPP16ServiceUtils, 'stopUpdatedMeterValues', () => {
+ /* noop */
+ })
+ // Provide a valid MeterValues template so buildTransactionBeginMeterValue
+ // succeeds during StartTransaction handling.
+ for (const { connectorId } of station.iterateConnectors(true)) {
+ const cs = station.getConnectorStatus(connectorId)
+ if (cs != null) {
+ cs.MeterValues = [{ unit: OCPP16MeterValueUnit.WATT_HOUR, value: '0' }]
+ }
+ }
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should emit invariant-satisfying MeterValues and meterStop == last register', async () => {
+ const startMs = 1_700_000_000_000
+ const { meterValues, stopEnergyWh } = await runTransaction(
+ station,
+ responseService,
+ 42,
+ startMs
+ )
+
+ assert.strictEqual(meterValues.length, 3)
+ let prevEnergy = -Infinity
+ let prevSoc = -Infinity
+ for (let i = 0; i < meterValues.length; i++) {
+ const mv = meterValues[i]
+ const voltage = findValue(mv, MeterValueMeasurand.VOLTAGE)
+ const current = findValue(mv, MeterValueMeasurand.CURRENT_IMPORT)
+ const power = findValue(mv, MeterValueMeasurand.POWER_ACTIVE_IMPORT)
+ const energy = findValue(mv, MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER)
+ const soc = findValue(mv, MeterValueMeasurand.STATE_OF_CHARGE)
+ assert.ok(
+ voltage != null && current != null && power != null && energy != null && soc != null
+ )
+ // INV-1: P = V·I·phases within ±3 W after rounding (AC 3-phase).
+ const expectedP = voltage * current * 3
+ assert.ok(
+ Math.abs(power - expectedP) <= 3,
+ `sample ${i.toString()}: |P - V·I·3|=${Math.abs(power - expectedP).toString()} exceeded 3W`
+ )
+ // INV-2 (SoC monotone non-decreasing) and INV-3 (E monotone non-decreasing).
+ assert.ok(energy >= prevEnergy, `sample ${i.toString()}: energy regressed`)
+ assert.ok(soc >= prevSoc, `sample ${i.toString()}: SoC regressed`)
+ prevEnergy = energy
+ prevSoc = soc
+ }
+
+ // Reconstruct the expected stop energy from emitted power samples
+ // (Σ P·Δt / 3.6e6) as an INDEPENDENT reference derived from the
+ // MeterValue stream — not from the register itself. Divergence
+ // between this reconstruction and the reported stop energy would
+ // indicate the register drifted away from the reported physics.
+ const MS_PER_HOUR = Constants.MS_PER_HOUR
+ let expectedAccumulatedWh = 0
+ for (const mv of meterValues) {
+ const powerW = findValue(mv, MeterValueMeasurand.POWER_ACTIVE_IMPORT)
+ assert.ok(powerW != null)
+ expectedAccumulatedWh += (powerW * TEST_METER_VALUES_INTERVAL_MS) / MS_PER_HOUR
+ }
+ assert.ok(
+ expectedAccumulatedWh > 0,
+ `expected some energy delivered across ${meterValues.length.toString()} samples`
+ )
+ assert.ok(
+ Math.abs(stopEnergyWh - expectedAccumulatedWh) <= 1,
+ `stopEnergyWh=${stopEnergyWh.toString()} diverged from Σ(P·Δt)=${expectedAccumulatedWh.toString()} Wh`
+ )
+ const lastMeterValue = meterValues.at(-1)
+ assert.ok(lastMeterValue != null, 'expected at least one MeterValue in stream')
+ const lastEnergy = findValue(lastMeterValue, MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER)
+ assert.ok(lastEnergy != null)
+ // Cross-check: stopEnergyWh must match the last MV register within the
+ // same 1 Wh tolerance as the independent Σ(P·Δt) check. Both are read
+ // from the emitted OCPP stream (final register value) vs. the response's
+ // StopTransaction meterStop — divergence would indicate that meterStop
+ // and the final register drifted apart between emission and finalization.
+ assert.ok(
+ Math.abs(stopEnergyWh - lastEnergy) <= 1,
+ `stopEnergyWh=${stopEnergyWh.toString()} vs last MV register=${lastEnergy.toString()}`
+ )
+ })
+
+ await it('should produce identical MeterValues sequences for identical seed + transactionId', async () => {
+ const startMs = 1_700_000_000_000
+ const runA = await runTransaction(station, responseService, 99, startMs)
+
+ // Fresh station for second run — same seed + transactionId must reproduce.
+ const ctx2 = createOCPP16ResponseTestContext()
+ const station2 = ctx2.station
+ const responseService2 = ctx2.responseService
+ setMockRequestHandler(station2, async () => Promise.resolve({}))
+ for (const { connectorId } of station2.iterateConnectors(true)) {
+ const cs = station2.getConnectorStatus(connectorId)
+ if (cs != null) {
+ cs.MeterValues = [{ unit: OCPP16MeterValueUnit.WATT_HOUR, value: '0' }]
+ }
+ }
+ const runB = await runTransaction(station2, responseService2, 99, startMs)
+
+ // Compare stringified SampledValues for byte-level equality of numeric outputs.
+ const serialize = (mv: MeterValue): string =>
+ JSON.stringify(
+ mv.sampledValue.map(sv => ({
+ measurand: sv.measurand,
+ value: sv.value,
+ }))
+ )
+ for (let i = 0; i < 3; i++) {
+ assert.strictEqual(
+ serialize(runA.meterValues[i]),
+ serialize(runB.meterValues[i]),
+ `MeterValue ${i.toString()} diverged between runs`
+ )
+ }
+ assert.strictEqual(runA.stopEnergyWh, runB.stopEnergyWh)
+ })
+
+ await it('should not create a session when StartTransaction is REJECTED', async () => {
+ const stationInfo = station.stationInfo
+ if (stationInfo != null) {
+ stationInfo.coherentMeterValues = true
+ stationInfo.randomSeed = 1
+ }
+ configureMeterValueTemplates(station)
+ const request: OCPP16StartTransactionRequest = {
+ connectorId: CONNECTOR_ID,
+ idTag: TEST_ID_TAG,
+ meterStart: 0,
+ timestamp: new Date(),
+ }
+ const rejectedResponse: OCPP16StartTransactionResponse = {
+ idTagInfo: { status: OCPP16AuthorizationStatus.BLOCKED },
+ transactionId: 7,
+ }
+ await responseService.responseHandler(
+ station,
+ OCPP16RequestCommand.START_TRANSACTION,
+ rejectedResponse,
+ request
+ )
+ assert.strictEqual(station.getCoherentSession(7), undefined)
+ })
+
+ await it('should destroy the coherent session even when the station stops during postTransactionDelay', async t => {
+ const stationInfo = station.stationInfo
+ assert.ok(stationInfo != null, 'stationInfo should be defined')
+ stationInfo.coherentMeterValues = true
+ stationInfo.randomSeed = 42
+ stationInfo.postTransactionDelay = 5
+ station.started = true
+ setupConnectorWithTransaction(station, CONNECTOR_ID, { transactionId: TRANSACTION_ID })
+ injectSession(station, 1_700_000_000_000)
+ assert.ok(
+ station.getCoherentSession(TRANSACTION_ID) != null,
+ 'session should exist before stop'
+ )
+
+ const stopRequest: OCPP16StopTransactionRequest = {
+ idTag: TEST_ID_TAG,
+ meterStop: 0,
+ timestamp: new Date(),
+ transactionId: TRANSACTION_ID,
+ }
+ const stopResponse: OCPP16StopTransactionResponse = {
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ }
+
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const promise = responseService.responseHandler(
+ station,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ stopResponse,
+ stopRequest
+ )
+ for (let i = 0; i < 10; i++) {
+ await flushMicrotasks()
+ }
+ station.started = false
+ t.mock.timers.tick(5000)
+ for (let i = 0; i < 10; i++) {
+ await flushMicrotasks()
+ }
+ await promise
+ })
+
+ assert.strictEqual(
+ station.getCoherentSession(TRANSACTION_ID),
+ undefined,
+ 'coherent session leaked when station stopped during postTransactionDelay'
+ )
+ })
+})
timestamp: new Date(),
}
const responsePayload: OCPP16StartTransactionResponse = {
- // INVALID + flag=true exercises the regression: without the pre-Start
- // guard the override would skip the abort path. The guard MUST still
- // win. ACCEPTED would not exercise the flag-vs-guard interaction.
+ // INVALID + flag=true exercises the pre-Start guard: the override
+ // must not bypass the abort path. ACCEPTED would not exercise the
+ // flag-vs-guard interaction.
idTagInfo: { status: OCPP16AuthorizationStatus.INVALID },
transactionId: 22,
}
--- /dev/null
+/**
+ * @file Tests for OCPP20ResponseService coherent MeterValues session wiring.
+ * @description Verifies that TransactionEvent(Started) creates
+ * a coherent MeterValues session on OCPP 2.0.1, mirroring the OCPP 1.6 path
+ * in `OCPP16ResponseService.handleResponseStartTransaction`. Also verifies
+ * the guards: opt-in feature flag and idToken acceptance.
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type {
+ OCPP20TransactionEventRequest,
+ OCPP20TransactionEventResponse,
+ UUIDv4,
+} from '../../../../src/types/index.js'
+
+import {
+ createTestableResponseService,
+ type TestableOCPP20ResponseService,
+} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
+import {
+ OCPP20AuthorizationStatusEnumType,
+ OCPP20TransactionEventEnumType,
+ OCPPVersion,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import {
+ setupConnectorWithTransaction,
+ standardCleanup,
+} from '../../../helpers/TestLifecycleHelpers.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_TRANSACTION_UUID,
+} from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../helpers/StationHelpers.js'
+import { buildTransactionEventRequest } from './OCPP20ResponseServiceTestUtils.js'
+
+const buildStartedRequest = (transactionId: UUIDv4): OCPP20TransactionEventRequest => {
+ const req = buildTransactionEventRequest(transactionId, OCPP20TransactionEventEnumType.Started)
+ return req
+}
+
+await describe('OCPP20ResponseServiceCoherentSession', async () => {
+ let station: ChargingStation
+ let testable: TestableOCPP20ResponseService
+ let createSpy: ReturnType<typeof mock.method<ChargingStation, 'createCoherentSession'>>
+
+ beforeEach(() => {
+ const { station: mockStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ stationInfo: {
+ coherentMeterValues: true,
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station = mockStation
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.transactionId = TEST_TRANSACTION_UUID
+ }
+ createSpy = mock.method(station, 'createCoherentSession', () => undefined)
+ const responseService = new OCPP20ResponseService()
+ testable = createTestableResponseService(responseService)
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should create a coherent session on Started with Accepted idToken', async () => {
+ const request = buildStartedRequest(TEST_TRANSACTION_UUID)
+ const response: OCPP20TransactionEventResponse = {
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+ }
+
+ await testable.handleResponseTransactionEvent(station, response, request)
+
+ assert.strictEqual(createSpy.mock.calls.length, 1, 'createCoherentSession must fire once')
+ assert.strictEqual(createSpy.mock.calls[0].arguments[0], TEST_TRANSACTION_UUID)
+ assert.strictEqual(createSpy.mock.calls[0].arguments[1], 1)
+ })
+
+ await it('should create a coherent session on Started with idTokenInfo omitted (implicit accept)', async () => {
+ const request = buildStartedRequest(TEST_TRANSACTION_UUID)
+ // No idTokenInfo → treated as Accepted by handleResponseTransactionEvent.
+ const response: OCPP20TransactionEventResponse = {}
+
+ await testable.handleResponseTransactionEvent(station, response, request)
+
+ assert.strictEqual(createSpy.mock.calls.length, 1)
+ })
+
+ await it('should NOT create a coherent session on rejected idToken without force override', async () => {
+ const request = buildStartedRequest(TEST_TRANSACTION_UUID)
+ const response: OCPP20TransactionEventResponse = {
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Invalid },
+ }
+
+ await testable.handleResponseTransactionEvent(station, response, request)
+
+ assert.strictEqual(
+ createSpy.mock.calls.length,
+ 0,
+ 'session must not be created when idToken rejected and force override is off'
+ )
+ })
+
+ await it('should create a coherent session on rejected idToken WHEN forceTransactionOnInvalidIdToken=true', async () => {
+ assert.ok(station.stationInfo != null)
+ station.stationInfo.forceTransactionOnInvalidIdToken = true
+ const request = buildStartedRequest(TEST_TRANSACTION_UUID)
+ const response: OCPP20TransactionEventResponse = {
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Invalid },
+ }
+
+ await testable.handleResponseTransactionEvent(station, response, request)
+
+ assert.strictEqual(
+ createSpy.mock.calls.length,
+ 1,
+ 'session must be created when force override is enabled, mirroring OCPP 1.6'
+ )
+ })
+
+ await it('should NOT create a session for non-Started event types', async () => {
+ const request = buildTransactionEventRequest(
+ TEST_TRANSACTION_UUID,
+ OCPP20TransactionEventEnumType.Updated
+ )
+ const response: OCPP20TransactionEventResponse = {
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted },
+ }
+
+ await testable.handleResponseTransactionEvent(station, response, request)
+
+ assert.strictEqual(
+ createSpy.mock.calls.length,
+ 0,
+ 'session must only be created on eventType=Started'
+ )
+ })
+})
import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
-import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type { ChargingStation, CoherentSession } from '../../../../src/charging-station/index.js'
import type { ConnectorStatus } from '../../../../src/types/index.js'
import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
-import { OCPPVersion } from '../../../../src/types/index.js'
+import { CurrentType, OCPPVersion, Voltage } from '../../../../src/types/index.js'
import {
flushMicrotasks,
standardCleanup,
} from '../../../helpers/TestLifecycleHelpers.js'
import { createMockChargingStation } from '../../helpers/StationHelpers.js'
-await describe('OCPP20ServiceUtils — PostTransactionDelay', async () => {
+await describe('OCPP20ServiceUtilsPostTransactionDelay', async () => {
let station: ChargingStation
let connectorStatus: ConnectorStatus
let requestHandlerMock: ReturnType<typeof mock.fn>
})
station = result.station
const cs = station.getConnectorStatus(1)
- if (cs == null) {
- throw new Error('Expected connector 1 to exist')
- }
+ assert.ok(cs != null, 'Expected connector 1 to exist')
connectorStatus = cs
connectorStatus.transactionStarted = true
connectorStatus.transactionId = 'tx-1'
'No StatusNotification should be sent'
)
})
+
+ await it('should destroy the coherent session even when the station stops during delay', async t => {
+ const stubSession: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 1,
+ chargingCurve: [{ powerFraction: 0, socPercent: 0 }],
+ id: 'stub',
+ initialSocPercentMax: 0,
+ initialSocPercentMin: 0,
+ maxPowerW: 1,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 0,
+ transactionId: 'tx-1',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ station.__injectCoherentSession('tx-1', stubSession)
+ assert.ok(
+ station.getCoherentSession('tx-1') != null,
+ 'session should exist before cleanupEndedTransaction'
+ )
+
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const promise = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus)
+ for (let i = 0; i < 10; i++) {
+ await flushMicrotasks()
+ }
+ station.started = false
+ t.mock.timers.tick(3000)
+ for (let i = 0; i < 10; i++) {
+ await flushMicrotasks()
+ }
+ await promise
+ })
+
+ assert.strictEqual(
+ station.getCoherentSession('tx-1'),
+ undefined,
+ 'coherent session leaked when station stopped during postTransactionDelay'
+ )
+ })
})