From: Jérôme Benoit Date: Fri, 3 Jul 2026 14:39:30 +0000 (+0200) Subject: refactor(meter-values): split CoherentMeterValuesGenerator into three modules (issue... X-Git-Tag: cli@v4.11.0~73 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=53b747bf53fb12762b84c1190fbbefacf95887c8;p=e-mobility-charging-stations-simulator.git refactor(meter-values): split CoherentMeterValuesGenerator into three modules (issue #1936 item i) (#1938) * refactor(meter-values): split CoherentMeterValuesGenerator into three modules (issue #1936 item i) Split the 820 LOC `CoherentMeterValuesGenerator.ts` (introduced by PR #1935) into three focused modules per issue #1936 item (i): - `CoherentSampleComputer.ts` (311 LOC): physics chain V→P→I→ΔE→SoC with INV-1/2/3 by construction (guard bundle, ramp factor, voltage noise, EVSE/EV/capacity clamps, integer-rounded emission, energy accrual, monotone SoC saturation), plus `advanceEnergyRegister` which the builder invokes once per sample so `meterStop` stays correct even when `Energy.Active.Import.Register` is not in the configured MeterValues. - `CoherentMeterValueBuilder.ts` (364 LOC): OCPP {@link MeterValue} assembly — phase family classifier, cross-measurand emit order (SoC→Voltage→Power→Current→Energy), within-measurand phase rank (no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N), kilo/unit divider, template resolution, and the `buildCoherentMeterValue` entry point that orchestrates `computeCoherentSample` + `advanceEnergyRegister` + per-template emission. - `CoherentMeterValuesGenerator.ts` (204 LOC, shrunk from 820): session lifecycle (`createCoherentSession`, `CreateSessionOptions`), PRNG helpers (`createStreamPrng` with the `tx:`-namespaced deriveSeed leg, `resolveRootSeed`), the `isCoherentModeActive` type guard, and the module-scope WeakMap runtime state (`SessionRuntime`, `getSessionRuntime`, `disposeCoherentSessionRuntime`) accessed by the computer. The three modules stack: Generator (WeakMap + PRNG helpers) ← Computer (imports `getSessionRuntime`, `createStreamPrng`) ← Builder (imports `computeCoherentSample`, `advanceEnergyRegister`, `ROUNDING_SCALE`, `CoherentSample`, `ComputeSampleOptions`). No cycles. The barrel (`meter-values/index.ts`) re-exports `buildCoherentMeterValue` + `BuildVersionedSampledValue` from the builder and `createCoherentSession` / `isCoherentModeActive` / `resolveRootSeed` from the generator so external consumers (`CoherentMeterValuesManager`, `OCPPServiceUtils`, `OCPP16ServiceUtils`) keep their existing imports. Preserved invariants (per mission constraints): - `__injectCoherentSession` NODE_ENV production-guard throw (unchanged — lives on `CoherentMeterValuesManager`). - Session-snapshot reads for `voltageOutNominal` / `currentType` / `numberOfPhases` (still read from `session.*` inside `computeCoherentSample`). - `tx:` PRNG namespace on the transactionId leg of `createStreamPrng` (preserves the XOR self-inverse guard). - non-finite-`intervalMs` defensive zero-sample guard preserved verbatim in `computeCoherentSample`. Test file imports updated to point at the new module boundaries; no test logic changes. Test invariant `fail: 0, skipped: 6` holds. Closes issue #1936 item (i). * refactor(meter-values): align banner and tighten JSDoc on new split modules (issue #1936 i) Revert the copyright banner on the three files introduced by the split (CoherentSampleComputer.ts, CoherentMeterValueBuilder.ts, index.ts) from 'Copyright ... 2021-2026' to 'Partial Copyright ... 2021-2025' to match the sibling verbatim convention (AutomaticTransactionGenerator.ts, ChargingStation.ts, CoherentMeterValuesManager.ts, PerformanceStatistics.ts). Wider repo-wide banner year sweep to 2021-2026 is deferred to a separate chore commit. Tighten two hedge-worded JSDoc lines in CoherentSampleComputer.ts: - 'Sample timestamp in milliseconds (typically Date.now())' now spells out 'callers pass Date.now() in production and a test-controlled clock in unit tests'. - Defensive-guard rationale 'a template override or future dynamic supply could return 0' tightened to 'a template override may set voltageOut to 0'. CoherentMeterValuesGenerator.ts is intentionally not touched here to avoid four-way merge collisions with #1940 (Prng rename), #1941 (physics refinements h), #1942 (RegisterValuesWithoutPhases k), and #1944 (EVSE-level template fallback g). Design-MAJOR findings (inverted Computer → Generator dependency, widened intra-package exports, 'Generator' semantic misnomer) are documented in the PR body as a post-stack-merge follow-up. * refactor(meter-values): align banner to same-folder sibling convention (issue #1936 i) Drop the 'Partial' prefix on the three files introduced by the split. Round-1 aligned to parent-folder siblings (ChargingStation.ts, AutomaticTransactionGenerator.ts, CoherentMeterValuesManager.ts) which use 'Partial Copyright ... 2021-2025', but same-folder siblings (EvProfiles.ts, Prng.ts, types.ts) use 'Copyright ... 2021-2025' without the prefix. Aligning to same-folder convention restores banner uniformity inside src/charging-station/meter-values/. CoherentMeterValuesGenerator.ts (2021-2026 outlier) remains untouched to avoid the four-way merge collision with #1940 / #1941 / #1942 / #1944 — repo-wide banner sweep is deferred as documented. * refactor(meter-values): unidirectional DAG for coherent split (issue #1936 i) Address the design findings raised by post-split review by relocating runtime state and PRNG plumbing to their responsibility owners: - Move SessionRuntime, sessionRuntimes, getSessionRuntime to CoherentSampleComputer.ts as module-private (they exist only to cache the voltage-noise PRNG closure across samples, which is a physics concern, not a session-lifecycle concern). - Move disposeCoherentSessionRuntime to CoherentSampleComputer.ts (exported for CoherentMeterValuesManager). Update the manager's direct import path accordingly. - Move createStreamPrng to Prng.ts alongside deriveSeed / mulberry32 / hashLabel. It composes those three helpers and is a PRNG primitive, not a session helper. - Rename CoherentMeterValuesGenerator.ts to CoherentSession.ts. After the moves the file owns only session identity (createCoherentSession, CreateSessionOptions), the strategy-gate type guard (isCoherentModeActive), and the root-seed resolver (resolveRootSeed). The 'Generator' name no longer describes the file — the entry point buildCoherentMeterValue lives in the builder. - Align banner on the renamed file to same-folder convention (Copyright ... 2021-2025, matching EvProfiles / Prng / types). - Update barrel and test imports. Result: strictly unidirectional import DAG within meter-values/: types ← { CoherentSession, CoherentSampleComputer, CoherentMeterValueBuilder, EvProfiles, Prng } Prng ← { CoherentSession, CoherentSampleComputer } EvProfiles ← { CoherentSession, CoherentSampleComputer } CoherentSampleComputer ← CoherentMeterValueBuilder CoherentSession no longer depends on any coherent sibling; Computer no longer depends on Session (the pre-refactor Computer → Session inversion is eliminated). Three previously-widened intra-package exports revert to module-private (SessionRuntime, sessionRuntimes, getSessionRuntime); ROUNDING_SCALE stays exported (Computer → Builder is the correct direction). Barrel public surface unchanged. Wire behavior and physics chain byte-identical — the move preserves every function body verbatim, only relocating them. Test invariant fail:0, skipped:6 preserved (2925 pass / 2931 total). The four open PRs touching CoherentMeterValuesGenerator.ts (#1940 c, #1941 h, #1942 k, #1944 g) will need manual rebase resolution on top of this refactor — accepted trade-off per the maintainer's directive to do the design work now rather than defer. * docs(meter-values): apply round-3 review fixes (issue #1936 i) - Retarget the stale '{@link ./CoherentMeterValuesGenerator}' reference in CoherentSampleComputer.ts @file JSDoc to describe the current state (physics chain + session-runtime WeakMap + disposeCoherentSessionRuntime teardown hook) instead of the pre-split historical evolution, per the AGENTS.md 'document current state; exclude historical evolution' rule. - Restore the getSessionRuntime JSDoc dropped during the Generator → Computer move. Documents the load-bearing invariant that the voltage-noise PRNG closure must be cached across samples so the stream advances rather than restarting from the same seed each draw. - Rename CoherentMeterValuesGenerator.test.ts to CoherentMeterValues.test.ts. Update @file header and describe() label to match the new subject — the test file now exercises CoherentSession, CoherentSampleComputer, CoherentMeterValueBuilder, and Prng; no single 'generator' module remains post-refactor. * docs(meter-values): apply round-4 review fixes (issue #1936 i) - Fix dangling '{@link ../../CoherentMeterValuesManager...}' at CoherentMeterValueBuilder.ts:287. From src/charging-station/meter-values/, '../../' resolves to src/ (target does not exist there); correct path is '../CoherentMeterValuesManager' (src/charging-station/). This matches the fixes already applied to the sibling occurrences in CoherentSampleComputer.ts:9 and CoherentSession.ts:37; Builder was missed during the round-3 sweep. - Add 'satisfies readonly MeterValueMeasurand[]' to MEASURAND_EMIT_ORDER in CoherentMeterValueBuilder.ts. Matches the 'as const satisfies Record<...>' idiom already used by PHASE_FAMILY and PHASE_RANK. Gates against a typo-introduced non-measurand value at compile time. - Expand the label-style '// EV acceptance from the curve at running SoC.' comment in CoherentSampleComputer.ts to document the load-bearing physics invariant: the taper MUST interpolate at session.socPercent (live state advanced by advanceEnergyRegister) rather than a constant or the initial SoC. Without this, a future maintainer could replace the live read with a constant, breaking the taper (P would not decrease as SoC rises, over-charging past the capacity clamp, violating INV-3). * docs(meter-values): correct socPercent mutator reference in taper comment (issue #1936 i) The round-4 physics comment at CoherentSampleComputer.ts:299 stated that session.socPercent is advanced 'by the previous advanceEnergyRegister tick', but advanceEnergyRegister (line 180) only mutates connectorStatus energy registers — it does not touch session.socPercent. The actual mutation happens at the end of computeCoherentSample itself (line 354). Retarget the comment's mutation-site reference to computeCoherentSample and inline-reference the assignment below. This preserves the load-bearing invariant that round-4 documented (taper must interpolate at live SoC, not initial) while pointing the future maintainer to the correct code location — a maintainer following the wrong pointer to advanceEnergyRegister would find no session mutation and might delete the 'stale' comment or replace the live read with a constant, silently breaking the taper (P would not decrease as SoC rises → over-charging past capacity clamp → violation of INV-3). * docs(coherent): strip historical narrative from JSDoc + test prose (issue #1936 i) Apply AGENTS.md 'document current state; exclude historical evolution' across the coherent-mode surface (both this PR's meter-values files and the CoherentMeterValuesManager landed in PR #1937): - CoherentMeterValuesManager.ts:@file — 'Extracted from ChargingStation' narrated origin. Rewrite to 'Sits alongside ChargingStation' — same design rationale (keeping the strictly opt-in coherent surface off the main class body) without the historical framing. - CoherentMeterValuesManager.ts:injectSession JSDoc — 'mirroring the seam previously exposed on ChargingStation' claimed the seam was no longer on ChargingStation, but __injectCoherentSession is still a live delegator on ChargingStation (points at manager.injectSession). Drop 'previously' — the seam mirrors the ChargingStation delegator. - CoherentMeterValueBuilder.ts:@file — 'Extracted from the original single-file generator as part of the issue #1936 (item i) file split to keep each module under the 250 LOC ceiling' was pure historical narrative; drop the sentence entirely. - CoherentMeterValueBuilder.ts:resolveTemplates JSDoc — 'tracked as a follow-up in issue #1936' was TODO-adjacent but redundant with git-blame trail. Keep the current-state limitation description (EVSE-level template inheritance needs getEvseStatus on the port). - meter-values/index.ts:@description — 'Module layout after the issue #1936 (item i) split:' → 'Module layout:'. - CoherentMeterValues.test.ts:@file — 'Exercises the split modules together' → 'Cross-module tests spanning'. - CoherentMeterValues.test.ts:797 assertion — '(moved to module-scope runtime state)' → '(owned by module-scope runtime state in CoherentSampleComputer)'. Same invariant, current-state phrasing. --- diff --git a/.serena/project.yml b/.serena/project.yml index 62169c93..f80a9ca1 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -25,7 +25,7 @@ excluded_tools: [] initial_prompt: 'You are working on a free and open source software project that simulates charging stations. Refer to the memories for more details and `.github/copilot-instructions.md` or `AGENTS.md` for development guidelines.' -# the name by which the project can be referenced within Serena +# the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: 'e-mobility-charging-stations-simulator' # list of mode names to that are always to be included in the set of active modes @@ -56,25 +56,31 @@ included_optional_tools: [] # Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] -# list of languages for which language servers are started; choose from: -# al ansible bash clojure cpp -# cpp_ccls crystal csharp csharp_omnisharp dart -# elixir elm erlang fortran fsharp +# list of languages for which language servers are started (LSP backend only); choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript # go groovy haskell haxe hlsl -# java json julia kotlin lean4 -# lua luau markdown matlab msl -# nix ocaml pascal perl php -# php_phpactor powershell python python_jedi python_ty -# r rego ruby ruby_solargraph rust -# scala solidity swift systemverilog terraform -# toml typescript typescript_vts vue yaml -# zig -# (This list may be outdated. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# html java json julia kotlin +# latex lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -117,8 +123,8 @@ ignored_memory_patterns: [] # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. -# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. -# No documentation on options means no options are available. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} # list of mode names to be activated additionally for this project, e.g. ["query-projects"] @@ -126,13 +132,38 @@ ls_specific_settings: {} # See https://oraios.github.io/serena/02-usage/050_configuration.html#modes added_modes: -# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# list of additional workspace folder paths for cross-package reference support. # Paths can be absolute or relative to the project root. # Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries. -# Currently supported for: TypeScript. +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. # Example: # additional_workspace_folders: # - ../sibling-package # - ../shared-lib -additional_workspace_folders: [] +ls_additional_workspace_folders: [] + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: + - . + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 diff --git a/src/charging-station/CoherentMeterValuesManager.ts b/src/charging-station/CoherentMeterValuesManager.ts index c367d2a1..5986a023 100644 --- a/src/charging-station/CoherentMeterValuesManager.ts +++ b/src/charging-station/CoherentMeterValuesManager.ts @@ -4,7 +4,7 @@ * @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 + * MeterValues. Sits alongside {@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`. @@ -15,7 +15,7 @@ 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 { disposeCoherentSessionRuntime } from './meter-values/CoherentSampleComputer.js' import { type CoherentSession, createCoherentSession, @@ -210,7 +210,7 @@ export class CoherentMeterValuesManager { * 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}. + * mirroring the seam on {@link ChargingStation}. * * Silently no-ops on non-opt-in stations * (`stationInfo.coherentMeterValues !== true`) so on the diff --git a/src/charging-station/meter-values/CoherentMeterValueBuilder.ts b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts new file mode 100644 index 00000000..8864bf10 --- /dev/null +++ b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts @@ -0,0 +1,362 @@ +// Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Coherent MeterValue emission (phase families, emit order, units). + * @description Assembles an OCPP {@link MeterValue} from a single + * {@link CoherentSample} produced by + * {@link ./CoherentSampleComputer.computeCoherentSample}. + * + * Two axes of ordering: + * - Across measurands: `SoC → Voltage → Power → Current → Energy` + * (mirrors the `getSampledValueTemplate` path so downstream consumers + * relying on OCPP MeterValue ordering keep working). + * - Within a measurand with multiple phase-qualified templates: no-phase + * first, then `L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N`. + * + * Unsupported `(measurand, phase)` combinations are logged and skipped. + * Only measurands enabled by the caller-resolved allow-list are emitted. + * The energy register is advanced unconditionally by the caller through + * {@link ./CoherentSampleComputer.advanceEnergyRegister} independent of + * whether the Energy measurand is emitted. + */ + +import type { + MeterValue, + MeterValueContext, + SampledValue, + SampledValueTemplate, +} from '../../types/index.js' +import type { CoherentSample, ComputeSampleOptions } from './CoherentSampleComputer.js' +import type { CoherentSession, ICoherentContext } from './types.js' + +import { + type ConnectorStatus, + MeterValueMeasurand, + MeterValuePhase, + MeterValueUnit, +} from '../../types/index.js' +import { Constants, logger, roundTo } from '../../utils/index.js' +import { + advanceEnergyRegister, + computeCoherentSample, + ROUNDING_SCALE, +} from './CoherentSampleComputer.js' + +const moduleName = 'CoherentMeterValueBuilder' + +/** + * Signature of the versioned SampledValue builder returned by the + * OCPP-version dispatcher in {@link ../ocpp/OCPPServiceUtils.buildMeterValue}. + * Kept structurally compatible so a coherent generator can emit SampledValues + * in either OCPP 1.6 or 2.0 formats without knowing the version. + */ +export type BuildVersionedSampledValue = ( + sampledValueTemplate: SampledValueTemplate, + value: number, + context?: MeterValueContext, + phase?: MeterValuePhase +) => SampledValue + +/** + * Phase family classifier lookup for coherent emission. `satisfies Record<...>` + * gates compile-time exhaustiveness so a new `MeterValuePhase` value fails + * compile until classified. `Aggregate` is applied when `phase` is `undefined` + * (the sentinel handled by `phaseFamily` outside the table). + * - `LineToNeutral`: bare `L1`/`L2`/`L3` and `L1-N`/`L2-N`/`L3-N` + * (line-current or phase-voltage measurements). + * - `LineToLine`: `L1-L2`/`L2-L3`/`L3-L1` (line-to-line voltage; not + * defined for current or power in the coherent model). + * - `Neutral`: `N` (physically 0 for balanced 3-phase Y). + */ +const PHASE_FAMILY = { + [MeterValuePhase.L1]: 'LineToNeutral', + [MeterValuePhase.L1_L2]: 'LineToLine', + [MeterValuePhase.L1_N]: 'LineToNeutral', + [MeterValuePhase.L2]: 'LineToNeutral', + [MeterValuePhase.L2_L3]: 'LineToLine', + [MeterValuePhase.L2_N]: 'LineToNeutral', + [MeterValuePhase.L3]: 'LineToNeutral', + [MeterValuePhase.L3_L1]: 'LineToLine', + [MeterValuePhase.L3_N]: 'LineToNeutral', + [MeterValuePhase.N]: 'Neutral', +} as const satisfies Record + +const phaseFamily = ( + phase: MeterValuePhase | undefined +): 'Aggregate' | 'LineToLine' | 'LineToNeutral' | 'Neutral' => + phase == null ? 'Aggregate' : PHASE_FAMILY[phase] + +/** + * Emit order across measurands, mirroring the `getSampledValueTemplate` + * path (SoC → Voltage → Power → Current → Energy). Preserved so downstream + * consumers relying on OCPP MeterValue ordering keep working. + */ +const MEASURAND_EMIT_ORDER = [ + MeterValueMeasurand.STATE_OF_CHARGE, + MeterValueMeasurand.VOLTAGE, + MeterValueMeasurand.POWER_ACTIVE_IMPORT, + MeterValueMeasurand.CURRENT_IMPORT, + MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, +] as const satisfies readonly MeterValueMeasurand[] + +/** + * Within-measurand phase order for deterministic per-phase emission: + * no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N. + * Lower rank emits first. `satisfies Record<...>` gates exhaustiveness + * so a new `MeterValuePhase` value fails compile until ranked. + */ +const PHASE_RANK = { + [MeterValuePhase.L1]: 1, + [MeterValuePhase.L1_L2]: 4, + [MeterValuePhase.L1_N]: 1, + [MeterValuePhase.L2]: 2, + [MeterValuePhase.L2_L3]: 5, + [MeterValuePhase.L2_N]: 2, + [MeterValuePhase.L3]: 3, + [MeterValuePhase.L3_L1]: 6, + [MeterValuePhase.L3_N]: 3, + [MeterValuePhase.N]: 7, +} as const satisfies Record + +/** + * Groups templates by measurand and sorts each bucket by phase rank. + * Templates without an explicit `measurand` default to + * `Energy.Active.Import.Register`, mirroring the existing convention. + * @param templates - Templates configured on the connector (or `undefined`). + * @returns Grouped, phase-ordered templates. + */ +const groupTemplatesByMeasurand = ( + templates: SampledValueTemplate[] | undefined +): Map => { + const groups = Map.groupBy( + templates ?? [], + t => t.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + for (const bucket of groups.values()) { + bucket.sort( + (a, b) => + (a.phase == null ? 0 : PHASE_RANK[a.phase]) - (b.phase == null ? 0 : PHASE_RANK[b.phase]) + ) + } + return groups +} + +/** + * Resolves the exact physical value to emit for a template given the + * coherent sample. Returns `undefined` for unsupported `(measurand, phase)` + * pairs so the caller can log-and-skip. Rounding is deferred to the emit + * site so unit-conversion divisions round once. + * + * Per-phase resolution (balanced 3-phase Y assumption): + * - Voltage: L-N ⇒ `sample.voltageV`; L-L ⇒ `√phases × sample.voltageV` + * (`√phases` collapses to 1 on single-phase, in which case L-L has no + * physical meaning and the template is skipped); N ⇒ 0. + * - Power.Active.Import: aggregate ⇒ total P; L-N ⇒ `P / phases`; + * L-L undefined; N undefined (neutral carries no active power in + * balanced 3-φ Y). + * - Current.Import: any line phase ⇒ `sample.currentA` (line current); + * L-L undefined; N ⇒ 0 (balanced 3-φ Y neutral current is zero). + * - SoC: aggregate scalar; phase-qualified templates rejected. + * - Energy.Active.Import.Register: aggregate ⇒ total register; L-N ⇒ + * `register / phases` (per-phase energy contribution under balanced + * 3-φ Y; Σ across all L-N templates equals the aggregate register + * within emit-unit rounding granularity — Wh: ≤ phases · 0.005 Wh; + * kWh: ≤ phases · 5 Wh); L-L undefined; N undefined. OCPP 2.0.1 + * `SampledDataCtrlr.RegisterValuesWithoutPhases` is not consulted; + * per-phase emission is driven by the connector template's phase + * qualifier. + * @param measurand - Target measurand. + * @param phase - Template `phase` field (may be `undefined`). + * @param sample - Coherent sample (source of aggregate values). + * @param numberOfPhases - Session phase count. + * @param connectorStatus - Connector status (for the energy register). + * @returns Value to emit, or `undefined` if the combination is unsupported. + */ +const resolvePhasedValue = ( + measurand: MeterValueMeasurand, + phase: MeterValuePhase | undefined, + sample: CoherentSample, + numberOfPhases: number, + connectorStatus: ConnectorStatus +): number | undefined => { + const family = phaseFamily(phase) + switch (measurand) { + case MeterValueMeasurand.CURRENT_IMPORT: + if (family === 'LineToLine') return undefined + if (family === 'Neutral') return 0 + return sample.currentA + case MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER: { + if (family === 'LineToLine' || family === 'Neutral') return undefined + const register = Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0) + if (family === 'LineToNeutral') { + if (numberOfPhases <= 0) return undefined + return register / numberOfPhases + } + return register + } + case MeterValueMeasurand.POWER_ACTIVE_IMPORT: + if (family === 'LineToLine' || family === 'Neutral') return undefined + if (family === 'LineToNeutral') { + if (numberOfPhases <= 0) return undefined + return sample.powerW / numberOfPhases + } + return sample.powerW + case MeterValueMeasurand.STATE_OF_CHARGE: + if (family !== 'Aggregate') return undefined + return sample.socPercent + case MeterValueMeasurand.VOLTAGE: + if (family === 'Neutral') return 0 + if (family === 'LineToLine') { + if (numberOfPhases <= 1) return undefined + return Math.sqrt(numberOfPhases) * sample.voltageV + } + return sample.voltageV + default: + return undefined + } +} + +/** + * Measurand → matching kilo-prefixed unit lookup. Populated only for the + * measurands whose `SampledValueTemplate.unit` may legitimately carry a + * kilo-scaled value (kW / kWh). Any other `(measurand, unit)` pair + * emits at unit scale (divider = 1). + */ +const KILO_UNIT_BY_MEASURAND: ReadonlyMap = new Map< + MeterValueMeasurand, + MeterValueUnit +>([ + [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, MeterValueUnit.KILO_WATT_HOUR], + [MeterValueMeasurand.POWER_ACTIVE_IMPORT, MeterValueUnit.KILO_WATT], +]) + +/** + * Returns the unit divider for a `(measurand, unit)` pair: the kilo divider + * when the template's unit is the kilo-prefixed variant of the measurand's + * base unit (kW for Power, kWh for Energy register), otherwise 1. + * @param measurand - Target measurand. + * @param unit - Template unit (may be `undefined`). + * @returns `Constants.UNIT_DIVIDER_KILO` or `1`. + */ +const resolveUnitDivider = ( + measurand: MeterValueMeasurand, + unit: MeterValueUnit | undefined +): number => + unit != null && KILO_UNIT_BY_MEASURAND.get(measurand) === unit ? Constants.UNIT_DIVIDER_KILO : 1 + +/** + * Returns the SampledValueTemplate array configured on the given connector. + * + * Reads the connector-level `MeterValues` templates only. Unlike + * {@link ../ocpp/OCPPServiceUtils.getSampledValueTemplate}, this does NOT + * fall back to EVSE-level `MeterValues` templates: the {@link ICoherentContext} + * surface exposes `getConnectorStatus` but not `getEvseStatus`. Adding + * EVSE-level template inheritance to the coherent path requires extending + * the context interface with `getEvseStatus`. + * @param context - Charging-station context. + * @param connectorId - Connector identifier. + * @returns Templates or `undefined`. + */ +const resolveTemplates = ( + context: ICoherentContext, + connectorId: number +): SampledValueTemplate[] | undefined => { + return context.getConnectorStatus(connectorId)?.MeterValues +} + +/** + * Builds a complete OCPP {@link MeterValue} from a coherent sample. + * + * Emission order: + * - Across measurands: `SoC → Voltage → Power → Current → Energy`. + * - Within a measurand with multiple phase-qualified templates: no-phase + * first, then `L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N`. + * + * Per-phase resolution — see {@link resolvePhasedValue}. Unsupported + * `(measurand, phase)` combinations are logged and skipped. + * + * Only measurands enabled by the caller-resolved allow-list are emitted. + * The energy register is advanced unconditionally by + * {@link advanceEnergyRegister} independent of whether the Energy + * measurand is emitted. + * @param context - Charging-station context. + * @param 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). + * @param mvContext - Optional MeterValue reading context. + * @param enabledMeasurands - Optional allow-list resolved from the + * version-appropriate OCPP variable at the `buildMeterValue` boundary. + * When `undefined`, all templates emit (default behavior). When defined, + * only measurands in the set emit. Governs OCPP 2.0.1 J02.FR.11 / + * E02.FR.09 / E06.FR.11 and OCPP 1.6 `MeterValuesSampledData`. + * @returns MeterValue with sampled values and current timestamp. + */ +export const buildCoherentMeterValue = ( + context: ICoherentContext, + session: CoherentSession, + buildVersionedSampledValue: BuildVersionedSampledValue, + options: ComputeSampleOptions, + mvContext?: MeterValueContext, + enabledMeasurands?: ReadonlySet +): MeterValue => { + const connectorStatus = context.getConnectorStatus(session.connectorId) + if (connectorStatus == null) { + logger.warn( + `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: missing connector ${session.connectorId.toString()} for transaction ${String(session.transactionId)}` + ) + return { sampledValue: [], timestamp: new Date() } + } + + const sample = computeCoherentSample(context, connectorStatus, session, options) + // Own the register update: happens once per sample, unconditionally, so + // meterStop is correct even when Energy.Active.Import.Register is not in + // the configured MeterValues. + advanceEnergyRegister(connectorStatus, sample.deltaEnergyWh) + + const templates = resolveTemplates(context, session.connectorId) + const groups = groupTemplatesByMeasurand(templates) + const sampledValue: SampledValue[] = [] + const isEnabled = (measurand: MeterValueMeasurand): boolean => + enabledMeasurands == null || enabledMeasurands.has(measurand) + const numberOfPhases = session.numberOfPhases + + for (const measurand of MEASURAND_EMIT_ORDER) { + if (!isEnabled(measurand)) continue + const bucket = groups.get(measurand) + if (bucket == null) continue + for (const template of bucket) { + const raw = resolvePhasedValue( + measurand, + template.phase, + sample, + numberOfPhases, + connectorStatus + ) + if (raw == null) { + logger.warn( + `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: unsupported (${measurand}, phase=${String(template.phase)}) — template skipped` + ) + continue + } + // Narrow the OCPP 2.0 `SampledValueTemplate.unit` open-string branch + // to the closed `MeterValueUnit` union for the Map lookup below; any + // string outside the enum returns `undefined` from the Map and falls + // through to divider = 1 (unit-scale emission). + const unitDivider = resolveUnitDivider(measurand, template.unit as MeterValueUnit | undefined) + const scaled = roundTo(raw / unitDivider, ROUNDING_SCALE) + sampledValue.push(buildVersionedSampledValue(template, scaled, mvContext)) + } + } + + // MeterValue = OCPP16MeterValue | OCPP20MeterValue is a discriminated + // union that diverges on the SampledValue.context enum. Coherent path + // produces version-appropriate SampledValues via the injected + // buildVersionedSampledValue callback, but the compile-time union of + // SampledValue[] cannot be narrowed here — a boundary cast is required. + return { sampledValue, timestamp: new Date() } as MeterValue +} diff --git a/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts b/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts deleted file mode 100644 index e926b30c..00000000 --- a/src/charging-station/meter-values/CoherentMeterValuesGenerator.ts +++ /dev/null @@ -1,820 +0,0 @@ -// Copyright Jerome Benoit. 2021-2025. All Rights Reserved. - -/** - * @file Physics-based coherent MeterValues generator. - * @description Constructs a coherent {@link MeterValue} in which every - * emitted measurand is derived from a single physics chain - * (V → P → I → ΔE → SoC) rather than sampled independently. - * - * Invariants (enforced by construction): - * - **INV-1**: AC: `P = V × I × phases`; DC: `P = V × I`. Emitted `powerW` - * is recomputed from the rounded emitted current and voltage so - * `|P - V·I·phases|` stays within the `ROUNDING_SCALE` half-width - * (≤ 0.005 W scalar bound) regardless of V or phases. Per-phase L-N - * `Power.Active.Import` emission is derived as - * `round(aggregate_P / phases, 2)`; the per-phase identity - * `|P_LxN - V_LxN · I_Lx|` therefore holds within `2 × ROUNDING_SCALE` - * half-width (≤ 0.01 W) — one half-width for the aggregate emit and - * one for the per-phase division. - * - **INV-2**: `SoC(t+1) ≥ SoC(t)` and `ΔSoC = ΔE / batteryCapacityWh × 100`. - * SoC monotone non-decreasing during charging and saturates at 100 %. - * - **INV-3**: `ΔE = P_clamped × Δt / MS_PER_HOUR` where `P_clamped` is the - * pre-emit-rounding capacity-clamped power. `E(t+1) ≥ E(t)`. A consumer - * integrating the post-rounding emitted `powerW` samples may diverge - * from the register by at most `ROUNDING_SCALE half-width × N × Δt / - * MS_PER_HOUR` Wh over `N` samples — bounded and invisible at - * `ROUNDING_SCALE` (~0.12 Wh over 24 h at 1 Hz). - * - `P ≤ min(EVSE_max, EV_acceptance(SoC))`. - * - `SoC ≥ 100 ⇒ P = 0, I = 0, ΔE = 0`. - * - * The generator owns the connector energy register update: it advances the - * register exactly once per sample, unconditionally, so `meterStop` is - * correct even when `Energy.Active.Import.Register` is not in the - * configured MeterValues. - * - * TODO(#1936): file size exceeds the 250 LOC ceiling documented in - * AGENTS.md. Modular split (CoherentSampleComputer.ts + CoherentMeterValueBuilder.ts, - * keeping session lifecycle helpers in this entry) tracked as follow-up. - */ - -import type { - ConnectorStatus, - MeterValue, - MeterValueContext, - SampledValue, - SampledValueTemplate, -} from '../../types/index.js' -import type { CoherentSession, EvProfile, ICoherentContext } from './types.js' - -import { - CurrentType, - MeterValueMeasurand, - MeterValuePhase, - MeterValueUnit, - Voltage, -} from '../../types/index.js' -import { Constants, logger, roundTo } from '../../utils/index.js' -import { interpolateChargingCurve, selectEvProfile } from './EvProfiles.js' -import { deriveSeed, hashLabel, mulberry32 } from './Prng.js' - -const moduleName = 'CoherentMeterValuesGenerator' - -/** - * Decimal places for all physics-quantity rounding (V, A, W, Wh, SoC). - * The `roundTo` half-width bound is `0.5 × 10^-ROUNDING_SCALE = 0.005` on - * each rounded quantity; INV-1 residual is bounded by this scalar. - */ -const ROUNDING_SCALE = 2 - -/** - * Signature of the versioned SampledValue builder returned by the - * OCPP-version dispatcher in {@link ../ocpp/OCPPServiceUtils.buildMeterValue}. - * Kept structurally compatible so a coherent generator can emit SampledValues - * in either OCPP 1.6 or 2.0 formats without knowing the version. - */ -export type BuildVersionedSampledValue = ( - sampledValueTemplate: SampledValueTemplate, - value: number, - context?: MeterValueContext, - phase?: MeterValuePhase -) => SampledValue - -/** - * Runtime-only per-session state. Kept in a module-scope WeakMap keyed by - * the {@link CoherentSession} object (rather than by transactionId) so - * runtime state is scoped to the session's identity — no cross-station - * coupling when two stations happen to share a transactionId — and is - * auto-collected when the session becomes unreachable. - */ -interface SessionRuntime { - voltagePrng?: () => number -} - -const sessionRuntimes = new WeakMap() - -/** - * Retrieves the runtime bag for a session, creating it on first access. - * Not exported: only the generator reads or writes runtime state. - * @param session - Coherent session. - * @returns Live runtime bag (mutated in place). - */ -const getSessionRuntime = (session: CoherentSession): SessionRuntime => { - let runtime = sessionRuntimes.get(session) - if (runtime == null) { - runtime = {} - sessionRuntimes.set(session, runtime) - } - return runtime -} - -/** - * Disposes runtime state for a session. Call from every session-teardown - * path (stop/reset/disconnect) to release cached PRNG state eagerly. - * The WeakMap makes eager disposal optional — unreachable sessions are - * collected automatically — but eager disposal preserves determinism - * across sequential transactions that reuse the same session identity. - * Idempotent. - * @param session - Coherent session (or `undefined` when the caller has - * no session at hand). - * @returns `true` when runtime was removed, `false` otherwise. - */ -export const disposeCoherentSessionRuntime = (session: CoherentSession | undefined): boolean => { - if (session == null) { - return false - } - return sessionRuntimes.delete(session) -} - -/** - * Deterministic per-transaction stream splitter. Combines the station - * `randomSeed` (or a stable fallback), the transactionId, and a label so - * that adding a new consumer never shifts an existing stream's sequence. - * @param rootSeed - Root 32-bit seed for the station. - * @param transactionId - Transaction identifier. - * @param label - Stream label (`'VOLTAGE_NOISE'`, `'POWER_NOISE'`, ...). - * @returns PRNG function producing [0, 1) floats. - */ -export const createStreamPrng = ( - rootSeed: number, - transactionId: number | string, - label: string -): (() => number) => { - // Namespace the transactionId leg with a `tx:` prefix so - // `String(transactionId) === label` cannot trigger the XOR self-inverse - // `deriveSeed(deriveSeed(r, X), X) === r`. Labels never start with `tx:` - // by construction (`VOLTAGE_NOISE`, `POWER_NOISE`, ...). - const txSeed = deriveSeed(rootSeed, `tx:${String(transactionId)}`) - return mulberry32(deriveSeed(txSeed, label)) -} - -/** - * Symmetric fluctuation helper: draws a uniform sample and maps it into - * `[base * (1 - percent), base * (1 + percent))`. - * @param base - Nominal value. - * @param percent - Half-width of the symmetric interval (e.g. 0.01 = ±1 %). - * @param prng - Seeded PRNG stream. - * @returns Fluctuated value. - */ -const fluctuate = (base: number, percent: number, prng: () => number): number => { - return base * (1 + (prng() * 2 - 1) * percent) -} - -/** - * 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 = ( - session: CoherentSession | undefined -): session is CoherentSession => session != null - -/** - * Unconditionally advances the connector energy registers by `deltaEnergyWh`. - * The coherent path owns register updates so `meterStop` stays correct even - * when the `Energy.Active.Import.Register` measurand is not configured. - * Negative or nullish register starting values are clamped to zero before - * accrual. - * @param connectorStatus - Target connector status (in-place update). - * @param deltaEnergyWh - Energy delta (Wh). - */ -export const advanceEnergyRegister = ( - connectorStatus: ConnectorStatus | undefined, - deltaEnergyWh: number -): void => { - if (connectorStatus == null) { - return - } - connectorStatus.energyActiveImportRegisterValue = - Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0) + deltaEnergyWh - connectorStatus.transactionEnergyActiveImportRegisterValue = - Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + deltaEnergyWh -} - -/** - * Coherent physics sample. All fields follow the `` naming - * convention (`currentA`, `powerW`, `voltageV`, ...). - */ -export interface CoherentSample { - currentA: number - deltaEnergyWh: number - energyRegisterWh: number - powerW: number - socPercent: number - voltageV: number -} - -/** - * Options for {@link computeCoherentSample}. - */ -export interface ComputeSampleOptions { - /** - * Sample interval in milliseconds. Drives energy accrual and the - * remaining-capacity clamp. Non-positive/non-finite triggers the - * zero-sample defensive branch. - */ - intervalMs: number - /** - * Sample timestamp in milliseconds (typically `Date.now()`); combined - * with `session.sessionStartMs` for ramp-up progress. Non-finite - * triggers the zero-sample defensive branch. - */ - nowMs: number - /** - * Root 32-bit seed for stream splitting; combined with - * `session.transactionId` and per-measurand labels to derive - * independent PRNG streams via FNV-1a stream splitting. - */ - rootSeed: number - /** - * Enable or disable per-sample voltage noise. When `false`, `voltageV` - * is exactly the nominal voltage with no PRNG-derived fluctuation. - * Intended for deterministic unit tests. Defaults to `true` when - * omitted (the `options.voltageNoise !== false` guard). - */ - voltageNoise?: boolean -} - -const buildZeroSample = ( - socPercent: number, - voltageV: number, - energyRegisterWh: number -): CoherentSample => ({ - currentA: 0, - deltaEnergyWh: 0, - energyRegisterWh, - powerW: 0, - socPercent: roundTo(socPercent, ROUNDING_SCALE), - voltageV: voltageV > 0 && Number.isFinite(voltageV) ? roundTo(voltageV, ROUNDING_SCALE) : 0, -}) - -/** - * Computes a single coherent sample and mutates the caller-owned - * `session.socPercent`. The energy register is NOT advanced here; the - * caller (`buildCoherentMeterValue`) invokes {@link advanceEnergyRegister} - * once per emitted sample so the semantics match the OCPP energy meter - * model. - * - * Physics chain (INV-1/INV-2/INV-3 hold by construction): - * 1. `rampFactor = min(1, elapsed / rampUp)` — immutable session start. - * 2. `V` — nominal ± small seed-derived noise. - * 3. `evAcceptanceW = curve(SoC) × profile.maxPowerW`. - * 4. `powerW = rampFactor × min(EVSE_max, evAcceptance) × socCap`. - * EVSE cap already folds in charging profiles via - * {@link ICoherentContext.getConnectorMaximumAvailablePower}. - * 5. `powerW` is then clamped to remaining battery capacity so a sample - * crossing 100 % SoC never over-charges the register. - * 6. `currentAExact = powerW / (V_round · phases)` — exact fraction - * (phases=1 for DC). Emitted current is rounded to `ROUNDING_SCALE`. - * 7. Emitted `powerW = round(V_round × currentA_round × phases)`; - * INV-1 holds within `ROUNDING_SCALE` half-width (≤ 0.005 W). - * 8. `ΔE = P_clamped × Δt / MS_PER_HOUR` — uses the pre-emit-rounding - * `powerW` so the register integrates the capacity-clamped power - * exactly (INV-3). - * 9. `ΔSoC = ΔE / capacity × 100`; `socPercent = min(100, soc + ΔSoC)`. - * @param context - Charging-station context. - * @param connectorStatus - Connector status. - * @param session - Active coherent session (resolved by caller). - * @param options - Per-sample parameters (interval, seed material, ...). - * @returns The computed sample. `energyRegisterWh` reflects the projected - * register value AFTER `advanceEnergyRegister` is applied by the caller. - */ -export const computeCoherentSample = ( - context: ICoherentContext, - connectorStatus: ConnectorStatus, - session: CoherentSession, - options: ComputeSampleOptions -): CoherentSample => { - const transactionId = session.transactionId - - // Defensive guard bundle covering NaN/incoherence sources: - // - intervalMs ≤ 0 or non-finite: divide-by-zero, negative Δt, or NaN/Infinity - // propagates through `maxPowerFromCapacityW = remainingWh · MS_PER_HOUR / - // intervalMs` and permanently poisons session.socPercent. `Number.isFinite` - // covers the NaN/±Infinity paths since `NaN <= 0 === false`. - // - batteryCapacityWh ≤ 0 or non-finite: Zod (`EvProfileSchema`) enforces - // `.positive()` at file load, but `injectCoherentSession` bypasses Zod; - // `deltaSocPercent = ΔE / batteryCapacityWh × 100 = NaN` would poison SoC. - // - nominal voltage ≤ 0 or non-finite: `Voltage` enum values are all-positive, - // but a template override or future dynamic supply could return 0. - // - nowMs non-finite: pushes elapsedMs to NaN and destabilizes rampFactor. - // - AC with numberOfPhases ≤ 0: divisor collapses to 0 (`V · 0 = 0`), - // currentA is guarded to zero, and P = 0 silently — a misconfigured - // multi-phase station would emit zeros for the whole session. Fail - // loudly with a zero sample so operators notice the misconfiguration. - const batteryCapacityWh = session.profile.batteryCapacityWh - const nominalV: number = session.voltageOutNominal - const currentType = session.currentType - const numberOfPhases = session.numberOfPhases - if ( - options.intervalMs <= 0 || - !Number.isFinite(options.intervalMs) || - batteryCapacityWh <= 0 || - !Number.isFinite(batteryCapacityWh) || - nominalV <= 0 || - !Number.isFinite(nominalV) || - !Number.isFinite(options.nowMs) || - (currentType === CurrentType.AC && numberOfPhases <= 0) - ) { - const safeV = nominalV > 0 && Number.isFinite(nominalV) ? nominalV : 0 - return buildZeroSample( - session.socPercent, - safeV, - Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) - ) - } - - const elapsedMs = Math.max(0, options.nowMs - session.sessionStartMs) - // Non-positive or non-finite rampUpDurationMs would either divide by zero - // (NaN) or produce rampFactor > 1 / negative. Treat any invalid value as - // "no ramp" (immediate full-power), matching the existing semantic where - // rampUpDurationMs = 0 means immediate full-power. - // Sub-millisecond values (e.g. Number.EPSILON) pass the guard but yield - // elapsedMs / rampUpDurationMs >> 1 for any real elapsed time, so - // Math.min(1, …) = 1 — semantically equivalent to rampUpDurationMs = 0. - const rampFactor = - session.rampUpDurationMs > 0 && Number.isFinite(session.rampUpDurationMs) - ? Math.min(1, elapsedMs / session.rampUpDurationMs) - : 1 - - // Voltage: nominal ± small seed-derived noise. The voltage PRNG lives on - // module-scope runtime state (not on the serializable session) so its - // stream advances across samples; constructing a new PRNG per sample - // would restart from the same seed each draw and produce a stalled - // (non-advancing) sequence. - let sampledV = nominalV - if (options.voltageNoise !== false) { - const runtime = getSessionRuntime(session) - runtime.voltagePrng ??= createStreamPrng(options.rootSeed, transactionId, 'VOLTAGE_NOISE') - sampledV = fluctuate( - nominalV, - Constants.DEFAULT_COHERENT_VOLTAGE_NOISE_PERCENT, - runtime.voltagePrng - ) - } - const roundedV = roundTo(sampledV, ROUNDING_SCALE) - - // EV acceptance from the curve at running SoC. - const acceptanceFraction = interpolateChargingCurve( - session.profile.chargingCurve, - session.socPercent - ) - const evAcceptanceW = acceptanceFraction * session.profile.maxPowerW - - // EVSE cap (already includes hardware/charging-profile clamps via ChargingStation). - const evseLimitW = context.getConnectorMaximumAvailablePower(session.connectorId) - - const socCap = session.socPercent >= 100 ? 0 : 1 - const targetPowerW = rampFactor * Math.min(evseLimitW, evAcceptanceW) * socCap - let powerW = Math.max(0, targetPowerW) - - // Clamp powerW to whatever the remaining battery capacity accepts over - // this interval so a sample that crosses 100 % SoC cannot over-charge the - // register. INV-3 is preserved because ΔE is computed from the clamped - // power below. - const remainingWh = Math.max( - 0, - ((100 - session.socPercent) / 100) * session.profile.batteryCapacityWh - ) - const maxPowerFromCapacityW = (remainingWh * Constants.MS_PER_HOUR) / options.intervalMs - powerW = Math.min(powerW, maxPowerFromCapacityW) - - // Physics: derive per-phase current as an exact fraction so - // V_round · currentAExact · phases = powerW - // holds identically. `numberOfPhases` is 1 for DC (line above) so a - // single branch covers both currents. Using integer-rounded amps here - // would inflate V·I·phases above the capacity-clamped powerW by up to - // V·phases·0.5 W, breaking INV-1. - const divisor = roundedV * numberOfPhases - const currentAExact = divisor > 0 ? powerW / divisor : 0 - - // Emission: round current to `ROUNDING_SCALE`, then derive emitted power - // from the rounded current so INV-1 (P = V·I·phases) holds within - // `ROUNDING_SCALE` half-width (≤ 0.005 W) regardless of V or phases. - const roundedCurrent = roundTo(currentAExact, ROUNDING_SCALE) - const roundedPower = roundTo(roundedV * roundedCurrent * numberOfPhases, ROUNDING_SCALE) - - // Energy accounting uses the clamped (pre-rounding) `powerW` so INV-3 - // holds within floating-point ε and the capacity budget is respected - // exactly. `Math.max(0, ...)` on `preRegisterWh` mirrors the clamp - // applied by `advanceEnergyRegister` so the reported `energyRegisterWh` - // and the post-advance persisted state agree even if the persisted - // register is corrupted to a negative value. - const deltaEnergyWh = (powerW * options.intervalMs) / Constants.MS_PER_HOUR - const preRegisterWh = Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) - const projectedRegisterWh = preRegisterWh + deltaEnergyWh - - // INV-2: SoC(t+1) ≥ SoC(t); ΔSoC = ΔE / batteryCapacityWh × 100. Saturates at 100 %. - const deltaSocPercent = (deltaEnergyWh / session.profile.batteryCapacityWh) * 100 - session.socPercent = Math.min(100, session.socPercent + deltaSocPercent) - - return { - currentA: roundedCurrent, - deltaEnergyWh, - energyRegisterWh: projectedRegisterWh, - powerW: roundedPower, - socPercent: roundTo(session.socPercent, ROUNDING_SCALE), - voltageV: roundedV, - } -} - -/** - * Phase family classifier lookup for coherent emission. `satisfies Record<...>` - * gates compile-time exhaustiveness so a new `MeterValuePhase` value fails - * compile until classified. `Aggregate` is applied when `phase` is `undefined` - * (the sentinel handled by `phaseFamily` outside the table). - * - `LineToNeutral`: bare `L1`/`L2`/`L3` and `L1-N`/`L2-N`/`L3-N` - * (line-current or phase-voltage measurements). - * - `LineToLine`: `L1-L2`/`L2-L3`/`L3-L1` (line-to-line voltage; not - * defined for current or power in the coherent model). - * - `Neutral`: `N` (physically 0 for balanced 3-phase Y). - */ -const PHASE_FAMILY = { - [MeterValuePhase.L1]: 'LineToNeutral', - [MeterValuePhase.L1_L2]: 'LineToLine', - [MeterValuePhase.L1_N]: 'LineToNeutral', - [MeterValuePhase.L2]: 'LineToNeutral', - [MeterValuePhase.L2_L3]: 'LineToLine', - [MeterValuePhase.L2_N]: 'LineToNeutral', - [MeterValuePhase.L3]: 'LineToNeutral', - [MeterValuePhase.L3_L1]: 'LineToLine', - [MeterValuePhase.L3_N]: 'LineToNeutral', - [MeterValuePhase.N]: 'Neutral', -} as const satisfies Record - -const phaseFamily = ( - phase: MeterValuePhase | undefined -): 'Aggregate' | 'LineToLine' | 'LineToNeutral' | 'Neutral' => - phase == null ? 'Aggregate' : PHASE_FAMILY[phase] - -/** - * Emit order across measurands, mirroring the `getSampledValueTemplate` - * path (SoC → Voltage → Power → Current → Energy). Preserved so downstream - * consumers relying on OCPP MeterValue ordering keep working. - */ -const MEASURAND_EMIT_ORDER = [ - MeterValueMeasurand.STATE_OF_CHARGE, - MeterValueMeasurand.VOLTAGE, - MeterValueMeasurand.POWER_ACTIVE_IMPORT, - MeterValueMeasurand.CURRENT_IMPORT, - MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, -] as const - -/** - * Within-measurand phase order for deterministic per-phase emission: - * no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N. - * Lower rank emits first. `satisfies Record<...>` gates exhaustiveness - * so a new `MeterValuePhase` value fails compile until ranked. - */ -const PHASE_RANK = { - [MeterValuePhase.L1]: 1, - [MeterValuePhase.L1_L2]: 4, - [MeterValuePhase.L1_N]: 1, - [MeterValuePhase.L2]: 2, - [MeterValuePhase.L2_L3]: 5, - [MeterValuePhase.L2_N]: 2, - [MeterValuePhase.L3]: 3, - [MeterValuePhase.L3_L1]: 6, - [MeterValuePhase.L3_N]: 3, - [MeterValuePhase.N]: 7, -} as const satisfies Record - -/** - * Groups templates by measurand and sorts each bucket by phase rank. - * Templates without an explicit `measurand` default to - * `Energy.Active.Import.Register`, mirroring the existing convention. - * @param templates - Templates configured on the connector (or `undefined`). - * @returns Grouped, phase-ordered templates. - */ -const groupTemplatesByMeasurand = ( - templates: SampledValueTemplate[] | undefined -): Map => { - const groups = Map.groupBy( - templates ?? [], - t => t.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER - ) - for (const bucket of groups.values()) { - bucket.sort( - (a, b) => - (a.phase == null ? 0 : PHASE_RANK[a.phase]) - (b.phase == null ? 0 : PHASE_RANK[b.phase]) - ) - } - return groups -} - -/** - * Resolves the exact physical value to emit for a template given the - * coherent sample. Returns `undefined` for unsupported `(measurand, phase)` - * pairs so the caller can log-and-skip. Rounding is deferred to the emit - * site so unit-conversion divisions round once. - * - * Per-phase resolution (balanced 3-phase Y assumption): - * - Voltage: L-N ⇒ `sample.voltageV`; L-L ⇒ `√phases × sample.voltageV` - * (`√phases` collapses to 1 on single-phase, in which case L-L has no - * physical meaning and the template is skipped); N ⇒ 0. - * - Power.Active.Import: aggregate ⇒ total P; L-N ⇒ `P / phases`; - * L-L undefined; N undefined (neutral carries no active power in - * balanced 3-φ Y). - * - Current.Import: any line phase ⇒ `sample.currentA` (line current); - * L-L undefined; N ⇒ 0 (balanced 3-φ Y neutral current is zero). - * - SoC: aggregate scalar; phase-qualified templates rejected. - * - Energy.Active.Import.Register: aggregate ⇒ total register; L-N ⇒ - * `register / phases` (per-phase energy contribution under balanced - * 3-φ Y; Σ across all L-N templates equals the aggregate register - * within emit-unit rounding granularity — Wh: ≤ phases · 0.005 Wh; - * kWh: ≤ phases · 5 Wh); L-L undefined; N undefined. OCPP 2.0.1 - * `SampledDataCtrlr.RegisterValuesWithoutPhases` is not consulted; - * per-phase emission is driven by the connector template's phase - * qualifier. - * @param measurand - Target measurand. - * @param phase - Template `phase` field (may be `undefined`). - * @param sample - Coherent sample (source of aggregate values). - * @param numberOfPhases - Session phase count. - * @param connectorStatus - Connector status (for the energy register). - * @returns Value to emit, or `undefined` if the combination is unsupported. - */ -const resolvePhasedValue = ( - measurand: MeterValueMeasurand, - phase: MeterValuePhase | undefined, - sample: CoherentSample, - numberOfPhases: number, - connectorStatus: ConnectorStatus -): number | undefined => { - const family = phaseFamily(phase) - switch (measurand) { - case MeterValueMeasurand.CURRENT_IMPORT: - if (family === 'LineToLine') return undefined - if (family === 'Neutral') return 0 - return sample.currentA - case MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER: { - if (family === 'LineToLine' || family === 'Neutral') return undefined - const register = Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0) - if (family === 'LineToNeutral') { - if (numberOfPhases <= 0) return undefined - return register / numberOfPhases - } - return register - } - case MeterValueMeasurand.POWER_ACTIVE_IMPORT: - if (family === 'LineToLine' || family === 'Neutral') return undefined - if (family === 'LineToNeutral') { - if (numberOfPhases <= 0) return undefined - return sample.powerW / numberOfPhases - } - return sample.powerW - case MeterValueMeasurand.STATE_OF_CHARGE: - if (family !== 'Aggregate') return undefined - return sample.socPercent - case MeterValueMeasurand.VOLTAGE: - if (family === 'Neutral') return 0 - if (family === 'LineToLine') { - if (numberOfPhases <= 1) return undefined - return Math.sqrt(numberOfPhases) * sample.voltageV - } - return sample.voltageV - default: - return undefined - } -} - -/** - * Measurand → matching kilo-prefixed unit lookup. Populated only for the - * measurands whose `SampledValueTemplate.unit` may legitimately carry a - * kilo-scaled value (kW / kWh). Any other `(measurand, unit)` pair - * emits at unit scale (divider = 1). - */ -const KILO_UNIT_BY_MEASURAND: ReadonlyMap = new Map< - MeterValueMeasurand, - MeterValueUnit ->([ - [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, MeterValueUnit.KILO_WATT_HOUR], - [MeterValueMeasurand.POWER_ACTIVE_IMPORT, MeterValueUnit.KILO_WATT], -]) - -/** - * Returns the unit divider for a `(measurand, unit)` pair: the kilo divider - * when the template's unit is the kilo-prefixed variant of the measurand's - * base unit (kW for Power, kWh for Energy register), otherwise 1. - * @param measurand - Target measurand. - * @param unit - Template unit (may be `undefined`). - * @returns `Constants.UNIT_DIVIDER_KILO` or `1`. - */ -const resolveUnitDivider = ( - measurand: MeterValueMeasurand, - unit: MeterValueUnit | undefined -): number => - unit != null && KILO_UNIT_BY_MEASURAND.get(measurand) === unit ? Constants.UNIT_DIVIDER_KILO : 1 - -/** - * Returns the SampledValueTemplate array configured on the given connector. - * - * Reads the connector-level `MeterValues` templates only. Unlike - * {@link ../ocpp/OCPPServiceUtils.getSampledValueTemplate}, this does NOT - * fall back to EVSE-level `MeterValues` templates: the {@link ICoherentContext} - * surface exposes `getConnectorStatus` but not `getEvseStatus`. Adding - * EVSE-level template inheritance to the coherent path requires extending - * the context interface and is tracked as a follow-up in issue #1936. - * @param context - Charging-station context. - * @param connectorId - Connector identifier. - * @returns Templates or `undefined`. - */ -const resolveTemplates = ( - context: ICoherentContext, - connectorId: number -): SampledValueTemplate[] | undefined => { - return context.getConnectorStatus(connectorId)?.MeterValues -} - -/** - * Builds a complete OCPP {@link MeterValue} from a coherent sample. - * - * Emission order: - * - Across measurands: `SoC → Voltage → Power → Current → Energy`. - * - Within a measurand with multiple phase-qualified templates: no-phase - * first, then `L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N`. - * - * Per-phase resolution — see {@link resolvePhasedValue}. Unsupported - * `(measurand, phase)` combinations are logged and skipped. - * - * Only measurands enabled by the caller-resolved allow-list are emitted. - * The energy register is advanced unconditionally by - * {@link advanceEnergyRegister} independent of whether the Energy - * measurand is emitted. - * @param context - Charging-station context. - * @param 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). - * @param mvContext - Optional MeterValue reading context. - * @param enabledMeasurands - Optional allow-list resolved from the - * version-appropriate OCPP variable at the `buildMeterValue` boundary. - * When `undefined`, all templates emit (default behavior). When defined, - * only measurands in the set emit. Governs OCPP 2.0.1 J02.FR.11 / - * E02.FR.09 / E06.FR.11 and OCPP 1.6 `MeterValuesSampledData`. - * @returns MeterValue with sampled values and current timestamp. - */ -export const buildCoherentMeterValue = ( - context: ICoherentContext, - session: CoherentSession, - buildVersionedSampledValue: BuildVersionedSampledValue, - options: ComputeSampleOptions, - mvContext?: MeterValueContext, - enabledMeasurands?: ReadonlySet -): MeterValue => { - const connectorStatus = context.getConnectorStatus(session.connectorId) - if (connectorStatus == null) { - logger.warn( - `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: missing connector ${session.connectorId.toString()} for transaction ${String(session.transactionId)}` - ) - return { sampledValue: [], timestamp: new Date() } - } - - const sample = computeCoherentSample(context, connectorStatus, session, options) - // Own the register update: happens once per sample, unconditionally, so - // meterStop is correct even when Energy.Active.Import.Register is not in - // the configured MeterValues. - advanceEnergyRegister(connectorStatus, sample.deltaEnergyWh) - - const templates = resolveTemplates(context, session.connectorId) - const groups = groupTemplatesByMeasurand(templates) - const sampledValue: SampledValue[] = [] - const isEnabled = (measurand: MeterValueMeasurand): boolean => - enabledMeasurands == null || enabledMeasurands.has(measurand) - const numberOfPhases = session.numberOfPhases - - for (const measurand of MEASURAND_EMIT_ORDER) { - if (!isEnabled(measurand)) continue - const bucket = groups.get(measurand) - if (bucket == null) continue - for (const template of bucket) { - const raw = resolvePhasedValue( - measurand, - template.phase, - sample, - numberOfPhases, - connectorStatus - ) - if (raw == null) { - logger.warn( - `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: unsupported (${measurand}, phase=${String(template.phase)}) — template skipped` - ) - continue - } - // Narrow the OCPP 2.0 `SampledValueTemplate.unit` open-string branch - // to the closed `MeterValueUnit` union for the Map lookup below; any - // string outside the enum returns `undefined` from the Map and falls - // through to divider = 1 (unit-scale emission). - const unitDivider = resolveUnitDivider(measurand, template.unit as MeterValueUnit | undefined) - const scaled = roundTo(raw / unitDivider, ROUNDING_SCALE) - sampledValue.push(buildVersionedSampledValue(template, scaled, mvContext)) - } - } - - // MeterValue = OCPP16MeterValue | OCPP20MeterValue is a discriminated - // union that diverges on the SampledValue.context enum. Coherent path - // produces version-appropriate SampledValues via the injected - // buildVersionedSampledValue callback, but the compile-time union of - // SampledValue[] cannot be narrowed here — a boundary cast is required. - return { sampledValue, timestamp: new Date() } as MeterValue -} - -/** - * Options for {@link createCoherentSession}. - */ -export interface CreateSessionOptions { - /** Target connector id. */ - connectorId: number - /** Session start timestamp in milliseconds. Defaults to `Date.now()`. */ - now?: number - /** Non-empty EV profile pool; one profile is picked via seeded weighted random selection. */ - profiles: EvProfile[] - /** - * Optional ramp-up duration in milliseconds. Defaults to - * `Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS`. - */ - rampUpDurationMs?: number - /** Root 32-bit seed for stream splitting. */ - rootSeed: number - /** Transaction identifier. */ - transactionId: number | string -} - -/** - * Builds a {@link CoherentSession} deterministically from the profile pool - * and per-transaction seed material. Weight-based profile selection uses a - * dedicated `'PROFILE_PICK'` stream and initial SoC uses `'INITIAL_SOC'`, - * so adding one consumer does not shift any other stream's sequence - * (stream-splitting via FNV-1a label hashing — see `deriveSeed` in `Prng.ts`). - * - * The nominal AC voltage is treated as phase voltage (line-to-neutral) per - * {@link ../../utils/ElectricUtils.ACElectricUtils}. If the station is AC - * and `voltageOut` is 400 V or 800 V, a warning is logged since those - * values are line-to-line in most catalogs and the utilities would compute - * physically implausible power. - * @param context - Charging-station context. - * @param options - Session parameters. - * @returns Fully initialized session, or `undefined` when profiles is empty. - */ -export const createCoherentSession = ( - context: ICoherentContext, - options: CreateSessionOptions -): CoherentSession | undefined => { - if (options.profiles.length === 0) { - return undefined - } - const now = options.now ?? Date.now() - const profilePickPrng = createStreamPrng(options.rootSeed, options.transactionId, 'PROFILE_PICK') - const socPrng = createStreamPrng(options.rootSeed, options.transactionId, 'INITIAL_SOC') - const profile = selectEvProfile(options.profiles, profilePickPrng()) - const socRange = Math.max(0, profile.initialSocPercentMax - profile.initialSocPercentMin) - const initialSoc = profile.initialSocPercentMin + socPrng() * socRange - - const currentType = context.stationInfo?.currentOutType ?? CurrentType.AC - const voltageOutNominal = context.getVoltageOut() - if ( - currentType === CurrentType.AC && - (voltageOutNominal === Voltage.VOLTAGE_400 || voltageOutNominal === Voltage.VOLTAGE_800) - ) { - logger.warn( - `${context.logPrefix()} ${moduleName}.createCoherentSession: AC voltageOut=${voltageOutNominal.toString()}V is treated as line-to-neutral (phase voltage) by ACElectricUtils. If this value is meant as line-to-line, coherent power/current will be physically implausible.` - ) - } - - return { - connectorId: options.connectorId, - currentType, - numberOfPhases: currentType === CurrentType.AC ? context.getNumberOfPhases() : 1, - profile, - rampUpDurationMs: options.rampUpDurationMs ?? Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS, - sessionStartMs: now, - socPercent: initialSoc, - transactionId: options.transactionId, - voltageOutNominal, - } -} - -/** - * Resolves the root PRNG seed for a station. Prefers the template - * `randomSeed` and falls back to a FNV-1a hash of `hashId`, ensuring - * determinism across stations without accidentally sharing streams. - * @param stationInfo - Station info (`randomSeed` and `hashId`). - * @returns 32-bit unsigned root seed. - */ -export const resolveRootSeed = ( - stationInfo: undefined | { hashId?: string; randomSeed?: number } -): number => { - if (stationInfo?.randomSeed != null && Number.isFinite(stationInfo.randomSeed)) { - return stationInfo.randomSeed >>> 0 - } - return hashLabel(stationInfo?.hashId ?? '') -} diff --git a/src/charging-station/meter-values/CoherentSampleComputer.ts b/src/charging-station/meter-values/CoherentSampleComputer.ts new file mode 100644 index 00000000..2f143976 --- /dev/null +++ b/src/charging-station/meter-values/CoherentSampleComputer.ts @@ -0,0 +1,365 @@ +// Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Physics computation for coherent MeterValues. + * @description Owns the physics chain V → P → I → ΔE → SoC that produces a + * single {@link CoherentSample} per emission tick, the per-session + * runtime WeakMap that caches the voltage-noise PRNG across samples, + * and the `disposeCoherentSessionRuntime` teardown hook consumed by + * {@link ../CoherentMeterValuesManager}. + * + * Invariants (enforced by construction): + * - **INV-1**: AC: `P = V × I × phases`; DC: `P = V × I`. Emitted `powerW` + * is recomputed from the rounded emitted current and voltage so + * `|P - V·I·phases|` stays within the `ROUNDING_SCALE` half-width + * (≤ 0.005 W scalar bound) regardless of V or phases. Per-phase L-N + * `Power.Active.Import` emission is derived as + * `round(aggregate_P / phases, 2)`; the per-phase identity + * `|P_LxN - V_LxN · I_Lx|` therefore holds within `2 × ROUNDING_SCALE` + * half-width (≤ 0.01 W) — one half-width for the aggregate emit and + * one for the per-phase division. + * - **INV-2**: `SoC(t+1) ≥ SoC(t)` and `ΔSoC = ΔE / batteryCapacityWh × 100`. + * SoC monotone non-decreasing during charging and saturates at 100 %. + * - **INV-3**: `ΔE = P_clamped × Δt / MS_PER_HOUR` where `P_clamped` is the + * pre-emit-rounding capacity-clamped power. `E(t+1) ≥ E(t)`. A consumer + * integrating the post-rounding emitted `powerW` samples may diverge + * from the register by at most `ROUNDING_SCALE half-width × N × Δt / + * MS_PER_HOUR` Wh over `N` samples — bounded and invisible at + * `ROUNDING_SCALE` (~0.12 Wh over 24 h at 1 Hz). + * - `P ≤ min(EVSE_max, EV_acceptance(SoC))`. + * - `SoC ≥ 100 ⇒ P = 0, I = 0, ΔE = 0`. + * + * The energy register update lives in {@link advanceEnergyRegister}; the + * caller (`buildCoherentMeterValue` in the builder module) invokes it once + * per sample so `meterStop` stays correct even when + * `Energy.Active.Import.Register` is not in the configured MeterValues. + */ + +import type { ConnectorStatus } from '../../types/index.js' +import type { CoherentSession, ICoherentContext } from './types.js' + +import { CurrentType } from '../../types/index.js' +import { Constants, roundTo } from '../../utils/index.js' +import { interpolateChargingCurve } from './EvProfiles.js' +import { createStreamPrng } from './Prng.js' + +/** + * Runtime-only per-session state. Kept in a module-scope WeakMap keyed by + * the {@link CoherentSession} object (rather than by transactionId) so + * runtime state is scoped to the session's identity — no cross-station + * coupling when two stations happen to share a transactionId — and is + * auto-collected when the session becomes unreachable. + */ +interface SessionRuntime { + voltagePrng?: () => number +} + +const sessionRuntimes = new WeakMap() + +/** + * Retrieves the runtime bag for a session, creating it on first access. + * Consumed by {@link computeCoherentSample} to cache the voltage-noise + * PRNG across samples so the stream advances rather than restarting + * from the same seed each draw. + * @param session - Coherent session. + * @returns Live runtime bag (mutated in place). + */ +const getSessionRuntime = (session: CoherentSession): SessionRuntime => { + let runtime = sessionRuntimes.get(session) + if (runtime == null) { + runtime = {} + sessionRuntimes.set(session, runtime) + } + return runtime +} + +/** + * Disposes runtime state for a session. Call from every session-teardown + * path (stop/reset/disconnect) to release cached PRNG state eagerly. + * The WeakMap makes eager disposal optional — unreachable sessions are + * collected automatically — but eager disposal preserves determinism + * across sequential transactions that reuse the same session identity. + * Idempotent. + * @param session - Coherent session (or `undefined` when the caller has + * no session at hand). + * @returns `true` when runtime was removed, `false` otherwise. + */ +export const disposeCoherentSessionRuntime = (session: CoherentSession | undefined): boolean => { + if (session == null) { + return false + } + return sessionRuntimes.delete(session) +} + +/** + * Decimal places for all physics-quantity rounding (V, A, W, Wh, SoC). + * The `roundTo` half-width bound is `0.5 × 10^-ROUNDING_SCALE = 0.005` on + * each rounded quantity; INV-1 residual is bounded by this scalar. + */ +export const ROUNDING_SCALE = 2 + +/** + * Coherent physics sample. All fields follow the `` naming + * convention (`currentA`, `powerW`, `voltageV`, ...). + */ +export interface CoherentSample { + currentA: number + deltaEnergyWh: number + energyRegisterWh: number + powerW: number + socPercent: number + voltageV: number +} + +/** + * Options for {@link computeCoherentSample}. + */ +export interface ComputeSampleOptions { + /** + * Sample interval in milliseconds. Drives energy accrual and the + * remaining-capacity clamp. Non-positive/non-finite triggers the + * zero-sample defensive branch. + */ + intervalMs: number + /** + * Sample timestamp in milliseconds; callers pass `Date.now()` in + * production and a test-controlled clock in unit tests. Combined with + * `session.sessionStartMs` for ramp-up progress. Non-finite triggers + * the zero-sample defensive branch. + */ + nowMs: number + /** + * Root 32-bit seed for stream splitting; combined with + * `session.transactionId` and per-measurand labels to derive + * independent PRNG streams via FNV-1a stream splitting. + */ + rootSeed: number + /** + * Enable or disable per-sample voltage noise. When `false`, `voltageV` + * is exactly the nominal voltage with no PRNG-derived fluctuation. + * Intended for deterministic unit tests. Defaults to `true` when + * omitted (the `options.voltageNoise !== false` guard). + */ + voltageNoise?: boolean +} + +const buildZeroSample = ( + socPercent: number, + voltageV: number, + energyRegisterWh: number +): CoherentSample => ({ + currentA: 0, + deltaEnergyWh: 0, + energyRegisterWh, + powerW: 0, + socPercent: roundTo(socPercent, ROUNDING_SCALE), + voltageV: voltageV > 0 && Number.isFinite(voltageV) ? roundTo(voltageV, ROUNDING_SCALE) : 0, +}) + +/** + * Symmetric fluctuation helper: draws a uniform sample and maps it into + * `[base * (1 - percent), base * (1 + percent))`. + * @param base - Nominal value. + * @param percent - Half-width of the symmetric interval (e.g. 0.01 = ±1 %). + * @param prng - Seeded PRNG stream. + * @returns Fluctuated value. + */ +const fluctuate = (base: number, percent: number, prng: () => number): number => { + return base * (1 + (prng() * 2 - 1) * percent) +} + +/** + * Unconditionally advances the connector energy registers by `deltaEnergyWh`. + * The coherent path owns register updates so `meterStop` stays correct even + * when the `Energy.Active.Import.Register` measurand is not configured. + * Negative or nullish register starting values are clamped to zero before + * accrual. + * @param connectorStatus - Target connector status (in-place update). + * @param deltaEnergyWh - Energy delta (Wh). + */ +export const advanceEnergyRegister = ( + connectorStatus: ConnectorStatus | undefined, + deltaEnergyWh: number +): void => { + if (connectorStatus == null) { + return + } + connectorStatus.energyActiveImportRegisterValue = + Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0) + deltaEnergyWh + connectorStatus.transactionEnergyActiveImportRegisterValue = + Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + deltaEnergyWh +} + +/** + * Computes a single coherent sample and mutates the caller-owned + * `session.socPercent`. The energy register is NOT advanced here; the + * caller (`buildCoherentMeterValue`) invokes {@link advanceEnergyRegister} + * once per emitted sample so the semantics match the OCPP energy meter + * model. + * + * Physics chain (INV-1/INV-2/INV-3 hold by construction): + * 1. `rampFactor = min(1, elapsed / rampUp)` — immutable session start. + * 2. `V` — nominal ± small seed-derived noise. + * 3. `evAcceptanceW = curve(SoC) × profile.maxPowerW`. + * 4. `powerW = rampFactor × min(EVSE_max, evAcceptance) × socCap`. + * EVSE cap already folds in charging profiles via + * {@link ICoherentContext.getConnectorMaximumAvailablePower}. + * 5. `powerW` is then clamped to remaining battery capacity so a sample + * crossing 100 % SoC never over-charges the register. + * 6. `currentAExact = powerW / (V_round · phases)` — exact fraction + * (phases=1 for DC). Emitted current is rounded to `ROUNDING_SCALE`. + * 7. Emitted `powerW = round(V_round × currentA_round × phases)`; + * INV-1 holds within `ROUNDING_SCALE` half-width (≤ 0.005 W). + * 8. `ΔE = P_clamped × Δt / MS_PER_HOUR` — uses the pre-emit-rounding + * `powerW` so the register integrates the capacity-clamped power + * exactly (INV-3). + * 9. `ΔSoC = ΔE / capacity × 100`; `socPercent = min(100, soc + ΔSoC)`. + * @param context - Charging-station context. + * @param connectorStatus - Connector status. + * @param session - Active coherent session (resolved by caller). + * @param options - Per-sample parameters (interval, seed material, ...). + * @returns The computed sample. `energyRegisterWh` reflects the projected + * register value AFTER `advanceEnergyRegister` is applied by the caller. + */ +export const computeCoherentSample = ( + context: ICoherentContext, + connectorStatus: ConnectorStatus, + session: CoherentSession, + options: ComputeSampleOptions +): CoherentSample => { + const transactionId = session.transactionId + + // Defensive guard bundle covering NaN/incoherence sources: + // - intervalMs ≤ 0 or non-finite: divide-by-zero, negative Δt, or NaN/Infinity + // propagates through `maxPowerFromCapacityW = remainingWh · MS_PER_HOUR / + // intervalMs` and permanently poisons session.socPercent. `Number.isFinite` + // covers the NaN/±Infinity paths since `NaN <= 0 === false`. + // - batteryCapacityWh ≤ 0 or non-finite: Zod (`EvProfileSchema`) enforces + // `.positive()` at file load, but `injectCoherentSession` bypasses Zod; + // `deltaSocPercent = ΔE / batteryCapacityWh × 100 = NaN` would poison SoC. + // - nominal voltage ≤ 0 or non-finite: `Voltage` enum values are all-positive, + // but a template override may set `voltageOut` to 0. + // - nowMs non-finite: pushes elapsedMs to NaN and destabilizes rampFactor. + // - AC with numberOfPhases ≤ 0: divisor collapses to 0 (`V · 0 = 0`), + // currentA is guarded to zero, and P = 0 silently — a misconfigured + // multi-phase station would emit zeros for the whole session. Fail + // loudly with a zero sample so operators notice the misconfiguration. + const batteryCapacityWh = session.profile.batteryCapacityWh + const nominalV: number = session.voltageOutNominal + const currentType = session.currentType + const numberOfPhases = session.numberOfPhases + if ( + options.intervalMs <= 0 || + !Number.isFinite(options.intervalMs) || + batteryCapacityWh <= 0 || + !Number.isFinite(batteryCapacityWh) || + nominalV <= 0 || + !Number.isFinite(nominalV) || + !Number.isFinite(options.nowMs) || + (currentType === CurrentType.AC && numberOfPhases <= 0) + ) { + const safeV = nominalV > 0 && Number.isFinite(nominalV) ? nominalV : 0 + return buildZeroSample( + session.socPercent, + safeV, + Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + ) + } + + const elapsedMs = Math.max(0, options.nowMs - session.sessionStartMs) + // Non-positive or non-finite rampUpDurationMs would either divide by zero + // (NaN) or produce rampFactor > 1 / negative. Treat any invalid value as + // "no ramp" (immediate full-power), matching the existing semantic where + // rampUpDurationMs = 0 means immediate full-power. + // Sub-millisecond values (e.g. Number.EPSILON) pass the guard but yield + // elapsedMs / rampUpDurationMs >> 1 for any real elapsed time, so + // Math.min(1, …) = 1 — semantically equivalent to rampUpDurationMs = 0. + const rampFactor = + session.rampUpDurationMs > 0 && Number.isFinite(session.rampUpDurationMs) + ? Math.min(1, elapsedMs / session.rampUpDurationMs) + : 1 + + // Voltage: nominal ± small seed-derived noise. The voltage PRNG lives on + // module-scope runtime state (not on the serializable session) so its + // stream advances across samples; constructing a new PRNG per sample + // would restart from the same seed each draw and produce a stalled + // (non-advancing) sequence. + let sampledV = nominalV + if (options.voltageNoise !== false) { + const runtime = getSessionRuntime(session) + runtime.voltagePrng ??= createStreamPrng(options.rootSeed, transactionId, 'VOLTAGE_NOISE') + sampledV = fluctuate( + nominalV, + Constants.DEFAULT_COHERENT_VOLTAGE_NOISE_PERCENT, + runtime.voltagePrng + ) + } + const roundedV = roundTo(sampledV, ROUNDING_SCALE) + + // EV acceptance from the profile's charging curve at the SoC of THIS + // sample (session.socPercent is advanced at the end of the previous + // computeCoherentSample tick — see the `session.socPercent = ...` + // assignment below), not the session's initial SoC — the taper must + // track live state so P falls off as the battery fills. + const acceptanceFraction = interpolateChargingCurve( + session.profile.chargingCurve, + session.socPercent + ) + const evAcceptanceW = acceptanceFraction * session.profile.maxPowerW + + // EVSE cap (already includes hardware/charging-profile clamps via ChargingStation). + const evseLimitW = context.getConnectorMaximumAvailablePower(session.connectorId) + + const socCap = session.socPercent >= 100 ? 0 : 1 + const targetPowerW = rampFactor * Math.min(evseLimitW, evAcceptanceW) * socCap + let powerW = Math.max(0, targetPowerW) + + // Clamp powerW to whatever the remaining battery capacity accepts over + // this interval so a sample that crosses 100 % SoC cannot over-charge the + // register. INV-3 is preserved because ΔE is computed from the clamped + // power below. + const remainingWh = Math.max( + 0, + ((100 - session.socPercent) / 100) * session.profile.batteryCapacityWh + ) + const maxPowerFromCapacityW = (remainingWh * Constants.MS_PER_HOUR) / options.intervalMs + powerW = Math.min(powerW, maxPowerFromCapacityW) + + // Physics: derive per-phase current as an exact fraction so + // V_round · currentAExact · phases = powerW + // holds identically. `numberOfPhases` is 1 for DC (line above) so a + // single branch covers both currents. Using integer-rounded amps here + // would inflate V·I·phases above the capacity-clamped powerW by up to + // V·phases·0.5 W, breaking INV-1. + const divisor = roundedV * numberOfPhases + const currentAExact = divisor > 0 ? powerW / divisor : 0 + + // Emission: round current to `ROUNDING_SCALE`, then derive emitted power + // from the rounded current so INV-1 (P = V·I·phases) holds within + // `ROUNDING_SCALE` half-width (≤ 0.005 W) regardless of V or phases. + const roundedCurrent = roundTo(currentAExact, ROUNDING_SCALE) + const roundedPower = roundTo(roundedV * roundedCurrent * numberOfPhases, ROUNDING_SCALE) + + // Energy accounting uses the clamped (pre-rounding) `powerW` so INV-3 + // holds within floating-point ε and the capacity budget is respected + // exactly. `Math.max(0, ...)` on `preRegisterWh` mirrors the clamp + // applied by `advanceEnergyRegister` so the reported `energyRegisterWh` + // and the post-advance persisted state agree even if the persisted + // register is corrupted to a negative value. + const deltaEnergyWh = (powerW * options.intervalMs) / Constants.MS_PER_HOUR + const preRegisterWh = Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + const projectedRegisterWh = preRegisterWh + deltaEnergyWh + + // INV-2: SoC(t+1) ≥ SoC(t); ΔSoC = ΔE / batteryCapacityWh × 100. Saturates at 100 %. + const deltaSocPercent = (deltaEnergyWh / session.profile.batteryCapacityWh) * 100 + session.socPercent = Math.min(100, session.socPercent + deltaSocPercent) + + return { + currentA: roundedCurrent, + deltaEnergyWh, + energyRegisterWh: projectedRegisterWh, + powerW: roundedPower, + socPercent: roundTo(session.socPercent, ROUNDING_SCALE), + voltageV: roundedV, + } +} diff --git a/src/charging-station/meter-values/CoherentSession.ts b/src/charging-station/meter-values/CoherentSession.ts new file mode 100644 index 00000000..6e88f0f2 --- /dev/null +++ b/src/charging-station/meter-values/CoherentSession.ts @@ -0,0 +1,134 @@ +// Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Coherent MeterValues session lifecycle + strategy-gate helpers. + * @description Session construction ({@link createCoherentSession}), the + * type-guard predicate ({@link isCoherentModeActive}) used by the OCPP + * strategy gate, and the root-seed resolver ({@link resolveRootSeed}). + * + * Physics computation and the module-scope runtime WeakMap + * (`disposeCoherentSessionRuntime`) live in + * {@link ./CoherentSampleComputer}; MeterValue emission lives in + * {@link ./CoherentMeterValueBuilder}; PRNG primitives + * ({@link ./Prng.createStreamPrng}, `deriveSeed`, `hashLabel`, + * `mulberry32`) live in {@link ./Prng}. This module owns only + * session identity and the strategy predicate — it has no cross-module + * private dependencies. + */ + +import type { CoherentSession, EvProfile, ICoherentContext } from './types.js' + +import { CurrentType, Voltage } from '../../types/index.js' +import { Constants, logger } from '../../utils/index.js' +import { selectEvProfile } from './EvProfiles.js' +import { createStreamPrng, hashLabel } from './Prng.js' + +const moduleName = 'CoherentSession' + +/** + * 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 on the production-backed + * injection path (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 = ( + session: CoherentSession | undefined +): session is CoherentSession => session != null + +/** + * Options for {@link createCoherentSession}. + */ +export interface CreateSessionOptions { + /** Target connector id. */ + connectorId: number + /** Session start timestamp in milliseconds. Defaults to `Date.now()`. */ + now?: number + /** Non-empty EV profile pool; one profile is picked via seeded weighted random selection. */ + profiles: EvProfile[] + /** + * Optional ramp-up duration in milliseconds. Defaults to + * `Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS`. + */ + rampUpDurationMs?: number + /** Root 32-bit seed for stream splitting. */ + rootSeed: number + /** Transaction identifier. */ + transactionId: number | string +} + +/** + * Builds a {@link CoherentSession} deterministically from the profile pool + * and per-transaction seed material. Weight-based profile selection uses a + * dedicated `'PROFILE_PICK'` stream and initial SoC uses `'INITIAL_SOC'`, + * so adding one consumer does not shift any other stream's sequence + * (stream-splitting via FNV-1a label hashing — see `deriveSeed` in `Prng.ts`). + * + * The nominal AC voltage is treated as phase voltage (line-to-neutral) per + * {@link ../../utils/ElectricUtils.ACElectricUtils}. If the station is AC + * and `voltageOut` is 400 V or 800 V, a warning is logged since those + * values are line-to-line in most catalogs and the utilities would compute + * physically implausible power. + * @param context - Charging-station context. + * @param options - Session parameters. + * @returns Fully initialized session, or `undefined` when profiles is empty. + */ +export const createCoherentSession = ( + context: ICoherentContext, + options: CreateSessionOptions +): CoherentSession | undefined => { + if (options.profiles.length === 0) { + return undefined + } + const now = options.now ?? Date.now() + const profilePickPrng = createStreamPrng(options.rootSeed, options.transactionId, 'PROFILE_PICK') + const socPrng = createStreamPrng(options.rootSeed, options.transactionId, 'INITIAL_SOC') + const profile = selectEvProfile(options.profiles, profilePickPrng()) + const socRange = Math.max(0, profile.initialSocPercentMax - profile.initialSocPercentMin) + const initialSoc = profile.initialSocPercentMin + socPrng() * socRange + + const currentType = context.stationInfo?.currentOutType ?? CurrentType.AC + const voltageOutNominal = context.getVoltageOut() + if ( + currentType === CurrentType.AC && + (voltageOutNominal === Voltage.VOLTAGE_400 || voltageOutNominal === Voltage.VOLTAGE_800) + ) { + logger.warn( + `${context.logPrefix()} ${moduleName}.createCoherentSession: AC voltageOut=${voltageOutNominal.toString()}V is treated as line-to-neutral (phase voltage) by ACElectricUtils. If this value is meant as line-to-line, coherent power/current will be physically implausible.` + ) + } + + return { + connectorId: options.connectorId, + currentType, + numberOfPhases: currentType === CurrentType.AC ? context.getNumberOfPhases() : 1, + profile, + rampUpDurationMs: options.rampUpDurationMs ?? Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS, + sessionStartMs: now, + socPercent: initialSoc, + transactionId: options.transactionId, + voltageOutNominal, + } +} + +/** + * Resolves the root PRNG seed for a station. Prefers the template + * `randomSeed` and falls back to a FNV-1a hash of `hashId`, ensuring + * determinism across stations without accidentally sharing streams. + * @param stationInfo - Station info (`randomSeed` and `hashId`). + * @returns 32-bit unsigned root seed. + */ +export const resolveRootSeed = ( + stationInfo: undefined | { hashId?: string; randomSeed?: number } +): number => { + if (stationInfo?.randomSeed != null && Number.isFinite(stationInfo.randomSeed)) { + return stationInfo.randomSeed >>> 0 + } + return hashLabel(stationInfo?.hashId ?? '') +} diff --git a/src/charging-station/meter-values/Prng.ts b/src/charging-station/meter-values/Prng.ts index f18bc0be..e46cb4cd 100644 --- a/src/charging-station/meter-values/Prng.ts +++ b/src/charging-station/meter-values/Prng.ts @@ -8,8 +8,8 @@ * * Stream splitting: `deriveSeed(rootSeed, label)` XORs a stable FNV-1a * 32-bit hash of the label into the root seed so adding one consumer - * (e.g. a new `POWER_NOISE` stream) does not shift any other stream's - * sequence. + * (e.g. a new `VOLTAGE_NOISE`, `PROFILE_PICK`, or `INITIAL_SOC` stream) + * does not shift any other stream's sequence. */ /** @@ -54,9 +54,8 @@ export const hashLabel = (label: string): number => { * so two chains collide when `H(x1) ^ H(y1) === H(x2) ^ H(y2)`. Birthday * bound on the 32-bit hash space is negligible at simulator scale * (expected collisions ≈ N²/2^33; ≈ 0.3 at N = 5×10⁴). The deterministic - * self-inverse `H(x) ^ H(x) === 0` is neutralized by - * {@link ./CoherentMeterValuesGenerator.createStreamPrng} namespacing the - * transactionId leg with a `tx:` prefix labels never carry. + * self-inverse `H(x) ^ H(x) === 0` is neutralized by {@link createStreamPrng} + * namespacing the transactionId leg with a `tx:` prefix labels never carry. * @param rootSeed - Root 32-bit seed. * @param label - Stable stream label. * @returns Derived 32-bit unsigned seed. @@ -64,3 +63,26 @@ export const hashLabel = (label: string): number => { export const deriveSeed = (rootSeed: number, label: string): number => { return ((rootSeed >>> 0) ^ hashLabel(label)) >>> 0 } + +/** + * Deterministic per-transaction stream splitter. Combines the station + * `randomSeed` (or a stable fallback), the transactionId, and a label so + * that adding a new consumer never shifts an existing stream's sequence. + * @param rootSeed - Root 32-bit seed for the station. + * @param transactionId - Transaction identifier. + * @param label - Stream label (`'VOLTAGE_NOISE'`, `'PROFILE_PICK'`, + * `'INITIAL_SOC'`, ...). + * @returns PRNG function producing [0, 1) floats. + */ +export const createStreamPrng = ( + rootSeed: number, + transactionId: number | string, + label: string +): (() => number) => { + // Namespace the transactionId leg with a `tx:` prefix so + // `String(transactionId) === label` cannot trigger the XOR self-inverse + // `deriveSeed(deriveSeed(r, X), X) === r`. Labels never start with `tx:` + // by construction (`VOLTAGE_NOISE`, `PROFILE_PICK`, `INITIAL_SOC`, ...). + const txSeed = deriveSeed(rootSeed, `tx:${String(transactionId)}`) + return mulberry32(deriveSeed(txSeed, label)) +} diff --git a/src/charging-station/meter-values/index.ts b/src/charging-station/meter-values/index.ts index 3397bb84..310889a7 100644 --- a/src/charging-station/meter-values/index.ts +++ b/src/charging-station/meter-values/index.ts @@ -9,15 +9,24 @@ * * Internal helpers are intentionally not re-exported; tests and internal * callers should import them directly from the owning sub-module. + * + * Module layout: + * - {@link ./CoherentSession} — session lifecycle + * (`createCoherentSession`, `CreateSessionOptions`), the strategy-gate + * type guard `isCoherentModeActive`, and the root-seed resolver + * `resolveRootSeed`. + * - {@link ./CoherentSampleComputer} — physics chain V→P→I→ΔE→SoC + * (INV-1/2/3 by construction), energy-register advance, and the + * module-scope runtime WeakMap (`disposeCoherentSessionRuntime`). + * - {@link ./CoherentMeterValueBuilder} — emit order, phase families, + * unit conversion, OCPP MeterValue assembly. + * - {@link ./Prng} — PRNG primitives (`mulberry32`, `hashLabel`, + * `deriveSeed`, `createStreamPrng`). */ -export { - buildCoherentMeterValue, - createCoherentSession, - isCoherentModeActive, - resolveRootSeed, -} from './CoherentMeterValuesGenerator.js' -export type { BuildVersionedSampledValue } from './CoherentMeterValuesGenerator.js' +export { buildCoherentMeterValue } from './CoherentMeterValueBuilder.js' +export type { BuildVersionedSampledValue } from './CoherentMeterValueBuilder.js' +export { createCoherentSession, isCoherentModeActive, resolveRootSeed } from './CoherentSession.js' export { loadEvProfilesFile } from './EvProfiles.js' export type { ChargingCurvePoint, diff --git a/tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts b/tests/charging-station/meter-values/CoherentMeterValues.test.ts similarity index 98% rename from tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts rename to tests/charging-station/meter-values/CoherentMeterValues.test.ts index 3b63e215..25cc62e4 100644 --- a/tests/charging-station/meter-values/CoherentMeterValuesGenerator.test.ts +++ b/tests/charging-station/meter-values/CoherentMeterValues.test.ts @@ -1,14 +1,17 @@ /** - * @file Tests for CoherentMeterValuesGenerator physics. + * @file Tests for coherent MeterValues generation. * @description Verifies invariants (P=V·I·phases, ΔE=P·Δt/3.6e6, SoC monotone, * saturation at 100 %), Wh/kWh unit conversion, energy-register ownership, * and same-seed determinism across AC 1-phase, AC 3-phase, and DC modes. + * Cross-module tests spanning `CoherentSampleComputer` (physics), + * `CoherentMeterValueBuilder` (emission), `CoherentSession` (lifecycle), + * and `Prng` (stream splitting). */ import assert from 'node:assert/strict' import { afterEach, describe, it } from 'node:test' -import type { BuildVersionedSampledValue } from '../../../src/charging-station/meter-values/CoherentMeterValuesGenerator.js' +import type { BuildVersionedSampledValue } from '../../../src/charging-station/meter-values/CoherentMeterValueBuilder.js' import type { CoherentSession, EvProfile, @@ -21,13 +24,15 @@ import type { SampledValueTemplate, } from '../../../src/types/index.js' +import { buildCoherentMeterValue } from '../../../src/charging-station/meter-values/CoherentMeterValueBuilder.js' import { - buildCoherentMeterValue, computeCoherentSample, - createCoherentSession, disposeCoherentSessionRuntime, +} from '../../../src/charging-station/meter-values/CoherentSampleComputer.js' +import { + createCoherentSession, resolveRootSeed, -} from '../../../src/charging-station/meter-values/CoherentMeterValuesGenerator.js' +} from '../../../src/charging-station/meter-values/CoherentSession.js' import { hashLabel } from '../../../src/charging-station/meter-values/Prng.js' import { AvailabilityType, @@ -162,7 +167,7 @@ const createSessionOrFail = ( return session } -await describe('CoherentMeterValuesGenerator', async () => { +await describe('CoherentMeterValues', async () => { afterEach(() => { standardCleanup() }) @@ -789,7 +794,7 @@ await describe('CoherentMeterValuesGenerator', async () => { }) assert.ok( !Object.prototype.hasOwnProperty.call(session, 'voltagePrng'), - 'CoherentSession must not carry voltagePrng (moved to module-scope runtime state)' + 'CoherentSession must not carry voltagePrng (owned by module-scope runtime state in CoherentSampleComputer)' ) })