- unpushed
- logform
- mnemonist
+ - multiton
- poolifier
- measurand
- measurands
import { parentPort } from 'node:worker_threads'
import { type RawData, WebSocket } from 'ws'
+import type { CoherentSession } from './meter-values/index.js'
+
import { BaseError, OCPPError } from '../exception/index.js'
import { PerformanceStatistics } from '../performance/index.js'
import {
} from '../utils/index.js'
import { AutomaticTransactionGenerator } from './AutomaticTransactionGenerator.js'
import { ChargingStationWorkerBroadcastChannel } from './broadcast-channel/ChargingStationWorkerBroadcastChannel.js'
+import { CoherentMeterValuesManager } from './CoherentMeterValuesManager.js'
import {
addConfigurationKey,
deleteConfigurationKey,
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}.
+ * **Test seam only** — delegates to
+ * {@link CoherentMeterValuesManager.injectSession}, which enforces the
+ * `NODE_ENV === 'production'` guard.
* @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)
+ CoherentMeterValuesManager.getInstance(this)?.injectSession(transactionId, session)
}
/**
/**
* 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.
+ * Idempotent. Delegates to
+ * {@link CoherentMeterValuesManager.createSession}; returns `undefined`
+ * when coherent mode is disabled or no valid EV profile file is loaded.
+ *
+ * Uses `peekInstance` (lookup-only): opt-in stations warm up the
+ * manager at initialize, so subsequent calls find it in the cache;
+ * non-opt-in stations never allocate a manager because
+ * `manager.createSession` would return `undefined` anyway.
* @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
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
+ return CoherentMeterValuesManager.peekInstance(this)?.createSession(transactionId, connectorId)
}
/**
}
}
AutomaticTransactionGenerator.deleteInstance(this)
+ CoherentMeterValuesManager.deleteInstance(this)
PerformanceStatistics.deleteInstance(this.stationInfo?.hashId)
OCPPAuthServiceFactory.clearInstance(this)
if (this.stationInfo != null) {
/**
* 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).
+ * call from every reset/stop/disconnect path. Delegates to
+ * {@link CoherentMeterValuesManager.destroySession}, which disposes the
+ * module-scope per-session runtime state (voltage-noise PRNG closure).
+ * Uses `peekInstance` so non-opt-in stations never allocate a manager
+ * on a transaction-end tear-down.
* @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)
+ return CoherentMeterValuesManager.peekInstance(this)?.destroySession(transactionId) ?? false
}
/**
}
/**
- * Retrieves the coherent session for a transaction, if any.
+ * Retrieves the coherent session for a transaction, if any. Delegates
+ * to {@link CoherentMeterValuesManager.getSession}. Uses `peekInstance`
+ * because this method is reached from the unconditional strategy gate
+ * in `OCPPServiceUtils.buildMeterValue` on every MeterValue tick — a
+ * `getInstance` here would allocate a manager for every station that
+ * ever emits a MeterValue, opt-in or not.
* @param transactionId - Transaction identifier.
* @returns The session or `undefined` when none exists.
*/
public getCoherentSession (transactionId: number | string): CoherentSession | undefined {
- return this.coherentSessions.get(transactionId)
+ return CoherentMeterValuesManager.peekInstance(this)?.getSession(transactionId)
}
public getConnectionTimeout (): number {
// 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()
+ CoherentMeterValuesManager.peekInstance(this)?.dispose()
this.stopping = false
}
} else {
}
}
this.saveStationInfo()
- this.initializeCoherentEvProfiles()
+ // Warm up the coherent MeterValues manager on opt-in so
+ // EV-profile-file warnings surface at startup, and reload profiles
+ // on every subsequent `initialize()` (reset, template file change)
+ // to propagate template mutations. On opt-out we deliberately
+ // leave any pre-existing manager in place: new sessions are
+ // already blocked by the `coherentMeterValues` gate in
+ // `createSession`, and in-flight sessions drain via
+ // `destroyCoherentSession` on transaction end — preserving
+ // provenance of transactions started before the flag flip.
+ if (this.stationInfo.coherentMeterValues === true) {
+ CoherentMeterValuesManager.getInstance(this)?.reloadEvProfiles()
+ }
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`
--- /dev/null
+// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+/**
+ * @file Per-station coherent MeterValues lifecycle owner.
+ * @description Holds the EV profile file, the active-session Map, and the
+ * create/destroy/inject lifecycle for physics-based coherent
+ * MeterValues. Extracted from {@link ChargingStation} to keep the
+ * strictly opt-in coherent surface off the main class body. Follows the
+ * per-station multiton pattern of `AutomaticTransactionGenerator` /
+ * `IdTagsCache` / `SharedLRUCache`: one instance per `stationInfo.hashId`.
+ */
+
+import type { ChargingStation } from './ChargingStation.js'
+
+import { BaseError } from '../exception/index.js'
+import { logger } from '../utils/index.js'
+import { getEvProfilesFile } from './Helpers.js'
+import { disposeCoherentSessionRuntime } from './meter-values/CoherentMeterValuesGenerator.js'
+import {
+ type CoherentSession,
+ createCoherentSession,
+ type EvProfilesFile,
+ loadEvProfilesFile,
+ resolveRootSeed,
+} from './meter-values/index.js'
+
+const moduleName = 'CoherentMeterValuesManager'
+
+/**
+ * Per-station owner of coherent MeterValues state. Instances are keyed by
+ * `stationInfo.hashId` and shared across every OCPP handler and service
+ * util that touches a station's transactions.
+ *
+ * The manager is created eagerly at station initialization when
+ * `coherentMeterValues=true`; stations with the option off never allocate
+ * a manager.
+ */
+export class CoherentMeterValuesManager {
+ private static readonly instances: Map<string, CoherentMeterValuesManager> = new Map<
+ string,
+ CoherentMeterValuesManager
+ >()
+
+ private readonly chargingStation: ChargingStation
+ private evProfiles?: EvProfilesFile
+ private readonly sessions: Map<number | string, CoherentSession>
+
+ private constructor (chargingStation: ChargingStation) {
+ this.chargingStation = chargingStation
+ this.sessions = new Map<number | string, CoherentSession>()
+ this.reloadEvProfiles()
+ }
+
+ /**
+ * Drops the manager instance for the station after disposing every
+ * in-flight session runtime. Idempotent — safe to call from every
+ * shutdown path.
+ * @param chargingStation - Owning station.
+ * @returns `true` when an instance was removed, `false` otherwise.
+ */
+ public static deleteInstance (chargingStation: ChargingStation): boolean {
+ const hashId = chargingStation.stationInfo?.hashId
+ if (hashId == null) {
+ return false
+ }
+ const manager = CoherentMeterValuesManager.instances.get(hashId)
+ if (manager == null) {
+ return false
+ }
+ manager.dispose()
+ return CoherentMeterValuesManager.instances.delete(hashId)
+ }
+
+ /**
+ * Returns the manager for the station, constructing on first call. The
+ * constructor eagerly loads the EV profile file (fail-soft: warnings
+ * logged, coherent session creation silently disabled on error).
+ *
+ * Use this only at the opt-in eager warm-up in
+ * `ChargingStation.initialize` and at the `injectSession` test seam
+ * (where the production-guard `BaseError` throw must remain
+ * reachable). Every other read/write path — including `createSession`
+ * — MUST use {@link peekInstance}: the eager warm-up guarantees
+ * opt-in stations already have their instance entry, and non-opt-in
+ * stations must not allocate on paths reached from the unconditional
+ * strategy gate in `OCPPServiceUtils.buildMeterValue`.
+ * @param chargingStation - Owning station.
+ * @returns The station's manager, or `undefined` iff
+ * `stationInfo.hashId` is not yet resolved (early-bootstrap failure
+ * mode — the sole path that returns `undefined`).
+ */
+ public static getInstance (
+ chargingStation: ChargingStation
+ ): CoherentMeterValuesManager | undefined {
+ const hashId = chargingStation.stationInfo?.hashId
+ if (hashId == null) {
+ return undefined
+ }
+ let manager = CoherentMeterValuesManager.instances.get(hashId)
+ if (manager == null) {
+ manager = new CoherentMeterValuesManager(chargingStation)
+ CoherentMeterValuesManager.instances.set(hashId, manager)
+ }
+ return manager
+ }
+
+ /**
+ * Lookup-only sibling of {@link getInstance}. Returns the existing
+ * manager for the station or `undefined` — never constructs. Use on
+ * every non-warm-up path that must not allocate a manager on behalf
+ * of non-opt-in stations, including `createSession`, reads
+ * (`getSession`), and idempotent teardown (`destroySession`,
+ * `stop()` dispose) — the strategy gate in
+ * `OCPPServiceUtils.buildMeterValue` reaches these paths
+ * unconditionally on every MeterValue tick.
+ *
+ * Load-bearing invariant: `ChargingStation.initialize` MUST call
+ * {@link getInstance} for opt-in stations before any subsequent write
+ * path is reached; otherwise `createCoherentSession` on an opt-in
+ * station silently no-ops because the instances-map miss returns
+ * `undefined`.
+ * @param chargingStation - Owning station.
+ * @returns The station's existing manager, or `undefined` when no
+ * manager has been created (station is not opted in, or
+ * `stationInfo.hashId` is not yet resolved).
+ */
+ public static peekInstance (
+ chargingStation: ChargingStation
+ ): CoherentMeterValuesManager | undefined {
+ const hashId = chargingStation.stationInfo?.hashId
+ if (hashId == null) {
+ return undefined
+ }
+ return CoherentMeterValuesManager.instances.get(hashId)
+ }
+
+ /**
+ * 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 createSession (
+ transactionId: number | string,
+ connectorId: number
+ ): CoherentSession | undefined {
+ const existing = this.sessions.get(transactionId)
+ if (existing != null) {
+ return existing
+ }
+ if (this.chargingStation.stationInfo?.coherentMeterValues !== true) {
+ return undefined
+ }
+ if (this.evProfiles == null || this.evProfiles.profiles.length === 0) {
+ return undefined
+ }
+ const session = createCoherentSession(this.chargingStation, {
+ connectorId,
+ profiles: this.evProfiles.profiles,
+ rootSeed: resolveRootSeed(this.chargingStation.stationInfo),
+ transactionId,
+ })
+ if (session != null) {
+ this.sessions.set(transactionId, session)
+ }
+ return session
+ }
+
+ /**
+ * Removes the coherent session for a transaction and disposes its
+ * module-scope runtime state (voltage-noise PRNG closure). Idempotent
+ * — safe to call from every reset/stop/disconnect path.
+ * @param transactionId - Transaction identifier.
+ * @returns `true` when a session was removed, `false` otherwise.
+ */
+ public destroySession (transactionId: number | string | undefined): boolean {
+ if (transactionId == null) {
+ return false
+ }
+ disposeCoherentSessionRuntime(this.sessions.get(transactionId))
+ return this.sessions.delete(transactionId)
+ }
+
+ /**
+ * Disposes every in-flight session runtime and empties the store.
+ * Called by `ChargingStation.stop()` finalization so a subsequent
+ * restart cannot resurrect stale state or leak module-scope runtime
+ * PRNG closures.
+ */
+ public dispose (): void {
+ for (const session of this.sessions.values()) {
+ disposeCoherentSessionRuntime(session)
+ }
+ this.sessions.clear()
+ }
+
+ /**
+ * Retrieves the coherent session for a transaction, if any.
+ * @param transactionId - Transaction identifier.
+ * @returns The session or `undefined` when none exists.
+ */
+ public getSession (transactionId: number | string): CoherentSession | undefined {
+ return this.sessions.get(transactionId)
+ }
+
+ /**
+ * Injects a pre-built session directly into the store. **Test seam
+ * only** — never call from production code; enforced at runtime by a
+ * `NODE_ENV === 'production'` guard that throws {@link BaseError},
+ * mirroring the seam previously exposed on {@link ChargingStation}.
+ *
+ * Silently no-ops on non-opt-in stations
+ * (`stationInfo.coherentMeterValues !== true`) so on the
+ * production-backed injection path the type guard
+ * `isCoherentModeActive(session)` cannot activate the coherent wire
+ * path by injecting on a station that never opted in. Tests that
+ * mock `ChargingStation` may write to their own session store
+ * bypassing this seam; the mock is responsible for enforcing its own
+ * opt-in invariant where relevant.
+ * @param transactionId - Transaction identifier.
+ * @param session - Pre-built session.
+ * @throws {BaseError} When invoked in a production build.
+ */
+ public injectSession (transactionId: number | string, session: CoherentSession): void {
+ if (process.env.NODE_ENV === 'production') {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.injectSession: test-only seam called in production build`
+ )
+ }
+ if (this.chargingStation.stationInfo?.coherentMeterValues !== true) {
+ return
+ }
+ this.sessions.set(transactionId, session)
+ }
+
+ /**
+ * Loads (or reloads) the EV profile file referenced by the station
+ * template. Fail-soft: non-opt-in stations and any error leave
+ * `evProfiles` as `undefined`, which turns {@link createSession}
+ * into a no-op for this station. Called eagerly by the constructor
+ * so operators see profile-file warnings at startup rather than at
+ * first transaction, and re-invoked by `ChargingStation.initialize`
+ * on every reload so runtime mutations of `evProfilesFile` or the
+ * file contents propagate without a process restart.
+ */
+ public reloadEvProfiles (): void {
+ this.evProfiles = undefined
+ const stationInfo = this.chargingStation.stationInfo
+ if (stationInfo?.coherentMeterValues !== true) {
+ return
+ }
+ const evProfilesFile = getEvProfilesFile(stationInfo)
+ if (evProfilesFile == null) {
+ logger.warn(
+ `${this.chargingStation.logPrefix()} ${moduleName}.reloadEvProfiles: coherentMeterValues=true but no evProfilesFile is configured, coherent MeterValues disabled`
+ )
+ return
+ }
+ const loaded = loadEvProfilesFile(evProfilesFile, this.chargingStation.logPrefix())
+ if (loaded == null) {
+ logger.warn(
+ `${this.chargingStation.logPrefix()} ${moduleName}.reloadEvProfiles: EV profiles could not be loaded, coherent MeterValues disabled`
+ )
+ return
+ }
+ this.evProfiles = loaded
+ }
+}
}
/**
- * 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.
+ * Type guard indicating that the coherent strategy owns MeterValue
+ * construction for a transaction. Callers look up the session via the
+ * per-station manager and pass the result to this predicate; a non-null
+ * session implies coherent mode is active (sessions are only created
+ * when the opt-in `coherentMeterValues=true` template flag is set and a
+ * valid EV profile file is loaded).
+ * @param session - Session looked up from
+ * {@link ../../CoherentMeterValuesManager.CoherentMeterValuesManager.getSession}.
+ * @returns `true` when the coherent path should own MeterValue
+ * construction, narrowing `session` to `CoherentSession`.
*/
export const isCoherentModeActive = (
- context: ICoherentContext,
- transactionId: number | string
-): boolean => {
- if (context.stationInfo?.coherentMeterValues !== true) {
- return false
- }
- return context.getCoherentSession(transactionId) != null
-}
+ session: CoherentSession | undefined
+): session is CoherentSession => session != null
/**
* Unconditionally advances the connector energy registers by `deltaEnergyWh`.
* {@link advanceEnergyRegister} independent of whether the Energy
* measurand is emitted.
* @param context - Charging-station context.
- * @param transactionId - Active transaction identifier.
+ * @param session - Active coherent session for the transaction. Callers
+ * look this up via
+ * {@link ../../CoherentMeterValuesManager.CoherentMeterValuesManager.getSession}
+ * at the strategy gate and thread it through — the port no longer
+ * exposes session lookup.
* @param buildVersionedSampledValue - Versioned SampledValue builder from
* the OCPP dispatcher in `OCPPServiceUtils.buildMeterValue`.
* @param options - Per-sample parameters (interval, seed material, timestamp).
*/
export const buildCoherentMeterValue = (
context: ICoherentContext,
- transactionId: number | string,
+ session: CoherentSession,
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) {
+ const connectorStatus = context.getConnectorStatus(session.connectorId)
+ if (connectorStatus == null) {
logger.warn(
- `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: missing session or connector for transaction ${String(transactionId)}`
+ `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: missing connector ${session.connectorId.toString()} for transaction ${String(session.transactionId)}`
)
return { sampledValue: [], timestamp: new Date() }
}
/**
* Minimal structural interface consumed by the coherent generator. Only
* fields/methods actually used are declared to break any potential type
- * cycle back to `ChargingStation`.
+ * cycle back to `ChargingStation`. Per-transaction session lookup is
+ * intentionally NOT exposed here: sessions are threaded to the generator
+ * by the caller (the strategy gate), so this port describes only what
+ * the physics chain needs to query about the station itself.
*/
export interface ICoherentContext {
- getCoherentSession: (transactionId: number | string) => CoherentSession | undefined
getConnectorMaximumAvailablePower: (connectorId: number) => number
getConnectorStatus: (connectorId: number) => ConnectorStatus | undefined
getNumberOfPhases: () => number
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(
+ const coherentSession =
+ transactionId != null ? chargingStation.getCoherentSession(transactionId) : undefined
+ if (transactionId != null && isCoherentModeActive(coherentSession)) {
+ return OCPP16ServiceUtils.buildCoherentTransactionBeginMeterValue(
chargingStation,
- transactionId,
- 0,
- measurandsKey,
- OCPP16MeterValueContext.TRANSACTION_BEGIN
- ) as OCPP16MeterValue
+ transactionId
+ )
}
const meterValue = buildEmptyMeterValue() as OCPP16MeterValue
// Energy.Active.Import.Register measurand (default)
}
}
+ /**
+ * Coherent-path builder for the OCPP 1.6 transaction-begin MeterValue.
+ * Routes 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 the 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.
+ * @param chargingStation - Target charging station.
+ * @param transactionId - Active transaction identifier.
+ * @returns OCPP 1.6 MeterValue produced by the coherent physics chain.
+ */
+ private static buildCoherentTransactionBeginMeterValue (
+ chargingStation: ChargingStation,
+ transactionId: number | string
+ ): OCPP16MeterValue {
+ const startTxnSampledDataKey = OCPP16VendorParametersKey.StartTxnSampledData
+ const measurandsKey =
+ getConfigurationKey(chargingStation, startTxnSampledDataKey)?.value != null
+ ? startTxnSampledDataKey
+ : undefined
+ return buildMeterValue(
+ chargingStation,
+ transactionId,
+ 0,
+ measurandsKey,
+ OCPP16MeterValueContext.TRANSACTION_BEGIN
+ ) as OCPP16MeterValue
+ }
+
private static buildSignedSampledValue (
signingConfig: SigningConfig,
meterValue: number,
// 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)
+ const coherentSession = chargingStation.getCoherentSession(transactionId)
+ if (isCoherentModeActive(coherentSession)) {
return buildCoherentMeterValue(
chargingStation,
- transactionId,
+ coherentSession,
buildVersionedSampledValue,
{
intervalMs: interval,
nowMs: Date.now(),
- rootSeed,
+ rootSeed: resolveRootSeed(chargingStation.stationInfo),
},
context,
resolveEnabledMeasurands(chargingStation, measurandsKey)
const sessions = new Map<number | string, CoherentSession>()
const context: ICoherentContext = {
- getCoherentSession: (id: number | string) => sessions.get(id),
getConnectorMaximumAvailablePower: () => evseMax,
getConnectorStatus: () => connectorStatus,
getNumberOfPhases: () => numberOfPhases,
MeterValueMeasurand.VOLTAGE,
])
const before = connectorStatus.energyActiveImportRegisterValue ?? 0
- buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ buildCoherentMeterValue(context, session, passThroughBuilder, {
intervalMs: TEST_METER_VALUES_INTERVAL_MS,
nowMs: TEST_METER_VALUES_INTERVAL_MS,
rootSeed: 42,
[MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER],
MeterValueUnit.WATT_HOUR
)
- const meterValue = buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ const meterValue = buildCoherentMeterValue(context, session, passThroughBuilder, {
intervalMs: 3_600_000, // 1 h → 1 Wh per 1 W
nowMs: 3_600_000,
rootSeed: 42,
[MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER],
MeterValueUnit.KILO_WATT_HOUR
)
- const meterValue = buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ const meterValue = buildCoherentMeterValue(context, session, passThroughBuilder, {
intervalMs: 3_600_000,
nowMs: 3_600_000,
rootSeed: 42,
{ measurand: MeterValueMeasurand.CURRENT_IMPORT, phase: MeterValuePhase.L1 },
{ measurand: MeterValueMeasurand.CURRENT_IMPORT, phase: MeterValuePhase.N },
] as unknown as SampledValueTemplate[]
- const mv = buildCoherentMeterValue(context, 1, phaseBuilder, {
+ const mv = buildCoherentMeterValue(context, session, phaseBuilder, {
intervalMs: TEST_METER_VALUES_INTERVAL_MS,
nowMs: TEST_METER_VALUES_INTERVAL_MS,
rootSeed: 42,
{ measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L1_L2 },
{ measurand: MeterValueMeasurand.VOLTAGE, phase: MeterValuePhase.L1_N },
] as unknown as SampledValueTemplate[]
- const mv = buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ const mv = buildCoherentMeterValue(context, session, passThroughBuilder, {
intervalMs: TEST_METER_VALUES_INTERVAL_MS,
nowMs: TEST_METER_VALUES_INTERVAL_MS,
rootSeed: 42,
unit: MeterValueUnit.WATT_HOUR,
},
] as unknown as SampledValueTemplate[]
- const mv = buildCoherentMeterValue(context, 1, passThroughBuilder, {
+ const mv = buildCoherentMeterValue(context, session, passThroughBuilder, {
intervalMs: 3_600_000,
nowMs: 3_600_000,
rootSeed: 42,