From 1986b3994cfefa83d9613f4a15ff82c9cf3071b1 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 3 Jul 2026 01:38:05 +0200 Subject: [PATCH] refactor(meter-values): extract CoherentMeterValuesManager, shrink ICoherentContext, split OCPP16 begin helper (issue #1936 items a, b, e) (#1937) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * refactor(meter-values): extract CoherentMeterValuesManager (issue #1936) Extract per-station coherent MeterValues lifecycle owner from ChargingStation into a dedicated singleton-per-station manager, mirroring the multiton pattern of AutomaticTransactionGenerator / IdTagsCache / SharedLRUCache (keyed by stationInfo.hashId). The manager owns the EV profile file, the active-session Map, and the create/destroy/inject lifecycle. ChargingStation keeps the four public methods as thin delegators for API stability — external OCPP handler call sites, the test helper mock, and existing tests are unchanged. Behavior is preserved: - Sessions are still created only when coherentMeterValues=true AND a valid EV profile file loads. - The __injectCoherentSession NODE_ENV production guard is preserved on the manager side (BaseError throw). - Session runtime state is disposed at every reset/stop/disconnect path via destroySession, and at station stop/delete via dispose / deleteInstance. Closes issue #1936 item (a). * refactor(meter-values): thread coherent session, shrink ICoherentContext (issue #1936) Drop `getCoherentSession` from `ICoherentContext`: the port now exposes only what the physics chain needs to query about the station itself. Session lookup moves to the caller (the strategy gate), which looks up via `ChargingStation.getCoherentSession` — the A1 delegator that routes through `CoherentMeterValuesManager` in production and through the test-mock's own Map in tests. Signature changes: - `isCoherentModeActive(session): session is CoherentSession` — pure type guard replacing the two-tier (stationInfo + session-lookup) predicate. The stationInfo gate is implied by session existence (sessions are only created via the manager when `coherentMeterValues=true`). - `buildCoherentMeterValue(context, session, ...)` — takes the session directly. The internal `context.getCoherentSession` lookup and the "missing session" warning branch collapse to a single connector-lookup guard. Strategy gate call sites in `OCPPServiceUtils.buildMeterValue` and `OCPP16ServiceUtils.buildTransactionBeginMeterValue` are threaded accordingly. Behavior preserved: sessions are still only routed to the coherent path when they exist; random/fixed path is untouched. Test suite invariant `fail: 0, skipped: 6` holds. Closes issue #1936 item (b). * refactor(ocpp16): extract buildCoherentTransactionBeginMeterValue (issue #1936) Split OCPP16ServiceUtils.buildTransactionBeginMeterValue's dual responsibility. The coherent short-circuit (route through buildMeterValue with the vendor StartTxnSampledData override) moves to a named private static helper; the outer function keeps only the random/fixed default path plus the strategy gate. The strategy gate in OCPP 1.6 now lives at a single well-labeled boundary in this file, mirroring the pattern established by OCPPServiceUtils.buildMeterValue for the periodic MeterValues path. Behavior is unchanged: same measurand-key resolution, same buildMeterValue re-dispatch, same TRANSACTION_BEGIN context. Closes issue #1936 item (e). * refactor(meter-values): add peekInstance to avoid phantom manager allocation (issue #1936) PR-A round-1 Oracle review (lanes A + D converged on this MAJOR finding): the strategy gate in `OCPPServiceUtils.buildMeterValue` reaches `ChargingStation.getCoherentSession` unconditionally on every MeterValue tick, and the delegator was routed through `CoherentMeterValuesManager.getInstance` — a lazy-create factory. Result: every station that ever emits a MeterValue allocated a `CoherentMeterValuesManager` + Map, regardless of the `coherentMeterValues` opt-in flag. This contradicted the file-header design contract that 'stations with the option off never allocate a manager'. Fix: add `CoherentMeterValuesManager.peekInstance(cs)` — a lookup-only sibling of `getInstance` that never constructs. Route the four read/destroy/dispose sites through `peekInstance`: - `ChargingStation.createCoherentSession` (opt-in station's manager is warmed up at initialize; non-opt-in stations return `undefined` without allocating). - `ChargingStation.destroyCoherentSession` - `ChargingStation.getCoherentSession` (the strategy-gate hot path). - `ChargingStation.stop()` finally-block dispose. Keep `getInstance` (create-if-missing) at: - `ChargingStation.__injectCoherentSession` — production-guard BaseError throw must remain reachable if this test seam is accidentally called in prod. - `ChargingStation.initialize` — the opt-in eager warm-up that surfaces EV-profile-file warnings at startup rather than at first transaction. Also tighten `getInstance` JSDoc to state precisely that `undefined` is returned iff `stationInfo.hashId` is not yet resolved (the sole failure mode), and cross-reference `peekInstance` for read paths. Behavior for opt-in stations is unchanged (warm-up allocates the same manager, subsequent reads find it in the cache). Non-opt-in stations no longer allocate. Test invariant `fail: 0, skipped: 6` holds. Closes issue #1936 item (a) sub-finding from round-1 review. * docs(meter-values): tighten getInstance/peekInstance JSDoc post round-2 review (issue #1936) PR-A round-2 Oracle review (lanes A + B converged on JSDoc precision): - Lane A: `getInstance` JSDoc still listed `createSession` as a caller, but the round-1 fix routed `createSession` through `peekInstance`. A future reader following the JSDoc could reintroduce the phantom- allocation bug at the exact site it was just patched. - Lane B: JSDoc labeled `destroySession` and `stop()` dispose as "read-only paths". Both are actually mutations (Map.delete + PRNG-closure disposal). Reword to "paths that must not allocate a manager on behalf of non-opt-in stations". - Lane A NIT: the eager-init invariant is load-bearing — if `ChargingStation.initialize` stops warming up the manager for opt-in stations, `createCoherentSession` silently no-ops. The invariant was implicit; make it explicit in `peekInstance` JSDoc. Docs-only. No runtime change. Test invariant `fail: 0, skipped: 6` holds. * fix(meter-values): propagate template reload to coherent manager (issue #1936) The template file watcher and reset() path already flush sharedLRUCache, idTagsCache, and OCPPAuthServiceFactory before calling initialize(). The coherent manager was left behind: getInstance returned the cached manager for the unchanged hashId, whose evProfiles were snapshotted at first-construction, so runtime mutations of evProfilesFile or its file contents did not take effect until process restart. Wire reloadEvProfiles() into every initialize() invocation so template mutations propagate. Symmetrically drop the manager entirely when the coherentMeterValues opt-in flips true to false so cached sessions and stale profile data do not leak across the config change. * refactor(meter-values): gate injectSession, harmonize banner and JSDoc (issue #1936) injectSession test seam now honors the stationInfo.coherentMeterValues opt-in gate. Pre-refactor, isCoherentModeActive checked the flag on every tick; post-refactor it trusts session existence. Without the gate, a test could inject a session on a non-opt-in station and activate the coherent wire path — contradicting the type-guard precondition. Production is already protected by the NODE_ENV production throw. Align copyright banner to the sibling verbatim form ('Partial Copyright Jerome Benoit. 2021-2025'). Harmonize JSDoc: peekInstance MUST-use list now includes createSession (mirrors getInstance JSDoc); reloadEvProfiles fail-soft description covers non-opt-in stations and any error; 'cache miss' prose replaced with 'instances-map miss' to match the field name. * refactor(meter-values): preserve in-flight coherent sessions across opt-in flag flip (issue #1936) The previous initialize() logic dropped the CoherentMeterValuesManager via deleteInstance() when coherentMeterValues flipped true to false. That immediately disposed every in-flight coherent session — and, in the template file watcher flow where initialize() runs before stopAutomaticTransactionGenerator(), that produced mixed-provenance TransactionEvent frames on OCPP 2.0.x: the Begin frame was coherent, the Update/End frames after the flip fell through the strategy gate to random path, and the CSMS saw a discontinuous transaction. Remove the else branch. New sessions are already blocked by the coherentMeterValues gate in createSession; existing in-flight sessions drain naturally via destroyCoherentSession on transaction end. Provenance is preserved for transactions started before the flag flip, and the reloadEvProfiles propagation on the true branch (the round-1 MAJOR fix) is unaffected. * docs(meter-values): calibrate injectSession JSDoc for mock-helper reality (issue #1936) The prior JSDoc claimed the opt-in guard means isCoherentModeActive cannot be tricked into activating the coherent wire path by injecting on a non-opt-in station. That is true only for the production-backed injection path (through the real CoherentMeterValuesManager.injectSession). Tests that mock ChargingStation write to their own session store bypassing this seam entirely, so the mock is responsible for enforcing its own opt-in invariant where relevant. Calibrate the JSDoc scope accordingly. --- cspell.config.yaml | 1 + src/charging-station/ChargingStation.ts | 128 +++------ .../CoherentMeterValuesManager.ts | 271 ++++++++++++++++++ .../CoherentMeterValuesGenerator.ts | 42 +-- src/charging-station/meter-values/types.ts | 6 +- .../ocpp/1.6/OCPP16ServiceUtils.ts | 57 ++-- src/charging-station/ocpp/OCPPServiceUtils.ts | 8 +- .../CoherentMeterValuesGenerator.test.ts | 13 +- 8 files changed, 387 insertions(+), 139 deletions(-) create mode 100644 src/charging-station/CoherentMeterValuesManager.ts diff --git a/cspell.config.yaml b/cspell.config.yaml index 7fc72870..d90fa86b 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -28,6 +28,7 @@ words: - unpushed - logform - mnemonist + - multiton - poolifier - measurand - measurands diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 44d250e2..774fc870 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -9,6 +9,8 @@ import { URL } from 'node:url' 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 { @@ -110,6 +112,7 @@ 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, @@ -132,7 +135,6 @@ import { getConnectorChargingProfilesLimit, getDefaultConnectorMaximumPower, getDefaultVoltageOut, - getEvProfilesFile, getHashId, getIdTagsFile, getMaxNumberOfConnectors, @@ -148,14 +150,6 @@ import { 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, @@ -215,8 +209,6 @@ export class ChargingStation extends EventEmitter { private automaticTransactionGeneratorConfiguration?: AutomaticTransactionGeneratorConfiguration private readonly chargingStationWorkerBroadcastChannel: ChargingStationWorkerBroadcastChannel - private coherentEvProfiles?: EvProfilesFile - private readonly coherentSessions: Map private configurationFile!: string private configurationFileHash!: string private configuredSupervisionUrl!: URL @@ -252,7 +244,6 @@ export class ChargingStation extends EventEmitter { this.sharedLRUCache = SharedLRUCache.getInstance() this.idTagsCache = IdTagsCache.getInstance() this.chargingStationWorkerBroadcastChannel = new ChargingStationWorkerBroadcastChannel(this) - this.coherentSessions = new Map() this.on(ChargingStationEvents.added, () => { parentPort?.postMessage(buildAddedMessage(this)) @@ -316,20 +307,15 @@ export class ChargingStation extends EventEmitter { /** * 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) } /** @@ -377,8 +363,14 @@ export class ChargingStation extends EventEmitter { /** * 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 @@ -388,27 +380,7 @@ export class ChargingStation extends EventEmitter { 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) } /** @@ -428,6 +400,7 @@ export class ChargingStation extends EventEmitter { } } AutomaticTransactionGenerator.deleteInstance(this) + CoherentMeterValuesManager.deleteInstance(this) PerformanceStatistics.deleteInstance(this.stationInfo?.hashId) OCPPAuthServiceFactory.clearInstance(this) if (this.stationInfo != null) { @@ -467,17 +440,16 @@ export class ChargingStation extends EventEmitter { /** * 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 } /** @@ -540,12 +512,17 @@ export class ChargingStation extends EventEmitter { } /** - * 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 { @@ -1314,10 +1291,7 @@ export class ChargingStation extends EventEmitter { // 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 { @@ -1911,7 +1885,18 @@ export class ChargingStation extends EventEmitter { } } 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( @@ -1942,33 +1927,6 @@ export class ChargingStation extends EventEmitter { } } - /** - * 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` diff --git a/src/charging-station/CoherentMeterValuesManager.ts b/src/charging-station/CoherentMeterValuesManager.ts new file mode 100644 index 00000000..c367d2a1 --- /dev/null +++ b/src/charging-station/CoherentMeterValuesManager.ts @@ -0,0 +1,271 @@ +// 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 = new Map< + string, + CoherentMeterValuesManager + >() + + private readonly chargingStation: ChargingStation + private evProfiles?: EvProfilesFile + private readonly sessions: Map + + private constructor (chargingStation: ChargingStation) { + this.chargingStation = chargingStation + this.sessions = new Map() + 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 + } +} diff --git a/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts b/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts index 1fe8dcce..e926b30c 100644 --- a/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts +++ b/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts @@ -160,22 +160,20 @@ const fluctuate = (base: number, percent: number, prng: () => number): number => } /** - * 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`. @@ -649,7 +647,11 @@ const resolveTemplates = ( * {@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). @@ -663,18 +665,16 @@ const resolveTemplates = ( */ export const buildCoherentMeterValue = ( context: ICoherentContext, - transactionId: number | string, + session: CoherentSession, buildVersionedSampledValue: BuildVersionedSampledValue, options: ComputeSampleOptions, mvContext?: MeterValueContext, enabledMeasurands?: ReadonlySet ): 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() } } diff --git a/src/charging-station/meter-values/types.ts b/src/charging-station/meter-values/types.ts index 65d92a85..63f01ed4 100644 --- a/src/charging-station/meter-values/types.ts +++ b/src/charging-station/meter-values/types.ts @@ -111,10 +111,12 @@ export interface CoherentSession { /** * 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 diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index e496199f..d34ff82c 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -149,29 +149,15 @@ export class OCPP16ServiceUtils { 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) @@ -981,6 +967,37 @@ export class OCPP16ServiceUtils { } } + /** + * 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, diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index d2cad639..b2b9b74b 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -1160,16 +1160,16 @@ export const buildMeterValue = ( // 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) diff --git a/tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts b/tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts index 99245421..3b63e215 100644 --- a/tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts +++ b/tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts @@ -106,7 +106,6 @@ const buildContext = ( const sessions = new Map() const context: ICoherentContext = { - getCoherentSession: (id: number | string) => sessions.get(id), getConnectorMaximumAvailablePower: () => evseMax, getConnectorStatus: () => connectorStatus, getNumberOfPhases: () => numberOfPhases, @@ -382,7 +381,7 @@ await describe('CoherentMeterValuesGenerator', async () => { 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, @@ -409,7 +408,7 @@ await describe('CoherentMeterValuesGenerator', async () => { [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, @@ -439,7 +438,7 @@ await describe('CoherentMeterValuesGenerator', async () => { [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, @@ -885,7 +884,7 @@ await describe('CoherentMeterValuesGenerator', async () => { { 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, @@ -944,7 +943,7 @@ await describe('CoherentMeterValuesGenerator', async () => { { 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, @@ -981,7 +980,7 @@ await describe('CoherentMeterValuesGenerator', async () => { 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, -- 2.53.0