Skip to content

Commit 1986b39

Browse files
refactor(meter-values): extract CoherentMeterValuesManager, shrink ICoherentContext, split OCPP16 begin helper (issue #1936 items a, b, e) (#1937)
* 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.
1 parent b907992 commit 1986b39

8 files changed

Lines changed: 387 additions & 139 deletions

File tree

cspell.config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ words:
2828
- unpushed
2929
- logform
3030
- mnemonist
31+
- multiton
3132
- poolifier
3233
- measurand
3334
- measurands

src/charging-station/ChargingStation.ts

Lines changed: 43 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { URL } from 'node:url'
99
import { parentPort } from 'node:worker_threads'
1010
import { type RawData, WebSocket } from 'ws'
1111

12+
import type { CoherentSession } from './meter-values/index.js'
13+
1214
import { BaseError, OCPPError } from '../exception/index.js'
1315
import { PerformanceStatistics } from '../performance/index.js'
1416
import {
@@ -110,6 +112,7 @@ import {
110112
} from '../utils/index.js'
111113
import { AutomaticTransactionGenerator } from './AutomaticTransactionGenerator.js'
112114
import { ChargingStationWorkerBroadcastChannel } from './broadcast-channel/ChargingStationWorkerBroadcastChannel.js'
115+
import { CoherentMeterValuesManager } from './CoherentMeterValuesManager.js'
113116
import {
114117
addConfigurationKey,
115118
deleteConfigurationKey,
@@ -132,7 +135,6 @@ import {
132135
getConnectorChargingProfilesLimit,
133136
getDefaultConnectorMaximumPower,
134137
getDefaultVoltageOut,
135-
getEvProfilesFile,
136138
getHashId,
137139
getIdTagsFile,
138140
getMaxNumberOfConnectors,
@@ -148,14 +150,6 @@ import {
148150
validateStationInfo,
149151
} from './Helpers.js'
150152
import { IdTagsCache } from './IdTagsCache.js'
151-
import { disposeCoherentSessionRuntime } from './meter-values/CoherentMeterValuesGenerator.js'
152-
import {
153-
type CoherentSession,
154-
createCoherentSession,
155-
type EvProfilesFile,
156-
loadEvProfilesFile,
157-
resolveRootSeed,
158-
} from './meter-values/index.js'
159153
import {
160154
buildBootNotificationRequest,
161155
createOCPPServices,
@@ -215,8 +209,6 @@ export class ChargingStation extends EventEmitter {
215209

216210
private automaticTransactionGeneratorConfiguration?: AutomaticTransactionGeneratorConfiguration
217211
private readonly chargingStationWorkerBroadcastChannel: ChargingStationWorkerBroadcastChannel
218-
private coherentEvProfiles?: EvProfilesFile
219-
private readonly coherentSessions: Map<number | string, CoherentSession>
220212
private configurationFile!: string
221213
private configurationFileHash!: string
222214
private configuredSupervisionUrl!: URL
@@ -252,7 +244,6 @@ export class ChargingStation extends EventEmitter {
252244
this.sharedLRUCache = SharedLRUCache.getInstance()
253245
this.idTagsCache = IdTagsCache.getInstance()
254246
this.chargingStationWorkerBroadcastChannel = new ChargingStationWorkerBroadcastChannel(this)
255-
this.coherentSessions = new Map<number | string, CoherentSession>()
256247

257248
this.on(ChargingStationEvents.added, () => {
258249
parentPort?.postMessage(buildAddedMessage(this))
@@ -316,20 +307,15 @@ export class ChargingStation extends EventEmitter {
316307

317308
/**
318309
* Injects a pre-built coherent session directly into the session store.
319-
* **Test seam only** — never call from production code; enforced at
320-
* runtime by a `NODE_ENV === 'production'` guard that throws
321-
* {@link BaseError}.
310+
* **Test seam only** — delegates to
311+
* {@link CoherentMeterValuesManager.injectSession}, which enforces the
312+
* `NODE_ENV === 'production'` guard.
322313
* @param transactionId - Transaction identifier.
323314
* @param session - Pre-built session.
324315
* @throws {BaseError} When invoked in a production build.
325316
*/
326317
public __injectCoherentSession (transactionId: number | string, session: CoherentSession): void {
327-
if (process.env.NODE_ENV === 'production') {
328-
throw new BaseError(
329-
`${this.logPrefix()} ${moduleName}.__injectCoherentSession: test-only seam called in production build`
330-
)
331-
}
332-
this.coherentSessions.set(transactionId, session)
318+
CoherentMeterValuesManager.getInstance(this)?.injectSession(transactionId, session)
333319
}
334320

335321
/**
@@ -377,8 +363,14 @@ export class ChargingStation extends EventEmitter {
377363

378364
/**
379365
* Creates or returns the coherent MeterValues session for a transaction.
380-
* Idempotent. Returns `undefined` when coherent mode is disabled or no
381-
* valid EV profile file is loaded.
366+
* Idempotent. Delegates to
367+
* {@link CoherentMeterValuesManager.createSession}; returns `undefined`
368+
* when coherent mode is disabled or no valid EV profile file is loaded.
369+
*
370+
* Uses `peekInstance` (lookup-only): opt-in stations warm up the
371+
* manager at initialize, so subsequent calls find it in the cache;
372+
* non-opt-in stations never allocate a manager because
373+
* `manager.createSession` would return `undefined` anyway.
382374
* @param transactionId - Transaction identifier from the CSMS.
383375
* @param connectorId - Connector on which the transaction is running.
384376
* @returns The active or newly-created session, or `undefined` when
@@ -388,27 +380,7 @@ export class ChargingStation extends EventEmitter {
388380
transactionId: number | string,
389381
connectorId: number
390382
): CoherentSession | undefined {
391-
const existing = this.coherentSessions.get(transactionId)
392-
if (existing != null) {
393-
return existing
394-
}
395-
if (this.stationInfo?.coherentMeterValues !== true) {
396-
return undefined
397-
}
398-
if (this.coherentEvProfiles == null || this.coherentEvProfiles.profiles.length === 0) {
399-
return undefined
400-
}
401-
const rootSeed = resolveRootSeed(this.stationInfo)
402-
const session = createCoherentSession(this, {
403-
connectorId,
404-
profiles: this.coherentEvProfiles.profiles,
405-
rootSeed,
406-
transactionId,
407-
})
408-
if (session != null) {
409-
this.coherentSessions.set(transactionId, session)
410-
}
411-
return session
383+
return CoherentMeterValuesManager.peekInstance(this)?.createSession(transactionId, connectorId)
412384
}
413385

414386
/**
@@ -428,6 +400,7 @@ export class ChargingStation extends EventEmitter {
428400
}
429401
}
430402
AutomaticTransactionGenerator.deleteInstance(this)
403+
CoherentMeterValuesManager.deleteInstance(this)
431404
PerformanceStatistics.deleteInstance(this.stationInfo?.hashId)
432405
OCPPAuthServiceFactory.clearInstance(this)
433406
if (this.stationInfo != null) {
@@ -467,17 +440,16 @@ export class ChargingStation extends EventEmitter {
467440

468441
/**
469442
* Removes the coherent session for a transaction. Idempotent — safe to
470-
* call from every reset/stop/disconnect path. Also disposes the module-scope
471-
* per-session runtime state (voltage-noise PRNG closure).
443+
* call from every reset/stop/disconnect path. Delegates to
444+
* {@link CoherentMeterValuesManager.destroySession}, which disposes the
445+
* module-scope per-session runtime state (voltage-noise PRNG closure).
446+
* Uses `peekInstance` so non-opt-in stations never allocate a manager
447+
* on a transaction-end tear-down.
472448
* @param transactionId - Transaction identifier.
473449
* @returns `true` when a session was removed, `false` otherwise.
474450
*/
475451
public destroyCoherentSession (transactionId: number | string | undefined): boolean {
476-
if (transactionId == null) {
477-
return false
478-
}
479-
disposeCoherentSessionRuntime(this.coherentSessions.get(transactionId))
480-
return this.coherentSessions.delete(transactionId)
452+
return CoherentMeterValuesManager.peekInstance(this)?.destroySession(transactionId) ?? false
481453
}
482454

483455
/**
@@ -540,12 +512,17 @@ export class ChargingStation extends EventEmitter {
540512
}
541513

542514
/**
543-
* Retrieves the coherent session for a transaction, if any.
515+
* Retrieves the coherent session for a transaction, if any. Delegates
516+
* to {@link CoherentMeterValuesManager.getSession}. Uses `peekInstance`
517+
* because this method is reached from the unconditional strategy gate
518+
* in `OCPPServiceUtils.buildMeterValue` on every MeterValue tick — a
519+
* `getInstance` here would allocate a manager for every station that
520+
* ever emits a MeterValue, opt-in or not.
544521
* @param transactionId - Transaction identifier.
545522
* @returns The session or `undefined` when none exists.
546523
*/
547524
public getCoherentSession (transactionId: number | string): CoherentSession | undefined {
548-
return this.coherentSessions.get(transactionId)
525+
return CoherentMeterValuesManager.peekInstance(this)?.getSession(transactionId)
549526
}
550527

551528
public getConnectionTimeout (): number {
@@ -1314,10 +1291,7 @@ export class ChargingStation extends EventEmitter {
13141291
// Drop any coherent sessions still tracked at shutdown so a
13151292
// subsequent restart cannot resurrect stale state or leak
13161293
// module-scope runtime PRNG closures.
1317-
for (const session of this.coherentSessions.values()) {
1318-
disposeCoherentSessionRuntime(session)
1319-
}
1320-
this.coherentSessions.clear()
1294+
CoherentMeterValuesManager.peekInstance(this)?.dispose()
13211295
this.stopping = false
13221296
}
13231297
} else {
@@ -1911,7 +1885,18 @@ export class ChargingStation extends EventEmitter {
19111885
}
19121886
}
19131887
this.saveStationInfo()
1914-
this.initializeCoherentEvProfiles()
1888+
// Warm up the coherent MeterValues manager on opt-in so
1889+
// EV-profile-file warnings surface at startup, and reload profiles
1890+
// on every subsequent `initialize()` (reset, template file change)
1891+
// to propagate template mutations. On opt-out we deliberately
1892+
// leave any pre-existing manager in place: new sessions are
1893+
// already blocked by the `coherentMeterValues` gate in
1894+
// `createSession`, and in-flight sessions drain via
1895+
// `destroyCoherentSession` on transaction end — preserving
1896+
// provenance of transactions started before the flag flip.
1897+
if (this.stationInfo.coherentMeterValues === true) {
1898+
CoherentMeterValuesManager.getInstance(this)?.reloadEvProfiles()
1899+
}
19151900
this.configuredSupervisionUrl = this.getConfiguredSupervisionUrl()
19161901
if (this.stationInfo.enableStatistics === true) {
19171902
this.performanceStatistics = PerformanceStatistics.getInstance(
@@ -1942,33 +1927,6 @@ export class ChargingStation extends EventEmitter {
19421927
}
19431928
}
19441929

1945-
/**
1946-
* Loads and validates the EV profile file when coherent MeterValues are
1947-
* enabled. Fail-soft: any error disables coherent mode for this station
1948-
* (createCoherentSession then becomes a no-op).
1949-
*/
1950-
private initializeCoherentEvProfiles (): void {
1951-
this.coherentEvProfiles = undefined
1952-
if (this.stationInfo?.coherentMeterValues !== true) {
1953-
return
1954-
}
1955-
const evProfilesFile = getEvProfilesFile(this.stationInfo)
1956-
if (evProfilesFile == null) {
1957-
logger.warn(
1958-
`${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: coherentMeterValues=true but no evProfilesFile is configured, coherent MeterValues disabled`
1959-
)
1960-
return
1961-
}
1962-
const loaded = loadEvProfilesFile(evProfilesFile, this.logPrefix())
1963-
if (loaded == null) {
1964-
logger.warn(
1965-
`${this.logPrefix()} ${moduleName}.initializeCoherentEvProfiles: EV profiles could not be loaded, coherent MeterValues disabled`
1966-
)
1967-
return
1968-
}
1969-
this.coherentEvProfiles = loaded
1970-
}
1971-
19721930
private initializeConnectorsFromTemplate (stationTemplate: ChargingStationTemplate): void {
19731931
if (stationTemplate.Connectors == null && isEmpty(this.connectors)) {
19741932
const errorMsg = `No already defined connectors and charging station information from template ${this.templateFile} with no connectors configuration defined`

0 commit comments

Comments
 (0)