From: Jérôme Benoit Date: Sun, 5 Jul 2026 15:15:52 +0000 (+0200) Subject: refactor(charging-station,meter-values): close #1936 (M-08 + f-2..f-5 + M-09) (#1949) X-Git-Tag: cli@v4.11.0~63 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=9c8261ea43f531467b0f9e77a8b8bb884fdf2994;p=e-mobility-charging-stations-simulator.git refactor(charging-station,meter-values): close #1936 (M-08 + f-2..f-5 + M-09) (#1949) * refactor(tests): split StationHelpers.ts into modular files (issue #1936 M-08) Split the 962-LOC monolithic StationHelpers.ts into five focused modules: - StationHelpers.types.ts (149 LOC): 7 interfaces + MockChargingStation type - StationHelpers.cleanup.ts (160 LOC): cleanup + reset helpers - StationHelpers.connector.ts (65 LOC): connector-status + EVSE-usage helpers - StationHelpers.template.ts (20 LOC): mock template factory - StationHelpers.factory.ts (607 LOC): createMockChargingStation factory StationHelpers.ts is now a pure re-export barrel preserving the public API, so all 77 consumer test files keep importing from './StationHelpers.js' without change. Mechanical move only: no logic, signature, or behavior change. determineEvseUsage is now exported (previously module-private) because it is consumed by the factory across module boundaries. * feat(meter-values): add powerFactor + rampShape to EvProfile (issue #1936 M-09) Two optional EvProfile refinements. Both default to current behavior so existing profiles and golden tests are unchanged. - powerFactor (0, 1], absent => 1: cos phi factor. Per-phase current becomes I = P / (V . phases . powerFactor); INV-1 extends to P_active = V.I.phases.powerFactor. - rampShape 'linear'|'sigmoid', absent => 'linear': sigmoid uses a shifted-scaled logistic (k=10) pinned at f(0)=0 and f(1)=1 exactly after endpoint normalization. * refactor(charging-station): extract charging-profile helpers to HelpersChargingProfile (#1936 f-2) Split Helpers.ts (Phase 2 of 5). Moves 14 charging-profile symbols into sibling HelpersChargingProfile.ts and re-exports the public ones from Helpers.ts so the barrel import path is preserved. Zero behavior change: same signatures, same code, same log content. The moduleName local in the new file switches log prefixes from 'Helpers.' to 'HelpersChargingProfile.', reflecting the actual source location. Moved (private inside new file): - getChargingStationChargingProfiles, buildChargingProfilesLimit, ChargingProfilesLimit interface, getChargingProfileId, getChargingProfilesLimit, canProceedRecurringChargingProfile, prepareRecurringChargingProfile, checkRecurringChargingProfileDuration Moved (exported from new file): - getChargingStationChargingProfilesLimit, getConnectorChargingProfiles, getConnectorChargingProfilesLimit, prepareChargingProfileKind, canProceedChargingProfile (all re-exported from Helpers.ts) - getSingleChargingSchedule (exported for Phase 3 direct import; not re-exported from Helpers.ts to keep the public API unchanged) Follows the barrel pattern established by HelpersReservation.ts in Phase 1 (PR #1946). * refactor(charging-station): extract connector-status helpers to HelpersConnectorStatus (#1936 f-3) Split Helpers.ts (Phase 3 of 5). Moves 8 connector-status symbols into sibling HelpersConnectorStatus.ts and re-exports the public ones from Helpers.ts so the barrel import path is preserved. Zero behavior change: same signatures, same code, same log content. The moduleName local in the new file switches log prefixes from 'Helpers.' to 'HelpersConnectorStatus.', reflecting the actual source location. Moved (private inside new file): - initializeConnectorStatus Moved (exported from new file, re-exported from Helpers.ts): - buildConnectorsMap, initializeConnectorsMapStatus, resetConnectorStatus, resetAuthorizeConnectorStatus, prepareConnectorStatus, checkStationInfoConnectorStatus, getBootConnectorStatus Follows the barrel pattern established by HelpersReservation.ts in Phase 1 (PR #1946). * refactor(charging-station): extract serial-number/id helpers to HelpersId (#1936 f-4) Extracts 4 identity helpers (getChargingStationId, getHashId, createSerialNumber, propagateSerialNumber) plus the ChargingStationNameTemplate type alias into a dedicated HelpersId.ts. The getRandomSerialNumberSuffix helper had a single internal caller (createSerialNumber) and no external consumer; it is demoted to module-private in the new file, matching its actual usage. Helpers.ts continues to re-export the 4 public symbols and the type alias via the barrel, so all 5 consumer files work unchanged. Refs #1936 * refactor(charging-station): extract config/template helpers to HelpersConfig (#1936 f-5) Extracts 10 configuration/template helpers (buildTemplateName, validateStationInfo, getDefaultConnectorMaximumPower, checkConfiguration, setChargingStationOptions, stationTemplateToStationInfo, getAmperageLimitationUnitDivider, getDefaultVoltageOut, getIdTagsFile, getEvProfilesFile) plus the two template-topology helpers getMaxNumberOfEvses and getMaxNumberOfConnectors into HelpersConfig.ts. The two template-topology helpers were originally listed as core-station helpers in the issue body, but getDefaultConnectorMaximumPower consumes both of them; moving them into HelpersConfig avoids a cross-module dependency back into Helpers.ts that would risk an ESM live-binding cycle. Helpers.ts imports getMaxNumberOfConnectors from HelpersConfig for use in the retained getConfiguredMaxNumberOfConnectors, closing the split with no cycle. Helpers.ts is now 185 LOC (core station helpers + 5 barrel re-exports), down from 1389 LOC before the phased split. Refs #1936 * [autofix.ci] apply automated fixes * fix(charging-station,meter-values): apply Round 1 review fixes to #1949 (issue #1936) 1. HelpersConnectorStatus.ts imported getMaxNumberOfConnectors via the Helpers.js barrel, which re-exports it from HelpersConfig.js while also re-exporting HelpersConnectorStatus.js. That is the exact ESM barrel cycle the config/template extraction was designed to avoid. Import getMaxNumberOfConnectors directly from HelpersConfig.js to keep the dependency direction one-way. 2. EvProfile powerFactor Zod schema tightened from (0, 1] to [0.5, 1]. The previous positive() lower bound accepted tiny values that made the divisor V*phases*powerFactor collapse toward zero and produce non-physical current. Real onboard chargers sit at 0.98..1.0; the 0.5 floor blocks configuration errors while keeping physically defensible room. Reflected in JSDoc, README, and the schema. 3. powerFactor is now AC-only. DC has no reactive component (P=V*I), so applying cos phi on DC profiles produces non-physical current. Gate on session.currentType === CurrentType.AC when computing the divisor; DC pins powerFactor to 1 regardless of the profile field. Test locks the DC-gate contract with an explicit regression case. 4. sigmoidRamp JSDoc reworded. Endpoint bit-exactness is pinned by the short-circuit paths at progress <= 0 and >= 1; the shift-scale normalization aligns interior open-interval values. The prior wording overstated what the normalization does on its own. 5. StationHelpers.ts barrel comment reworded to describe the public surface directly instead of framing it as backward compatibility. Refs #1936 * fix(charging-station,meter-values): apply Round 2 review fixes to #1949 (issue #1936) 1. INV-1 DC docstring stale: CoherentSampleComputer.ts @file block said DC: P = V*I*powerFactor but the implementation and README both pin powerFactor to 1 on DC (no reactive component in DC). Corrected to DC: P = V*I and cross-referenced the AC-only gate. 2. resolvePhasedValue non-emission of OCPP measurands documented: Power.Factor and Power.Reactive.Import are defined in OCPP 1.6 and 2.0.1 measurand enums but the coherent path does not emit them. EvProfile.powerFactor scales the AC current/power chain internally but is not surfaced as Power.Factor. Templates configuring these measurands under coherent mode are skipped with a warning. The JSDoc now lists the supported measurand set and calls out the unsupported ones explicitly. 3. propagateSerialNumber (public export via Helpers barrel) gained a JSDoc block with @throws {BaseError}, matching the coverage pattern established for HelpersConfig.ts public exports. 4. buildChargingProfilesLimit (module-private but non-trivial) gained a JSDoc block with @throws {BaseError} for parallelism. 5. Test describe block renamed from 'rampShape sigmoid' to 'rampShape sigmoid' with the literal quoted, matching the string- literal-type convention used everywhere else for rampShape values. Refs #1936 * docs(charging-station): complete R2 findings coverage on helper split (#1936) Applies design decisions for all remaining R2 findings on PR #1949: 1. JSDoc completeness (Lane A M-R2.2 + Lane B M-R2.6): every public export re-exported through the Helpers.ts barrel now carries a symbol-level JSDoc block matching the HelpersConfig.ts style established during Phase 5 extraction. Coverage: 3 exports in HelpersId.ts (getChargingStationId, getHashId, createSerialNumber), 5 exports in HelpersChargingProfile.ts (getSingleChargingSchedule, getChargingStationChargingProfilesLimit, getConnectorChargingProfilesLimit, prepareChargingProfileKind, canProceedChargingProfile), and 7 exports in HelpersConnectorStatus.ts (getBootConnectorStatus, checkStationInfoConnectorStatus, buildConnectorsMap, initializeConnectorsMapStatus, resetAuthorizeConnectorStatus, resetConnectorStatus, prepareConnectorStatus). ChargingStationNameTemplate type also documented. 2. Barrel style divergence (Lane A N-R2.1): the two barrels use different re-export shapes on purpose. Helpers.ts uses explicit name lists to control and document the external API surface; StationHelpers.ts uses 'export *' because its consumers are internal to the test tree. A one-line justification comment now lives in each barrel so the divergence is intentional-by-record. 3. HelpersId moduleName absence (Lane A N-R2.3): every helper in this file is pure (no logger.* calls), so declaring an unused moduleName constant would violate the no-dead-code convention. Documented as an explicit design decision in the @file block. Refs #1936 * docs(charging-station): rewrite R2b JSDoc blocks that misdescribed the code (#1936) R3 review caught 5 R2b JSDoc blocks whose narrative did not match the actual code path. Every block is rewritten to reflect current behavior: 1. getSingleChargingSchedule: the code returns undefined for ANY array shape (OCPP 2.0.x), not just for zero-or-multi-entry arrays. Doc now states arrays are logged (debug) and skipped, only the OCPP 1.6 single-schedule shape is consumed. The pre-existing OCPP 2.0.x array-shape handling gap is out of scope for this PR. 2. getChargingStationChargingProfilesLimit: the code filters CHARGE_POINT_MAX_PROFILE (OCPP 1.6 value). Doc now uses that name explicitly and calls out that the OCPP 2.0.1 equivalent ChargingStationMaxProfile is not handled by this filter. Also out-of-scope pre-existing behavior. 3. getBootConnectorStatus: doc claimed a fallback to Available, but the code returns Unavailable when the station or connector is unavailable. Doc now enumerates the four branches explicitly (Unavailable / persisted status / bootStatus / Available). 4. initializeConnectorsMapStatus: doc had two independent errors. Connector 0 is mutated (availability=Operative, chargingProfiles default to empty), not left untouched. And initializeConnectorStatus runs on the transactionStarted-unset branch, not on the pending flag branch. Doc now describes all four actual branches. 5. resetConnectorStatus: doc said energy counters (plural); only the transaction-scoped register is reset. Doc said connector identity (id, availability) is preserved; ConnectorStatus has no id field, only the outer Map key. Doc now says transaction-scoped energy counter and preserves availability only. Also replaced {@link initializeConnectorStatus} references with prose since the target is module-private and would emit unresolved-reference warnings from TypeDoc. Refs #1936 * fix(charging-station): close R3 findings — OCPP 2.0.x profile support + Readonly type (#1936) Applies the 3 remaining R3 findings as proper code fixes (not docs-only): 1. getSingleChargingSchedule unwraps length-1 OCPP 2.0.x arrays. Previously any array shape returned undefined, so all 8 callers silently dropped OCPP 2.0.x profiles at limit-resolution and preparation time. The fix unwraps chargingSchedule[0] when the array has exactly one entry (the OCPP 2.0.x single-schedule shape), and keeps the debug log + skip path for zero or multi-entry arrays (2-3 concurrent schedules are valid per spec but the coherent path does not pick between them). JSDoc rewritten to describe the post-fix contract. 4 regression tests added: OCPP 1.6 shape, length-1 unwrap, length-2 skip, empty-array skip. 2. getChargingStationChargingProfilesLimit filter now accepts both ChargingProfilePurposeType.CHARGE_POINT_MAX_PROFILE (OCPP 1.6 value 'ChargePointMaxProfile') and ChargingProfilePurposeType.ChargingStationMaxProfile (OCPP 2.0.1 value 'ChargingStationMaxProfile'). Previously only the OCPP 1.6 value matched, so OCPP 2.0.1 station-scope profiles were excluded from station-level limit resolution. JSDoc updated. 3. ChargingStationNameTemplate is now Readonly>. The type was already used read-only by getChargingStationId; the Readonly wrapper makes the pure-read contract explicit at the type level. Note: findings 1 and 2 are pre-existing behavior gaps exposed by the R2b/R3 JSDoc accuracy audit, not regressions introduced by the refactor. Fixing them here rather than deferring to a follow-up issue matches the session precedent (PR-J F06.FR.06, PR-K RegisterValuesWithoutPhases, PR-H physics refinements). Refs #1936 * fix(charging-station): close R4 findings, add station-scope filter regression tests (#1936) R4 review surfaced two convergent findings on the R3b station-scope profile filter fix: 1. N-R4.1: the R3b filter accepted CHARGE_POINT_MAX_PROFILE (OCPP 1.6) and ChargingStationMaxProfile (OCPP 2.0.1), but OCPP 2.0.1 has a second station-scope purpose ChargingStationExternalConstraints (OCA J01 use-case: external LMS/EMS-imposed caps). The local validator at OCPP20IncomingRequestService.ts:4157-4164 already treats it identically to ChargingStationMaxProfile (must apply to EVSE 0). Filter now accepts all three station-scope purposes. JSDoc updated to describe the OCA J01 semantic distinction and the coherent path's decision to treat both OCPP 2.0.1 purposes as equivalent inputs to stack-level tie-break. 2. N-R4.3: R3b Fix #2 had no regression test locking the filter acceptance. 4 targeted tests added covering the 3 station-scope purposes (CHARGE_POINT_MAX_PROFILE, ChargingStationMaxProfile, ChargingStationExternalConstraints) plus a negative TX_PROFILE rejection. Tests seed connector 0's chargingProfiles directly via MockChargingStation and assert getChargingStationChargingProfilesLimit returns the expected watt limit or undefined. Refs #1936 * docs(charging-station): apply Round 5 review fixes to #1949 (issue #1936) R5 review surfaced one MAJOR spec-citation error and three test-quality MINORs. Only actionable items are fixed here; prior R4b commit body is immutable per session convention (squash-safe branch history). 1. M1 spec citation: OCA J01 in R4b JSDoc is factually wrong. J01 is the Metering functional block (Sending Meter Values not related to a transaction). ChargingStationExternalConstraints belongs to the K SmartCharging block: K04 for the internal Load Balancing use-case (matches ChargingStationMaxProfile semantic) and K11-K14 for the External Charging Limit family that the station reports to the CSMS (matches ChargingStationExternalConstraints semantic). JSDoc rewrites the citation to K04 vs K11-K14. 2. n2 test coverage: getSingleChargingSchedule regression tests missed the OCPP 2.0.1 spec upper bound of a length-3 chargingSchedule array (spec cardinality is 1..3). Added one test verifying the length-3 case returns undefined (matching the current always-skip-non-length-1 behavior). 3. n3 test coverage: the R4b station-scope filter tests all seeded a single profile at stackLevel 0, missing (a) tie-break correctness between two station-scope profiles at different stack levels and (b) filter + purpose interaction when a station-scope and a TX_PROFILE are both seeded on connector 0. Added two tests covering both cases. 4. n1 test setup rationale: the seed helper reassigns station.stationInfo after mock construction. This only reaches code paths that read chargingStation.stationInfo directly (the maximumPower sanity check); factory methods like getNumberOfPhases and getVoltageOut capture the constructor-time options in a closure and do not observe the mutation. Tests use ChargingRateUnitType.WATT to bypass AC/DC conversion, so currentOutType is not exercised. Added an inline comment locking the design decision so future contributors know why the mutation is inert for phase/voltage getters. Renamed the seed helper from seedConnectorZeroWithProfile (singular) to seedConnectorZeroWithProfiles (plural) to fit the multi-profile cases. Refs #1936 * docs(charging-station): refine R5b OCA citation with K08.FR.04 merge behavior (#1936) R6 review surfaced one MINOR precision gap in the R5b OCA citation and one false-positive on issue linkage (rejected — issue #1936 IS the correct closing target per session-verified reconciliation of 11 coordinated PRs). R5b already fixed OCA J01 (Metering) to OCA K04 (Internal LMS) and OCA K11-K14 (External Charging Limit). R6 Lane A + Lane B convergent on a semantic distinction: K11-K14 describes the profile's ORIGIN (external system sets the limit, station stores it internally), not the coherent path's BEHAVIOR (composite-schedule merge input). The authoritative citation for the merge-input behavior is OCA K08.FR.04 (Get Composite Schedule) and safety invariant SC.01. R5b also called ChargingStationExternalConstraints an LMS/EMS-imposed cap the station reports to the CSMS — accurate for the reporting flow via NotifyChargingLimit / ReportChargingProfiles, but the code path uses the profile as merge input rather than emit-source. JSDoc now cites both K11-K14 (origin: external system sets limit, station stores internally, reports upstream to CSMS) and K08.FR.04 / SC.01 (behavior: composite-schedule merge input consumed by the coherent path). Refs #1936 * test(charging-station,meter-values): apply Round 7 test-strengthening fixes to #1949 (#1936) R7 review surfaced two valid regression-proof gaps in the test suite (other Lane B claims rejected as too-strict interpretation of boundary and purpose-guard tests). Only actionable strengthening applied here. 1. Sigmoid ramp test at CoherentMeterValues.test.ts:1782+ only pinned endpoints (0, 1) via short-circuit and midpoint (0.5) via symmetry — all three points collapse to the same values on linear ramp. A silent revert of the sigmoid branch back to linear would pass the assertion. Added two interior-curvature assertions at rampUpDurationMs/4 and 3*rampUpDurationMs/4 where sigmoid (k=10) emits ~0.07 and ~0.93 respectively vs linear 0.25 and 0.75. Tolerance 0.15/0.85 sits well below the ~0.18 sigmoid-vs-linear gap, so the sigmoid feature is now contractually locked at interior points. 2. Readonly contract on ChargingStationNameTemplate had no regression lock. Added a compile-time assertion via @ts-expect-error on a mutation attempt. If the Readonly> wrapper is silently removed, tsc --noEmit fails with 'Unused @ts-expect-error directive' at build time. Runtime tolerates the mutation (Readonly is compile-time only); the compile-time directive is the regression lock. Refs #1936 * docs(charging-station): fix HelpersId JSDoc warnings on createSerialNumber (#1936) pnpm format surfaced 3 lint warnings on HelpersId.ts:createSerialNumber: - cspell/spellchecker: unknown word 'uppercased' - jsdoc/require-param-description: missing params.randomSerialNumber description - jsdoc/require-param-description: missing params.randomSerialNumberUpperCase description Rewrote the JSDoc to spell 'upper case' with a space (standard English) and added explicit descriptions for the two nested params. pnpm format and pnpm lint are now warning-free. Refs #1936 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- diff --git a/README.md b/README.md index cfd62f97..f1c5696b 100644 --- a/README.md +++ b/README.md @@ -474,15 +474,17 @@ The EV profile file is a JSON file referenced by the `evProfilesFile` template f } ``` -| Field | Type | Constraints | Description | -| ---------------------- | --------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | string | non-empty | Unique profile identifier (used in logs) | -| `batteryCapacityWh` | number | > 0 | Battery capacity in Wh. Bounds ΔSoC per ΔE sample | -| `maxPowerW` | number | > 0 | Maximum EV acceptance power in W at SoC = 0 | -| `weight` | number | ≥ 0 | Relative selection weight. Higher weight increases the probability this profile is chosen for a transaction. A weight of 0 disables the profile | -| `initialSocPercentMin` | number | [0, 100] | Minimum initial SoC (%) at transaction start. If greater than `initialSocPercentMax`, the two values are swapped and a warning is logged | -| `initialSocPercentMax` | number | [0, 100] | Maximum initial SoC (%) at transaction start. See `initialSocPercentMin` for swap-and-warn behavior when bounds are inverted | -| `chargingCurve` | `{ socPercent, powerFraction }[]` | non-empty, sorted | Piecewise-linear taper of EV acceptance power as a fraction of `maxPowerW`. Must be sorted non-decreasing by `socPercent`. `powerFraction` in [0, 1] | +| Field | Type | Constraints | Description | +| ---------------------- | --------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | non-empty | Unique profile identifier (used in logs) | +| `batteryCapacityWh` | number | > 0 | Battery capacity in Wh. Bounds ΔSoC per ΔE sample | +| `maxPowerW` | number | > 0 | Maximum EV acceptance power in W at SoC = 0 | +| `weight` | number | ≥ 0 | Relative selection weight. Higher weight increases the probability this profile is chosen for a transaction. A weight of 0 disables the profile | +| `initialSocPercentMin` | number | [0, 100] | Minimum initial SoC (%) at transaction start. If greater than `initialSocPercentMax`, the two values are swapped and a warning is logged | +| `initialSocPercentMax` | number | [0, 100] | Maximum initial SoC (%) at transaction start. See `initialSocPercentMin` for swap-and-warn behavior when bounds are inverted | +| `powerFactor` | number | [0.5, 1], optional | Optional cos φ between real and apparent power. Absent ⇒ `1` (unity, current behavior preserved). AC only: multiplies the divisor in the per-phase current derivation `I = P / (V · phases · powerFactor)`, so `I` rises inversely proportional to `powerFactor` for a given delivered active power. Ignored on DC profiles (`P = V·I` has no reactive component). Lower bound `0.5` blocks configuration values that would drive the divisor toward zero; real onboard chargers sit at `0.98..1.0` | +| `rampShape` | `'linear'` \| `'sigmoid'` | optional | Optional ramp-up shape between session start and full-power acceptance. Absent ⇒ `'linear'` (current behavior preserved). `'sigmoid'` selects an S-shaped logistic pinned at `f(0) = 0` and `f(rampUpDurationMs) = 1` that models CCS/CHAdeMO handshake and pre-charge behavior more faithfully | +| `chargingCurve` | `{ socPercent, powerFraction }[]` | non-empty, sorted | Piecewise-linear taper of EV acceptance power as a fraction of `maxPowerW`. Must be sorted non-decreasing by `socPercent`. `powerFraction` in [0, 1] | A template file is available at [src/assets/ev-profiles-template.json](./src/assets/ev-profiles-template.json). diff --git a/src/assets/ev-profiles-template.json b/src/assets/ev-profiles-template.json index ab0c2aa1..d8607334 100644 --- a/src/assets/ev-profiles-template.json +++ b/src/assets/ev-profiles-template.json @@ -21,6 +21,8 @@ "maxPowerW": 22000, "initialSocPercentMin": 10, "initialSocPercentMax": 60, + "powerFactor": 0.98, + "rampShape": "sigmoid", "chargingCurve": [ { "socPercent": 0, "powerFraction": 1.0 }, { "socPercent": 70, "powerFraction": 0.85 }, diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index 4fe7cfb2..1a900be5 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -1,107 +1,64 @@ import type { EventEmitter } from 'node:events' -import { - addDays, - addSeconds, - addWeeks, - differenceInDays, - differenceInSeconds, - differenceInWeeks, - type Interval, - isAfter, - isBefore, - isDate, - isWithinInterval, - toDate, -} from 'date-fns' -import { maxTime } from 'date-fns/constants' -import { hash, randomBytes } from 'node:crypto' -import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path' -import { env } from 'node:process' -import { fileURLToPath } from 'node:url' - import type { ChargingStation } from './ChargingStation.js' -import { BaseError } from '../exception/index.js' import { - AmpereUnits, - AvailabilityType, - type ChargingProfile, - ChargingProfileKindType, - ChargingProfilePurposeType, - ChargingRateUnitType, - type ChargingSchedule, - type ChargingSchedulePeriod, - type ChargingStationConfiguration, - type ChargingStationInfo, - type ChargingStationOptions, type ChargingStationTemplate, type ChargingStationWorkerMessageEvents, ConnectorPhaseRotation, - type ConnectorStatus, - ConnectorStatusEnum, - CurrentType, - type EvseTemplate, - OCPPVersion, - PowerUnits, - RecurrencyKindType, StandardParametersKey, type SupportedFeatureProfiles, - Voltage, } from '../types/index.js' -import { - ACElectricUtils, - clone, - Constants, - convertToDate, - convertToInt, - DCElectricUtils, - isArraySorted, - isEmpty, - isNotEmptyArray, - isNotEmptyString, - isValidDate, - logger, - secureRandom, -} from '../utils/index.js' +import { isNotEmptyArray, logger, secureRandom } from '../utils/index.js' import { getConfigurationKey } from './ConfigurationKeyUtils.js' +import { getMaxNumberOfConnectors } from './HelpersConfig.js' const moduleName = 'Helpers' -export const buildTemplateName = (templateFile: string): string => { - if (isAbsolute(templateFile)) { - templateFile = relative( - resolve(join(dirname(fileURLToPath(import.meta.url)), 'assets', 'station-templates')), - templateFile - ) - } - const templateFileParsedPath = parse(templateFile) - return join(templateFileParsedPath.dir, templateFileParsedPath.name) -} - -export type ChargingStationNameTemplate = Pick< - ChargingStationTemplate, - 'baseName' | 'fixedName' | 'nameSuffix' -> - -export const getChargingStationId = ( - index: number, - nameTemplate: ChargingStationNameTemplate | undefined -): string => { - if (nameTemplate == null) { - return "Unknown 'chargingStationId'" - } - // In case of multiple instances: add instance index to charging station id - const instanceIndex = env.CF_INSTANCE_INDEX ?? 0 - const idSuffix = nameTemplate.nameSuffix ?? '' - const idStr = `000000000${index.toString()}` - return nameTemplate.fixedName === true - ? nameTemplate.baseName - : `${nameTemplate.baseName}-${instanceIndex.toString()}${idStr.substring( - idStr.length - 4 - )}${idSuffix}` -} +// Barrel re-exports use explicit name lists (rather than `export * from`) +// so the public API surface of `./Helpers.js` is documented in-file and +// grep-able, and so accidental additions to a sibling file do not +// silently widen the public surface. The test-side barrel +// (`tests/charging-station/helpers/StationHelpers.ts`) uses `export *` +// because its consumers are internal to the test tree. +export { + canProceedChargingProfile, + getChargingStationChargingProfilesLimit, + getConnectorChargingProfiles, + getConnectorChargingProfilesLimit, + prepareChargingProfileKind, +} from './HelpersChargingProfile.js' +export { + buildTemplateName, + checkConfiguration, + getAmperageLimitationUnitDivider, + getDefaultConnectorMaximumPower, + getDefaultVoltageOut, + getEvProfilesFile, + getIdTagsFile, + getMaxNumberOfConnectors, + getMaxNumberOfEvses, + setChargingStationOptions, + stationTemplateToStationInfo, + validateStationInfo, +} from './HelpersConfig.js' +export { + buildConnectorsMap, + checkStationInfoConnectorStatus, + getBootConnectorStatus, + initializeConnectorsMapStatus, + prepareConnectorStatus, + resetAuthorizeConnectorStatus, + resetConnectorStatus, +} from './HelpersConnectorStatus.js' +export { + type ChargingStationNameTemplate, + createSerialNumber, + getChargingStationId, + getHashId, + propagateSerialNumber, +} from './HelpersId.js' export { hasPendingReservation, hasPendingReservations, @@ -109,97 +66,6 @@ export { removeExpiredReservations, } from './HelpersReservation.js' -export const getHashId = ( - index: number, - stationTemplate: ChargingStationTemplate, - chargingStationIdOverride?: string -): string => { - const chargingStationInfo = { - chargePointModel: stationTemplate.chargePointModel, - chargePointVendor: stationTemplate.chargePointVendor, - ...(stationTemplate.chargeBoxSerialNumberPrefix != null && { - chargeBoxSerialNumber: stationTemplate.chargeBoxSerialNumberPrefix, - }), - ...(stationTemplate.chargePointSerialNumberPrefix != null && { - chargePointSerialNumber: stationTemplate.chargePointSerialNumberPrefix, - }), - ...(stationTemplate.meterSerialNumberPrefix != null && { - meterSerialNumber: stationTemplate.meterSerialNumberPrefix, - }), - ...(stationTemplate.meterType != null && { - meterType: stationTemplate.meterType, - }), - } - return hash( - Constants.DEFAULT_HASH_ALGORITHM, - `${JSON.stringify(chargingStationInfo)}${ - chargingStationIdOverride ?? getChargingStationId(index, stationTemplate) - }`, - 'hex' - ) -} - -export const validateStationInfo = (chargingStation: ChargingStation): void => { - if (chargingStation.stationInfo == null || isEmpty(chargingStation.stationInfo)) { - throw new BaseError('Missing charging station information') - } - if ( - chargingStation.stationInfo.chargingStationId == null || - isEmpty(chargingStation.stationInfo.chargingStationId) - ) { - throw new BaseError('Missing chargingStationId in stationInfo properties') - } - const chargingStationId = chargingStation.stationInfo.chargingStationId - if ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - chargingStation.stationInfo.hashId == null || - isEmpty(chargingStation.stationInfo.hashId) - ) { - throw new BaseError(`${chargingStationId}: Missing hashId in stationInfo properties`) - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (chargingStation.stationInfo.templateIndex == null) { - throw new BaseError(`${chargingStationId}: Missing templateIndex in stationInfo properties`) - } - if (chargingStation.stationInfo.templateIndex <= 0) { - throw new BaseError( - `${chargingStationId}: Invalid templateIndex value in stationInfo properties` - ) - } - if ( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - chargingStation.stationInfo.templateName == null || - isEmpty(chargingStation.stationInfo.templateName) - ) { - throw new BaseError(`${chargingStationId}: Missing templateName in stationInfo properties`) - } - if (chargingStation.stationInfo.maximumPower == null) { - throw new BaseError(`${chargingStationId}: Missing maximumPower in stationInfo properties`) - } - if (chargingStation.stationInfo.maximumPower <= 0) { - throw new RangeError( - `${chargingStationId}: Invalid maximumPower value in stationInfo properties` - ) - } - if (chargingStation.stationInfo.maximumAmperage == null) { - throw new BaseError(`${chargingStationId}: Missing maximumAmperage in stationInfo properties`) - } - if (chargingStation.stationInfo.maximumAmperage <= 0) { - throw new RangeError( - `${chargingStationId}: Invalid maximumAmperage value in stationInfo properties` - ) - } - switch (chargingStation.stationInfo.ocppVersion) { - case OCPPVersion.VERSION_20: - case OCPPVersion.VERSION_201: - if (chargingStation.getNumberOfEvses() === 0) { - throw new BaseError( - `${chargingStationId}: OCPP ${chargingStation.stationInfo.ocppVersion} requires at least one EVSE defined in the charging station template/configuration` - ) - } - } -} - export const checkChargingStationState = ( chargingStation: ChargingStation, logPrefix: string @@ -231,340 +97,6 @@ export const getPhaseRotationValue = ( return undefined } -export const getMaxNumberOfEvses = (evses: Record | undefined): number => { - if (evses == null) { - return -1 - } - return isEmpty(evses) ? 0 : Object.keys(evses).length -} - -export const getDefaultConnectorMaximumPower = ( - stationTemplate: ChargingStationTemplate -): number | undefined => { - let maximumPower: number | undefined - if (isNotEmptyArray(stationTemplate.power)) { - const powerArrayRandomIndex = Math.floor(secureRandom() * stationTemplate.power.length) - maximumPower = - stationTemplate.powerUnit === PowerUnits.KILO_WATT - ? stationTemplate.power[powerArrayRandomIndex] * 1000 - : stationTemplate.power[powerArrayRandomIndex] - } else if (typeof stationTemplate.power === 'number') { - maximumPower = - stationTemplate.powerUnit === PowerUnits.KILO_WATT - ? stationTemplate.power * 1000 - : stationTemplate.power - } - if (maximumPower == null) { - return undefined - } - if (stationTemplate.powerSharedByConnectors === true) { - return maximumPower - } - const staticCount = - stationTemplate.Evses != null - ? // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - getMaxNumberOfEvses(stationTemplate.Evses) - (stationTemplate.Evses['0'] != null ? 1 : 0) - : getMaxNumberOfConnectors(stationTemplate.Connectors) - - (stationTemplate.Connectors?.['0'] != null ? 1 : 0) - return staticCount > 0 ? maximumPower / staticCount : undefined -} - -export const getMaxNumberOfConnectors = ( - connectors: Record | undefined -): number => { - if (connectors == null) { - return -1 - } - return isEmpty(connectors) ? 0 : Object.keys(connectors).length -} - -export const getBootConnectorStatus = ( - chargingStation: ChargingStation, - connectorId: number, - connectorStatus: ConnectorStatus -): ConnectorStatusEnum => { - if ( - !chargingStation.isChargingStationAvailable() || - !chargingStation.isConnectorAvailable(connectorId) - ) { - return ConnectorStatusEnum.Unavailable - } - if (connectorStatus.transactionStarted === true && connectorStatus.status != null) { - return connectorStatus.status - } - if (connectorStatus.bootStatus != null) { - return connectorStatus.bootStatus - } - return ConnectorStatusEnum.Available -} - -export const checkConfiguration = ( - stationConfiguration: ChargingStationConfiguration | undefined, - logPrefix: string, - configurationFile: string -): void => { - if (stationConfiguration == null) { - const errorMsg = `Failed to read charging station configuration file ${configurationFile}` - logger.error(`${logPrefix} ${moduleName}.checkConfiguration: ${errorMsg}`) - throw new BaseError(errorMsg) - } - if (isEmpty(stationConfiguration)) { - const errorMsg = `Empty charging station configuration from file ${configurationFile}` - logger.error(`${logPrefix} ${moduleName}.checkConfiguration: ${errorMsg}`) - throw new BaseError(errorMsg) - } -} - -export const checkStationInfoConnectorStatus = ( - connectorId: number, - connectorStatus: ConnectorStatus, - logPrefix: string, - templateFile: string -): void => { - if (connectorStatus.status != null) { - logger.warn( - `${logPrefix} ${moduleName}.checkStationInfoConnectorStatus: Charging station information from template ${templateFile} with connector id ${connectorId.toString()} status configuration defined, removing it` - ) - delete connectorStatus.status - } -} - -export const setChargingStationOptions = ( - stationInfo: ChargingStationInfo, - options?: ChargingStationOptions -): ChargingStationInfo => { - if (options?.supervisionUrls != null) { - stationInfo.supervisionUrls = options.supervisionUrls - } - if (options?.supervisionUser != null) { - stationInfo.supervisionUser = options.supervisionUser - } - if (options?.supervisionPassword != null) { - stationInfo.supervisionPassword = options.supervisionPassword - } - if (options?.persistentConfiguration != null) { - stationInfo.stationInfoPersistentConfiguration = options.persistentConfiguration - stationInfo.ocppPersistentConfiguration = options.persistentConfiguration - stationInfo.automaticTransactionGeneratorPersistentConfiguration = - options.persistentConfiguration - } - if (options?.autoStart != null) { - stationInfo.autoStart = options.autoStart - } - if (options?.autoRegister != null) { - stationInfo.autoRegister = options.autoRegister - } - if (options?.enableStatistics != null) { - stationInfo.enableStatistics = options.enableStatistics - } - if (options?.ocppStrictCompliance != null) { - stationInfo.ocppStrictCompliance = options.ocppStrictCompliance - } - if (options?.stopTransactionsOnStopped != null) { - stationInfo.stopTransactionsOnStopped = options.stopTransactionsOnStopped - } - if (options?.baseName != null) { - stationInfo.baseName = options.baseName - } - if (options?.fixedName != null) { - stationInfo.fixedName = options.fixedName - } - if (options?.nameSuffix != null) { - stationInfo.nameSuffix = options.nameSuffix - } - return stationInfo -} - -export const buildConnectorsMap = ( - connectors: Record, - logPrefix: string, - templateFile: string -): Map => { - const connectorsMap = new Map() - if (getMaxNumberOfConnectors(connectors) > 0) { - for (const [connectorKey, connectorStatus] of Object.entries(connectors)) { - const connectorId = convertToInt(connectorKey) - checkStationInfoConnectorStatus(connectorId, connectorStatus, logPrefix, templateFile) - connectorsMap.set(connectorId, clone(connectorStatus)) - } - } else { - logger.warn( - `${logPrefix} ${moduleName}.buildConnectorsMap: Charging station information from template ${templateFile} with no connectors, cannot build connectors map` - ) - } - return connectorsMap -} - -export const initializeConnectorsMapStatus = ( - connectors: Map, - logPrefix: string, - defaultMaximumPower?: number -): void => { - for (const [connectorId, connectorStatus] of connectors) { - if (connectorId > 0 && connectorStatus.transactionStarted === true) { - if ( - connectorStatus.transactionId == null || - connectorStatus.status === ConnectorStatusEnum.Finishing - ) { - resetConnectorStatus(connectorStatus) - connectorStatus.locked = false - logger.warn( - `${logPrefix} ${moduleName}.initializeConnectorsMapStatus: Connector id ${connectorId.toString()} at initialization has stale transaction state, resetting` - ) - } else { - logger.warn( - `${logPrefix} ${moduleName}.initializeConnectorsMapStatus: Connector id ${connectorId.toString()} at initialization has a transaction started with id ${connectorStatus.transactionId.toString()}` - ) - } - } - if (connectorId === 0) { - connectorStatus.availability = AvailabilityType.Operative - connectorStatus.chargingProfiles ??= [] - } else if (connectorId > 0 && connectorStatus.transactionStarted == null) { - initializeConnectorStatus(connectorStatus, defaultMaximumPower) - } - } -} - -export const resetAuthorizeConnectorStatus = (connectorStatus: ConnectorStatus): void => { - connectorStatus.idTagLocalAuthorized = false - connectorStatus.idTagAuthorized = false - delete connectorStatus.localAuthorizeIdTag - delete connectorStatus.authorizeIdTag -} - -export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefined): void => { - if (connectorStatus == null) { - return - } - if (isNotEmptyArray(connectorStatus.chargingProfiles)) { - connectorStatus.chargingProfiles = connectorStatus.chargingProfiles.filter( - chargingProfile => - (chargingProfile.chargingProfilePurpose === ChargingProfilePurposeType.TX_PROFILE && - chargingProfile.transactionId != null && - connectorStatus.transactionId != null && - chargingProfile.transactionId !== connectorStatus.transactionId) || - chargingProfile.chargingProfilePurpose !== ChargingProfilePurposeType.TX_PROFILE - ) - } - resetAuthorizeConnectorStatus(connectorStatus) - connectorStatus.transactionPending = false - connectorStatus.transactionRemoteStarted = false - connectorStatus.transactionStarted = false - delete connectorStatus.transactionStart - delete connectorStatus.transactionId - delete connectorStatus.transactionIdTag - delete connectorStatus.transactionGroupIdToken - connectorStatus.transactionEnergyActiveImportRegisterValue = 0 - delete connectorStatus.transactionBeginMeterValue - delete connectorStatus.transactionEndedMeterValues - if (connectorStatus.transactionEndedMeterValuesSetInterval != null) { - clearInterval(connectorStatus.transactionEndedMeterValuesSetInterval) - delete connectorStatus.transactionEndedMeterValuesSetInterval - } - delete connectorStatus.transactionSeqNo - delete connectorStatus.publicKeySentInTransaction - delete connectorStatus.transactionEvseSent - delete connectorStatus.transactionIdTokenSent - delete connectorStatus.transactionDeauthorized - delete connectorStatus.transactionDeauthorizedEnergyWh -} - -export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): ConnectorStatus => { - if (connectorStatus.reservation != null) { - const reservationExpiryDate = convertToDate(connectorStatus.reservation.expiryDate) - if (reservationExpiryDate != null) { - connectorStatus.reservation.expiryDate = reservationExpiryDate - } else { - delete connectorStatus.reservation - } - } - if (isNotEmptyArray(connectorStatus.chargingProfiles)) { - connectorStatus.chargingProfiles = connectorStatus.chargingProfiles - .filter( - chargingProfile => - chargingProfile.chargingProfilePurpose !== ChargingProfilePurposeType.TX_PROFILE - ) - .map(chargingProfile => { - const chargingSchedule = getSingleChargingSchedule(chargingProfile) - if (chargingSchedule != null) { - chargingSchedule.startSchedule = - convertToDate(chargingSchedule.startSchedule) ?? new Date() - } - chargingProfile.validFrom = convertToDate(chargingProfile.validFrom) - chargingProfile.validTo = convertToDate(chargingProfile.validTo) - return chargingProfile - }) - } - return connectorStatus -} - -export const stationTemplateToStationInfo = ( - stationTemplate: ChargingStationTemplate -): ChargingStationInfo => { - stationTemplate = clone(stationTemplate) - delete stationTemplate.power - delete stationTemplate.powerUnit - delete stationTemplate.Connectors - delete stationTemplate.Evses - delete stationTemplate.Configuration - delete stationTemplate.AutomaticTransactionGenerator - delete stationTemplate.numberOfConnectors - delete stationTemplate.chargeBoxSerialNumberPrefix - delete stationTemplate.chargePointSerialNumberPrefix - delete stationTemplate.meterSerialNumberPrefix - return stationTemplate as ChargingStationInfo -} - -export const createSerialNumber = ( - stationTemplate: ChargingStationTemplate, - stationInfo: ChargingStationInfo, - params?: { - randomSerialNumber?: boolean - randomSerialNumberUpperCase?: boolean - } -): void => { - params = { - ...{ randomSerialNumber: true, randomSerialNumberUpperCase: true }, - ...params, - } - const serialNumberSuffix = params.randomSerialNumber - ? getRandomSerialNumberSuffix({ - upperCase: params.randomSerialNumberUpperCase, - }) - : '' - isNotEmptyString(stationTemplate.chargePointSerialNumberPrefix) && - (stationInfo.chargePointSerialNumber = `${stationTemplate.chargePointSerialNumberPrefix}${serialNumberSuffix}`) - isNotEmptyString(stationTemplate.chargeBoxSerialNumberPrefix) && - (stationInfo.chargeBoxSerialNumber = `${stationTemplate.chargeBoxSerialNumberPrefix}${serialNumberSuffix}`) - isNotEmptyString(stationTemplate.meterSerialNumberPrefix) && - (stationInfo.meterSerialNumber = `${stationTemplate.meterSerialNumberPrefix}${serialNumberSuffix}`) -} - -export const propagateSerialNumber = ( - stationTemplate: ChargingStationTemplate | undefined, - stationInfoSrc: ChargingStationInfo | undefined, - stationInfoDst: ChargingStationInfo -): void => { - if (stationInfoSrc == null || stationTemplate == null) { - throw new BaseError( - 'Missing charging station template or existing configuration to propagate serial number' - ) - } - stationTemplate.chargePointSerialNumberPrefix != null && - stationInfoSrc.chargePointSerialNumber != null - ? (stationInfoDst.chargePointSerialNumber = stationInfoSrc.chargePointSerialNumber) - : stationInfoDst.chargePointSerialNumber != null && - delete stationInfoDst.chargePointSerialNumber - stationTemplate.chargeBoxSerialNumberPrefix != null && - stationInfoSrc.chargeBoxSerialNumber != null - ? (stationInfoDst.chargeBoxSerialNumber = stationInfoSrc.chargeBoxSerialNumber) - : stationInfoDst.chargeBoxSerialNumber != null && delete stationInfoDst.chargeBoxSerialNumber - stationTemplate.meterSerialNumberPrefix != null && stationInfoSrc.meterSerialNumber != null - ? (stationInfoDst.meterSerialNumber = stationInfoSrc.meterSerialNumber) - : stationInfoDst.meterSerialNumber != null && delete stationInfoDst.meterSerialNumber -} - export const hasFeatureProfile = ( chargingStation: ChargingStation, featureProfile: SupportedFeatureProfiles @@ -575,196 +107,6 @@ export const hasFeatureProfile = ( )?.value?.includes(featureProfile) } -export const getAmperageLimitationUnitDivider = (stationInfo: ChargingStationInfo): number => { - let unitDivider = 1 - switch (stationInfo.amperageLimitationUnit) { - case AmpereUnits.CENTI_AMPERE: - unitDivider = 100 - break - case AmpereUnits.DECI_AMPERE: - unitDivider = 10 - break - case AmpereUnits.MILLI_AMPERE: - unitDivider = 1000 - break - } - return unitDivider -} - -const getChargingStationChargingProfiles = ( - chargingStation: ChargingStation -): ChargingProfile[] => { - return (chargingStation.getConnectorStatus(0)?.chargingProfiles ?? []) - .filter( - chargingProfile => - chargingProfile.chargingProfilePurpose === - ChargingProfilePurposeType.CHARGE_POINT_MAX_PROFILE - ) - .sort((a, b) => b.stackLevel - a.stackLevel) -} - -export const getChargingStationChargingProfilesLimit = ( - chargingStation: ChargingStation -): number | undefined => { - const chargingProfiles = getChargingStationChargingProfiles(chargingStation) - if (isNotEmptyArray(chargingProfiles)) { - const chargingProfilesLimit = getChargingProfilesLimit(chargingStation, 0, chargingProfiles) - if (chargingProfilesLimit != null) { - const limit = buildChargingProfilesLimit(chargingStation, chargingProfilesLimit) - const chargingStationMaximumPower = chargingStation.stationInfo?.maximumPower - if (chargingStationMaximumPower == null) { - return limit - } - if (limit > chargingStationMaximumPower) { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.getChargingStationChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than charging station maximum ${chargingStationMaximumPower.toString()}: %j`, - chargingProfilesLimit - ) - return chargingStationMaximumPower - } - return limit - } - } -} - -/** - * Gets the connector charging profiles relevant for power limitation shallow cloned - * and sorted by priorities - * @param chargingStation - Charging station - * @param connectorId - Connector id - * @returns Connector charging profiles array - */ -export const getConnectorChargingProfiles = ( - chargingStation: ChargingStation, - connectorId: number -): ChargingProfile[] => { - return (chargingStation.getConnectorStatus(connectorId)?.chargingProfiles ?? []) - .slice() - .sort((a, b) => { - if ( - a.chargingProfilePurpose === ChargingProfilePurposeType.TX_PROFILE && - b.chargingProfilePurpose === ChargingProfilePurposeType.TX_DEFAULT_PROFILE - ) { - return -1 - } else if ( - a.chargingProfilePurpose === ChargingProfilePurposeType.TX_DEFAULT_PROFILE && - b.chargingProfilePurpose === ChargingProfilePurposeType.TX_PROFILE - ) { - return 1 - } - return b.stackLevel - a.stackLevel - }) - .concat( - (chargingStation.getConnectorStatus(0)?.chargingProfiles ?? []) - .filter( - chargingProfile => - chargingProfile.chargingProfilePurpose === ChargingProfilePurposeType.TX_DEFAULT_PROFILE - ) - .sort((a, b) => b.stackLevel - a.stackLevel) - ) -} - -export const getConnectorChargingProfilesLimit = ( - chargingStation: ChargingStation, - connectorId: number -): number | undefined => { - const chargingProfiles = getConnectorChargingProfiles(chargingStation, connectorId) - if (isNotEmptyArray(chargingProfiles)) { - const chargingProfilesLimit = getChargingProfilesLimit( - chargingStation, - connectorId, - chargingProfiles - ) - if (chargingProfilesLimit != null) { - const limit = buildChargingProfilesLimit(chargingStation, chargingProfilesLimit) - const maximumPower = chargingStation.stationInfo?.maximumPower - if (maximumPower == null) { - return limit - } - const connectorMaximumPower = - chargingStation.getConnectorStatus(connectorId)?.maximumPower ?? - maximumPower / (chargingStation.powerDivider ?? 1) - if (limit > connectorMaximumPower) { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.getConnectorChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than connector ${connectorId.toString()} maximum ${connectorMaximumPower.toString()}: %j`, - chargingProfilesLimit - ) - return connectorMaximumPower - } - return limit - } - } -} - -const buildChargingProfilesLimit = ( - chargingStation: ChargingStation, - chargingProfilesLimit: ChargingProfilesLimit -): number => { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - const errorMsg = `Unknown ${chargingStation.stationInfo?.currentOutType} currentOutType in charging station information, cannot build charging profiles limit` - const { chargingProfile, limit } = chargingProfilesLimit - const chargingSchedule = getSingleChargingSchedule( - chargingProfile, - chargingStation.logPrefix(), - 'buildChargingProfilesLimit' - ) - if (chargingSchedule == null) { - return limit - } - switch (chargingStation.stationInfo?.currentOutType) { - case CurrentType.AC: - return chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT - ? limit - : ACElectricUtils.powerTotal( - chargingStation.getNumberOfPhases(), - chargingStation.getVoltageOut(), - limit - ) - case CurrentType.DC: - return chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT - ? limit - : DCElectricUtils.power(chargingStation.getVoltageOut(), limit) - default: - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.buildChargingProfilesLimit: ${errorMsg}` - ) - throw new BaseError(errorMsg) - } -} - -export const getDefaultVoltageOut = ( - currentType: CurrentType, - logPrefix: string, - templateFile: string -): Voltage => { - const errorMsg = `Unknown ${currentType} currentOutType in template file ${templateFile}, cannot define default voltage out` - let defaultVoltageOut: number - switch (currentType) { - case CurrentType.AC: - defaultVoltageOut = Voltage.VOLTAGE_230 - break - case CurrentType.DC: - defaultVoltageOut = Voltage.VOLTAGE_400 - break - default: - logger.error(`${logPrefix} ${moduleName}.getDefaultVoltageOut: ${errorMsg}`) - throw new BaseError(errorMsg) - } - return defaultVoltageOut -} - -export const getIdTagsFile = (stationInfo: ChargingStationInfo): string | undefined => { - return stationInfo.idTagsFile != null - ? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.idTagsFile)) - : undefined -} - -export const getEvProfilesFile = (stationInfo: ChargingStationInfo): string | undefined => { - return stationInfo.evProfilesFile != null - ? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.evProfilesFile)) - : undefined -} - export const waitChargingStationEvents = async ( emitter: EventEmitter, event: ChargingStationWorkerMessageEvents, @@ -848,489 +190,3 @@ export const pickConfiguredNumberOfConnectors = ( } return undefined } - -const initializeConnectorStatus = ( - connectorStatus: ConnectorStatus, - defaultMaximumPower?: number -): void => { - connectorStatus.availability = AvailabilityType.Operative - connectorStatus.idTagLocalAuthorized = false - connectorStatus.idTagAuthorized = false - connectorStatus.transactionRemoteStarted = false - connectorStatus.transactionStarted = false - connectorStatus.energyActiveImportRegisterValue = 0 - connectorStatus.transactionEnergyActiveImportRegisterValue = 0 - connectorStatus.chargingProfiles ??= [] - if (defaultMaximumPower != null) { - connectorStatus.maximumPower ??= defaultMaximumPower - } -} - -interface ChargingProfilesLimit { - chargingProfile: ChargingProfile - limit: number -} - -const getChargingProfileId = (chargingProfile: ChargingProfile): string => { - const id = chargingProfile.chargingProfileId ?? chargingProfile.id - return typeof id === 'number' ? id.toString() : 'unknown' -} - -const getSingleChargingSchedule = ( - chargingProfile: ChargingProfile, - logPrefix?: string, - methodName?: string -): ChargingSchedule | undefined => { - if (!Array.isArray(chargingProfile.chargingSchedule)) { - return chargingProfile.chargingSchedule - } - if (logPrefix != null && methodName != null) { - logger.debug( - `${logPrefix} ${moduleName}.${methodName}: Charging profile id ${getChargingProfileId(chargingProfile)} has an OCPP 2.0 chargingSchedule array and is skipped` - ) - } -} - -/** - * Get the charging profiles limit for a connector - * Charging profiles shall already be sorted by priorities - * @param chargingStation - The charging station instance - * @param connectorId - The connector identifier - * @param chargingProfiles - Array of charging profiles - * @returns Charging profiles limit or undefined if no valid limit found - */ -const getChargingProfilesLimit = ( - chargingStation: ChargingStation, - connectorId: number, - chargingProfiles: ChargingProfile[] -): ChargingProfilesLimit | undefined => { - const debugLogMsg = `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profiles limit found: %j` - const currentDate = new Date() - const connectorStatus = chargingStation.getConnectorStatus(connectorId) - let previousActiveChargingProfile: ChargingProfile | undefined - for (const chargingProfile of chargingProfiles) { - const chargingProfileId = getChargingProfileId(chargingProfile) - const chargingSchedule = getSingleChargingSchedule( - chargingProfile, - chargingStation.logPrefix(), - 'getChargingProfilesLimit' - ) - if (chargingSchedule == null) { - continue - } - if (chargingSchedule.startSchedule == null) { - logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} has no startSchedule defined. Trying to set it to the connector current transaction start date` - ) - // OCPP specifies that if startSchedule is not defined, it should be relative to start of the connector transaction - chargingSchedule.startSchedule = connectorStatus?.transactionStart - } - if (!isDate(chargingSchedule.startSchedule)) { - logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} startSchedule property is not a Date instance. Trying to convert it to a Date instance` - ) - chargingSchedule.startSchedule = convertToDate(chargingSchedule.startSchedule) ?? new Date() - } - if (chargingSchedule.duration == null) { - logger.debug( - `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} has no duration defined and will be set to the maximum time allowed` - ) - // OCPP specifies that if duration is not defined, it should be infinite - - chargingSchedule.duration = differenceInSeconds(maxTime, chargingSchedule.startSchedule) - } - if ( - !prepareChargingProfileKind( - connectorStatus, - chargingProfile, - currentDate, - chargingStation.logPrefix() - ) - ) { - continue - } - if (!canProceedChargingProfile(chargingProfile, currentDate, chargingStation.logPrefix())) { - continue - } - // Check if the charging profile is active - if ( - isWithinInterval(currentDate, { - end: addSeconds(chargingSchedule.startSchedule, chargingSchedule.duration), - - start: chargingSchedule.startSchedule, - }) - ) { - if (isNotEmptyArray(chargingSchedule.chargingSchedulePeriod)) { - const chargingSchedulePeriodCompareFn = ( - a: ChargingSchedulePeriod, - b: ChargingSchedulePeriod - ): number => a.startPeriod - b.startPeriod - if ( - !isArraySorted( - chargingSchedule.chargingSchedulePeriod, - chargingSchedulePeriodCompareFn - ) - ) { - logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} schedule periods are not sorted by start period` - ) - - chargingSchedule.chargingSchedulePeriod.sort(chargingSchedulePeriodCompareFn) - } - // Check if the first schedule period startPeriod property is equal to 0 - - if (chargingSchedule.chargingSchedulePeriod[0].startPeriod !== 0) { - logger.error( - `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} first schedule period start period ${chargingSchedule.chargingSchedulePeriod[0].startPeriod.toString()} is not equal to 0` - ) - continue - } - // Handle only one schedule period - - if (chargingSchedule.chargingSchedulePeriod.length === 1) { - const chargingProfilesLimit: ChargingProfilesLimit = { - chargingProfile, - - limit: chargingSchedule.chargingSchedulePeriod[0].limit, - } - logger.debug(debugLogMsg, chargingProfilesLimit) - return chargingProfilesLimit - } - let previousChargingSchedulePeriod: ChargingSchedulePeriod | undefined - // Search for the right schedule period - for (const [ - index, - chargingSchedulePeriod, - ] of chargingSchedule.chargingSchedulePeriod.entries()) { - // Find the right schedule period - if ( - isAfter( - addSeconds(chargingSchedule.startSchedule, chargingSchedulePeriod.startPeriod), - currentDate - ) - ) { - // Found the schedule period: previous is the correct one - const chargingProfilesLimit: ChargingProfilesLimit = { - chargingProfile: previousActiveChargingProfile ?? chargingProfile, - - limit: previousChargingSchedulePeriod?.limit ?? chargingSchedulePeriod.limit, - } - logger.debug(debugLogMsg, chargingProfilesLimit) - return chargingProfilesLimit - } - // Handle the last schedule period within the charging profile duration - if ( - index === chargingSchedule.chargingSchedulePeriod.length - 1 || - (index < chargingSchedule.chargingSchedulePeriod.length - 1 && - differenceInSeconds( - addSeconds( - chargingSchedule.startSchedule, - - chargingSchedule.chargingSchedulePeriod[index + 1].startPeriod - ), - - chargingSchedule.startSchedule - ) > chargingSchedule.duration) - ) { - const chargingProfilesLimit: ChargingProfilesLimit = { - chargingProfile, - - limit: chargingSchedulePeriod.limit, - } - logger.debug(debugLogMsg, chargingProfilesLimit) - return chargingProfilesLimit - } - // Keep a reference to previous charging schedule period - - previousChargingSchedulePeriod = chargingSchedulePeriod - } - } - // Keep a reference to previous active charging profile - previousActiveChargingProfile = chargingProfile - } - } -} - -export const prepareChargingProfileKind = ( - connectorStatus: ConnectorStatus | undefined, - chargingProfile: ChargingProfile, - currentDate: Date | number | string, - logPrefix: string -): boolean => { - const chargingProfileId = getChargingProfileId(chargingProfile) - const chargingSchedule = getSingleChargingSchedule( - chargingProfile, - logPrefix, - 'prepareChargingProfileKind' - ) - if (chargingSchedule == null) { - return false - } - switch (chargingProfile.chargingProfileKind) { - case ChargingProfileKindType.RECURRING: - if (!canProceedRecurringChargingProfile(chargingProfile, logPrefix)) { - return false - } - prepareRecurringChargingProfile(chargingProfile, currentDate, logPrefix) - break - case ChargingProfileKindType.RELATIVE: - if (chargingSchedule.startSchedule != null) { - logger.warn( - `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} has a startSchedule property defined. It will be ignored or used if the connector has a transaction started` - ) - delete chargingSchedule.startSchedule - } - if (connectorStatus?.transactionStarted !== true) { - logger.debug( - `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} has no active transaction, cannot be evaluated` - ) - return false - } - chargingSchedule.startSchedule = connectorStatus.transactionStart - if (chargingSchedule.startSchedule == null) { - logger.warn( - `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} has active transaction without start date` - ) - return false - } - if (chargingSchedule.duration != null) { - const elapsedSeconds = differenceInSeconds(currentDate, chargingSchedule.startSchedule) - if (elapsedSeconds > chargingSchedule.duration) { - logger.debug( - `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} duration ${chargingSchedule.duration.toString()}s exceeded (elapsed: ${elapsedSeconds.toString()}s)` - ) - return false - } - } - break - } - return true -} - -export const canProceedChargingProfile = ( - chargingProfile: ChargingProfile, - currentDate: Date | number | string, - logPrefix: string -): boolean => { - const chargingProfileId = getChargingProfileId(chargingProfile) - const chargingSchedule = getSingleChargingSchedule( - chargingProfile, - logPrefix, - 'canProceedChargingProfile' - ) - if (chargingSchedule == null) { - return false - } - if ( - (isValidDate(chargingProfile.validFrom) && isBefore(currentDate, chargingProfile.validFrom)) || - (isValidDate(chargingProfile.validTo) && isAfter(currentDate, chargingProfile.validTo)) - ) { - logger.debug( - `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} is not valid for the current date ${ - isDate(currentDate) ? currentDate.toISOString() : currentDate.toString() - }` - ) - return false - } - if (chargingSchedule.startSchedule == null || chargingSchedule.duration == null) { - logger.error( - `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has no startSchedule or duration defined` - ) - return false - } - - if (!isValidDate(chargingSchedule.startSchedule)) { - logger.error( - `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has an invalid startSchedule date defined` - ) - return false - } - if (!Number.isSafeInteger(chargingSchedule.duration)) { - logger.error( - `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has non integer duration defined` - ) - return false - } - return true -} - -const canProceedRecurringChargingProfile = ( - chargingProfile: ChargingProfile, - logPrefix: string -): boolean => { - const chargingProfileId = getChargingProfileId(chargingProfile) - const chargingSchedule = getSingleChargingSchedule( - chargingProfile, - logPrefix, - 'canProceedRecurringChargingProfile' - ) - if (chargingSchedule == null) { - return false - } - if ( - chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING && - chargingProfile.recurrencyKind == null - ) { - logger.error( - `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfileId} has no recurrencyKind defined` - ) - return false - } - if ( - chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING && - chargingSchedule.startSchedule == null - ) { - logger.error( - `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfileId} has no startSchedule defined` - ) - return false - } - return true -} - -/** - * Adjust recurring charging profile startSchedule to the current recurrency time interval if needed - * @param chargingProfile - The charging profile to adjust - * @param currentDate - The current date/time - * @param logPrefix - Prefix for logging messages - * @returns Whether the charging profile is active at the given date - */ -const prepareRecurringChargingProfile = ( - chargingProfile: ChargingProfile, - currentDate: Date | number | string, - logPrefix: string -): boolean => { - const chargingProfileId = getChargingProfileId(chargingProfile) - const chargingSchedule = getSingleChargingSchedule( - chargingProfile, - logPrefix, - 'prepareRecurringChargingProfile' - ) - if (chargingSchedule == null) { - return false - } - let recurringIntervalTranslated = false - let recurringInterval: Interval | undefined - switch (chargingProfile.recurrencyKind) { - case RecurrencyKindType.DAILY: { - const startSchedule = chargingSchedule.startSchedule ?? new Date() - recurringInterval = { - end: addDays(startSchedule, 1), - start: startSchedule, - } - checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix) - if ( - !isWithinInterval(currentDate, recurringInterval) && - isBefore(recurringInterval.end, currentDate) - ) { - chargingSchedule.startSchedule = addDays( - recurringInterval.start, - differenceInDays(currentDate, recurringInterval.start) - ) - recurringInterval = { - end: addDays(chargingSchedule.startSchedule, 1), - - start: chargingSchedule.startSchedule, - } - recurringIntervalTranslated = true - } - break - } - case RecurrencyKindType.WEEKLY: { - const startSchedule = chargingSchedule.startSchedule ?? new Date() - recurringInterval = { - end: addWeeks(startSchedule, 1), - start: startSchedule, - } - checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix) - if ( - !isWithinInterval(currentDate, recurringInterval) && - isBefore(recurringInterval.end, currentDate) - ) { - chargingSchedule.startSchedule = addWeeks( - recurringInterval.start, - differenceInWeeks(currentDate, recurringInterval.start) - ) - recurringInterval = { - end: addWeeks(chargingSchedule.startSchedule, 1), - - start: chargingSchedule.startSchedule, - } - recurringIntervalTranslated = true - } - break - } - default: - logger.error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${chargingProfile.recurrencyKind} charging profile id ${chargingProfileId} is not supported` - ) - } - if ( - recurringIntervalTranslated && - recurringInterval != null && - !isWithinInterval(currentDate, recurringInterval) - ) { - logger.error( - `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${ - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - chargingProfile.recurrencyKind - } charging profile id ${chargingProfileId} recurrency time interval [${toDate( - recurringInterval.start as Date - ).toISOString()}, ${toDate( - recurringInterval.end as Date - ).toISOString()}] has not been properly translated to current date ${ - isDate(currentDate) ? currentDate.toISOString() : currentDate.toString() - } ` - ) - } - return recurringIntervalTranslated -} - -const checkRecurringChargingProfileDuration = ( - chargingProfile: ChargingProfile, - interval: Interval, - logPrefix: string -): void => { - const chargingProfileId = getChargingProfileId(chargingProfile) - const chargingSchedule = getSingleChargingSchedule( - chargingProfile, - logPrefix, - 'checkRecurringChargingProfileDuration' - ) - if (chargingSchedule == null) { - return - } - if (chargingSchedule.duration == null) { - logger.warn( - `${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${ - chargingProfile.chargingProfileKind - } charging profile id ${chargingProfileId} duration is not defined, set it to the recurrency time interval duration ${differenceInSeconds( - interval.end, - interval.start - ).toString()}` - ) - chargingSchedule.duration = differenceInSeconds(interval.end, interval.start) - } else if (chargingSchedule.duration > differenceInSeconds(interval.end, interval.start)) { - logger.warn( - `${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${ - chargingProfile.chargingProfileKind - } charging profile id ${chargingProfileId} duration ${chargingSchedule.duration.toString()} is greater than the recurrency time interval duration ${differenceInSeconds( - interval.end, - interval.start - ).toString()}` - ) - chargingSchedule.duration = differenceInSeconds(interval.end, interval.start) - } -} - -const getRandomSerialNumberSuffix = (params?: { - randomBytesLength?: number - upperCase?: boolean -}): string => { - const randomSerialNumberSuffix = randomBytes(params?.randomBytesLength ?? 16).toString('hex') - if (params?.upperCase) { - return randomSerialNumberSuffix.toUpperCase() - } - return randomSerialNumberSuffix -} diff --git a/src/charging-station/HelpersChargingProfile.ts b/src/charging-station/HelpersChargingProfile.ts new file mode 100644 index 00000000..962fe0b5 --- /dev/null +++ b/src/charging-station/HelpersChargingProfile.ts @@ -0,0 +1,727 @@ +// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Charging profile helpers. + * @description Predicates and helpers for OCPP charging profiles: station + * and connector charging-profile limit resolution, single-schedule + * selection, recurring profile preparation, and validity checks. + * Re-exported from `./Helpers.js` so callers keep the barrel import path + * (`import { canProceedChargingProfile, ... } from './Helpers.js'`). + */ + +import { + addDays, + addSeconds, + addWeeks, + differenceInDays, + differenceInSeconds, + differenceInWeeks, + type Interval, + isAfter, + isBefore, + isDate, + isWithinInterval, + toDate, +} from 'date-fns' +import { maxTime } from 'date-fns/constants' + +import type { ChargingStation } from './ChargingStation.js' + +import { BaseError } from '../exception/index.js' +import { + type ChargingProfile, + ChargingProfileKindType, + ChargingProfilePurposeType, + ChargingRateUnitType, + type ChargingSchedule, + type ChargingSchedulePeriod, + type ConnectorStatus, + CurrentType, + RecurrencyKindType, +} from '../types/index.js' +import { + ACElectricUtils, + convertToDate, + DCElectricUtils, + isArraySorted, + isNotEmptyArray, + isValidDate, + logger, +} from '../utils/index.js' + +const moduleName = 'HelpersChargingProfile' + +interface ChargingProfilesLimit { + chargingProfile: ChargingProfile + limit: number +} + +const getChargingProfileId = (chargingProfile: ChargingProfile): string => { + const id = chargingProfile.chargingProfileId ?? chargingProfile.id + return typeof id === 'number' ? id.toString() : 'unknown' +} + +/** + * Extracts the single {@link ChargingSchedule} referenced by a charging + * profile. OCPP 1.6 templates carry the schedule directly as a single + * value and are returned unchanged. OCPP 2.0.x templates carry a + * `chargingSchedule` array of 1 to 3 entries: a single-entry array is + * unwrapped and returned; a 0 or 2-3 entry array is logged (debug) and + * `undefined` is returned so the caller can skip cleanly, because the + * coherent path does not currently pick between multiple concurrent + * OCPP 2.0.x schedules. + * @param chargingProfile - Source charging profile. + * @param logPrefix - Optional log prefix for the multi-entry debug entry. + * @param methodName - Optional caller name included in the debug entry. + * @returns Single schedule for the OCPP 1.6 shape or a length-1 OCPP 2.0.x array, or `undefined` when the array carries 0 or 2-3 entries. + */ +export const getSingleChargingSchedule = ( + chargingProfile: ChargingProfile, + logPrefix?: string, + methodName?: string +): ChargingSchedule | undefined => { + if (!Array.isArray(chargingProfile.chargingSchedule)) { + return chargingProfile.chargingSchedule + } + if (chargingProfile.chargingSchedule.length === 1) { + return chargingProfile.chargingSchedule[0] + } + if (logPrefix != null && methodName != null) { + logger.debug( + `${logPrefix} ${moduleName}.${methodName}: Charging profile id ${getChargingProfileId(chargingProfile)} has an OCPP 2.0 chargingSchedule array with ${chargingProfile.chargingSchedule.length.toString()} entries and is skipped` + ) + } +} + +const getChargingStationChargingProfiles = ( + chargingStation: ChargingStation +): ChargingProfile[] => { + return (chargingStation.getConnectorStatus(0)?.chargingProfiles ?? []) + .filter( + chargingProfile => + chargingProfile.chargingProfilePurpose === + ChargingProfilePurposeType.CHARGE_POINT_MAX_PROFILE || + chargingProfile.chargingProfilePurpose === + ChargingProfilePurposeType.ChargingStationMaxProfile || + chargingProfile.chargingProfilePurpose === + ChargingProfilePurposeType.ChargingStationExternalConstraints + ) + .sort((a, b) => b.stackLevel - a.stackLevel) +} + +/** + * Highest-priority station-scope power limit currently in effect on the + * station. Combines the charging profiles whose purpose matches any + * station-scope value: `CHARGE_POINT_MAX_PROFILE` on OCPP 1.6, or + * `ChargingStationMaxProfile` / `ChargingStationExternalConstraints` on + * OCPP 2.0.1. Both OCPP 2.0.1 purposes cap station power at EVSE 0 + * (`ChargingStationMaxProfile` is the station-owned operational cap per + * OCA K04 Internal Load Balancing; `ChargingStationExternalConstraints` + * is a limit imposed by an external system that the station stores + * internally per OCA K11-K14 External Charging Limit and reports upstream + * to the CSMS via `NotifyChargingLimit` / `ReportChargingProfiles`, and + * that participates in composite-schedule merging per OCA K08.FR.04 and + * safety invariant SC.01); the coherent path treats both OCPP 2.0.1 + * purposes as equivalent inputs to the stack-level tie-break. Sorts by + * stack level and evaluates the winning profile's schedule period. + * @param chargingStation - Source charging station. + * @returns Limit in watts, or `undefined` when no applicable profile is found. + */ +export const getChargingStationChargingProfilesLimit = ( + chargingStation: ChargingStation +): number | undefined => { + const chargingProfiles = getChargingStationChargingProfiles(chargingStation) + if (isNotEmptyArray(chargingProfiles)) { + const chargingProfilesLimit = getChargingProfilesLimit(chargingStation, 0, chargingProfiles) + if (chargingProfilesLimit != null) { + const limit = buildChargingProfilesLimit(chargingStation, chargingProfilesLimit) + const chargingStationMaximumPower = chargingStation.stationInfo?.maximumPower + if (chargingStationMaximumPower == null) { + return limit + } + if (limit > chargingStationMaximumPower) { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.getChargingStationChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than charging station maximum ${chargingStationMaximumPower.toString()}: %j`, + chargingProfilesLimit + ) + return chargingStationMaximumPower + } + return limit + } + } +} + +/** + * Gets the connector charging profiles relevant for power limitation shallow cloned + * and sorted by priorities + * @param chargingStation - Charging station + * @param connectorId - Connector id + * @returns Connector charging profiles array + */ +export const getConnectorChargingProfiles = ( + chargingStation: ChargingStation, + connectorId: number +): ChargingProfile[] => { + return (chargingStation.getConnectorStatus(connectorId)?.chargingProfiles ?? []) + .slice() + .sort((a, b) => { + if ( + a.chargingProfilePurpose === ChargingProfilePurposeType.TX_PROFILE && + b.chargingProfilePurpose === ChargingProfilePurposeType.TX_DEFAULT_PROFILE + ) { + return -1 + } else if ( + a.chargingProfilePurpose === ChargingProfilePurposeType.TX_DEFAULT_PROFILE && + b.chargingProfilePurpose === ChargingProfilePurposeType.TX_PROFILE + ) { + return 1 + } + return b.stackLevel - a.stackLevel + }) + .concat( + (chargingStation.getConnectorStatus(0)?.chargingProfiles ?? []) + .filter( + chargingProfile => + chargingProfile.chargingProfilePurpose === ChargingProfilePurposeType.TX_DEFAULT_PROFILE + ) + .sort((a, b) => b.stackLevel - a.stackLevel) + ) +} + +/** + * Highest-priority per-connector power limit currently in effect on the + * given connector. Combines that connector's charging profiles, filters + * by priority, and evaluates the winning profile's schedule period. + * @param chargingStation - Source charging station. + * @param connectorId - Target connector id. + * @returns Limit in watts, or `undefined` when no applicable profile is found. + */ +export const getConnectorChargingProfilesLimit = ( + chargingStation: ChargingStation, + connectorId: number +): number | undefined => { + const chargingProfiles = getConnectorChargingProfiles(chargingStation, connectorId) + if (isNotEmptyArray(chargingProfiles)) { + const chargingProfilesLimit = getChargingProfilesLimit( + chargingStation, + connectorId, + chargingProfiles + ) + if (chargingProfilesLimit != null) { + const limit = buildChargingProfilesLimit(chargingStation, chargingProfilesLimit) + const maximumPower = chargingStation.stationInfo?.maximumPower + if (maximumPower == null) { + return limit + } + const connectorMaximumPower = + chargingStation.getConnectorStatus(connectorId)?.maximumPower ?? + maximumPower / (chargingStation.powerDivider ?? 1) + if (limit > connectorMaximumPower) { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.getConnectorChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than connector ${connectorId.toString()} maximum ${connectorMaximumPower.toString()}: %j`, + chargingProfilesLimit + ) + return connectorMaximumPower + } + return limit + } + } +} + +/** + * Converts a charging-profiles limit expressed in the schedule's unit into + * watts. When the schedule unit is already `WATT`, the limit is returned + * unchanged; when it is `AMPERE`, the value is converted using the AC or + * DC electrical helper matching the station's `currentOutType`. + * @param chargingStation - Station carrying `stationInfo.currentOutType`. + * @param chargingProfilesLimit - Selected charging profile and its raw limit. + * @returns Limit in watts, or the raw limit when no schedule is available. + * @throws {BaseError} When `stationInfo.currentOutType` is neither `AC` nor `DC`. + */ +const buildChargingProfilesLimit = ( + chargingStation: ChargingStation, + chargingProfilesLimit: ChargingProfilesLimit +): number => { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + const errorMsg = `Unknown ${chargingStation.stationInfo?.currentOutType} currentOutType in charging station information, cannot build charging profiles limit` + const { chargingProfile, limit } = chargingProfilesLimit + const chargingSchedule = getSingleChargingSchedule( + chargingProfile, + chargingStation.logPrefix(), + 'buildChargingProfilesLimit' + ) + if (chargingSchedule == null) { + return limit + } + switch (chargingStation.stationInfo?.currentOutType) { + case CurrentType.AC: + return chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT + ? limit + : ACElectricUtils.powerTotal( + chargingStation.getNumberOfPhases(), + chargingStation.getVoltageOut(), + limit + ) + case CurrentType.DC: + return chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT + ? limit + : DCElectricUtils.power(chargingStation.getVoltageOut(), limit) + default: + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.buildChargingProfilesLimit: ${errorMsg}` + ) + throw new BaseError(errorMsg) + } +} + +/** + * Get the charging profiles limit for a connector + * Charging profiles shall already be sorted by priorities + * @param chargingStation - The charging station instance + * @param connectorId - The connector identifier + * @param chargingProfiles - Array of charging profiles + * @returns Charging profiles limit or undefined if no valid limit found + */ +const getChargingProfilesLimit = ( + chargingStation: ChargingStation, + connectorId: number, + chargingProfiles: ChargingProfile[] +): ChargingProfilesLimit | undefined => { + const debugLogMsg = `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profiles limit found: %j` + const currentDate = new Date() + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + let previousActiveChargingProfile: ChargingProfile | undefined + for (const chargingProfile of chargingProfiles) { + const chargingProfileId = getChargingProfileId(chargingProfile) + const chargingSchedule = getSingleChargingSchedule( + chargingProfile, + chargingStation.logPrefix(), + 'getChargingProfilesLimit' + ) + if (chargingSchedule == null) { + continue + } + if (chargingSchedule.startSchedule == null) { + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} has no startSchedule defined. Trying to set it to the connector current transaction start date` + ) + // OCPP specifies that if startSchedule is not defined, it should be relative to start of the connector transaction + chargingSchedule.startSchedule = connectorStatus?.transactionStart + } + if (!isDate(chargingSchedule.startSchedule)) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} startSchedule property is not a Date instance. Trying to convert it to a Date instance` + ) + chargingSchedule.startSchedule = convertToDate(chargingSchedule.startSchedule) ?? new Date() + } + if (chargingSchedule.duration == null) { + logger.debug( + `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} has no duration defined and will be set to the maximum time allowed` + ) + // OCPP specifies that if duration is not defined, it should be infinite + + chargingSchedule.duration = differenceInSeconds(maxTime, chargingSchedule.startSchedule) + } + if ( + !prepareChargingProfileKind( + connectorStatus, + chargingProfile, + currentDate, + chargingStation.logPrefix() + ) + ) { + continue + } + if (!canProceedChargingProfile(chargingProfile, currentDate, chargingStation.logPrefix())) { + continue + } + // Check if the charging profile is active + if ( + isWithinInterval(currentDate, { + end: addSeconds(chargingSchedule.startSchedule, chargingSchedule.duration), + + start: chargingSchedule.startSchedule, + }) + ) { + if (isNotEmptyArray(chargingSchedule.chargingSchedulePeriod)) { + const chargingSchedulePeriodCompareFn = ( + a: ChargingSchedulePeriod, + b: ChargingSchedulePeriod + ): number => a.startPeriod - b.startPeriod + if ( + !isArraySorted( + chargingSchedule.chargingSchedulePeriod, + chargingSchedulePeriodCompareFn + ) + ) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} schedule periods are not sorted by start period` + ) + + chargingSchedule.chargingSchedulePeriod.sort(chargingSchedulePeriodCompareFn) + } + // Check if the first schedule period startPeriod property is equal to 0 + + if (chargingSchedule.chargingSchedulePeriod[0].startPeriod !== 0) { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profile id ${chargingProfileId} first schedule period start period ${chargingSchedule.chargingSchedulePeriod[0].startPeriod.toString()} is not equal to 0` + ) + continue + } + // Handle only one schedule period + + if (chargingSchedule.chargingSchedulePeriod.length === 1) { + const chargingProfilesLimit: ChargingProfilesLimit = { + chargingProfile, + + limit: chargingSchedule.chargingSchedulePeriod[0].limit, + } + logger.debug(debugLogMsg, chargingProfilesLimit) + return chargingProfilesLimit + } + let previousChargingSchedulePeriod: ChargingSchedulePeriod | undefined + // Search for the right schedule period + for (const [ + index, + chargingSchedulePeriod, + ] of chargingSchedule.chargingSchedulePeriod.entries()) { + // Find the right schedule period + if ( + isAfter( + addSeconds(chargingSchedule.startSchedule, chargingSchedulePeriod.startPeriod), + currentDate + ) + ) { + // Found the schedule period: previous is the correct one + const chargingProfilesLimit: ChargingProfilesLimit = { + chargingProfile: previousActiveChargingProfile ?? chargingProfile, + + limit: previousChargingSchedulePeriod?.limit ?? chargingSchedulePeriod.limit, + } + logger.debug(debugLogMsg, chargingProfilesLimit) + return chargingProfilesLimit + } + // Handle the last schedule period within the charging profile duration + if ( + index === chargingSchedule.chargingSchedulePeriod.length - 1 || + (index < chargingSchedule.chargingSchedulePeriod.length - 1 && + differenceInSeconds( + addSeconds( + chargingSchedule.startSchedule, + + chargingSchedule.chargingSchedulePeriod[index + 1].startPeriod + ), + + chargingSchedule.startSchedule + ) > chargingSchedule.duration) + ) { + const chargingProfilesLimit: ChargingProfilesLimit = { + chargingProfile, + + limit: chargingSchedulePeriod.limit, + } + logger.debug(debugLogMsg, chargingProfilesLimit) + return chargingProfilesLimit + } + // Keep a reference to previous charging schedule period + + previousChargingSchedulePeriod = chargingSchedulePeriod + } + } + // Keep a reference to previous active charging profile + previousActiveChargingProfile = chargingProfile + } + } +} + +/** + * Materializes a charging profile's `chargingProfileKind` for the given + * connector when the kind is `Recurring` or `Relative`, promoting the + * profile's `startSchedule` to an absolute date derived from the current + * moment (relative) or the recurrence period (recurring). + * @param connectorStatus - Target connector status; unused for absolute-kind profiles. + * @param chargingProfile - Profile to prepare (mutated when kind is `Recurring` or `Relative`). + * @param currentDate - Reference clock reading. + * @param logPrefix - Log prefix for the warn/error paths. + * @returns `true` when the profile is usable after preparation, `false` otherwise. + */ +export const prepareChargingProfileKind = ( + connectorStatus: ConnectorStatus | undefined, + chargingProfile: ChargingProfile, + currentDate: Date | number | string, + logPrefix: string +): boolean => { + const chargingProfileId = getChargingProfileId(chargingProfile) + const chargingSchedule = getSingleChargingSchedule( + chargingProfile, + logPrefix, + 'prepareChargingProfileKind' + ) + if (chargingSchedule == null) { + return false + } + switch (chargingProfile.chargingProfileKind) { + case ChargingProfileKindType.RECURRING: + if (!canProceedRecurringChargingProfile(chargingProfile, logPrefix)) { + return false + } + prepareRecurringChargingProfile(chargingProfile, currentDate, logPrefix) + break + case ChargingProfileKindType.RELATIVE: + if (chargingSchedule.startSchedule != null) { + logger.warn( + `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} has a startSchedule property defined. It will be ignored or used if the connector has a transaction started` + ) + delete chargingSchedule.startSchedule + } + if (connectorStatus?.transactionStarted !== true) { + logger.debug( + `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} has no active transaction, cannot be evaluated` + ) + return false + } + chargingSchedule.startSchedule = connectorStatus.transactionStart + if (chargingSchedule.startSchedule == null) { + logger.warn( + `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} has active transaction without start date` + ) + return false + } + if (chargingSchedule.duration != null) { + const elapsedSeconds = differenceInSeconds(currentDate, chargingSchedule.startSchedule) + if (elapsedSeconds > chargingSchedule.duration) { + logger.debug( + `${logPrefix} ${moduleName}.prepareChargingProfileKind: Relative charging profile id ${chargingProfileId} duration ${chargingSchedule.duration.toString()}s exceeded (elapsed: ${elapsedSeconds.toString()}s)` + ) + return false + } + } + break + } + return true +} + +/** + * Predicate deciding whether a charging profile is currently active + * (within its validity window and recurrence rules). + * @param chargingProfile - Profile to evaluate. + * @param currentDate - Reference clock reading. + * @param logPrefix - Log prefix for the warn paths. + * @returns `true` when the profile is currently applicable, `false` otherwise. + */ +export const canProceedChargingProfile = ( + chargingProfile: ChargingProfile, + currentDate: Date | number | string, + logPrefix: string +): boolean => { + const chargingProfileId = getChargingProfileId(chargingProfile) + const chargingSchedule = getSingleChargingSchedule( + chargingProfile, + logPrefix, + 'canProceedChargingProfile' + ) + if (chargingSchedule == null) { + return false + } + if ( + (isValidDate(chargingProfile.validFrom) && isBefore(currentDate, chargingProfile.validFrom)) || + (isValidDate(chargingProfile.validTo) && isAfter(currentDate, chargingProfile.validTo)) + ) { + logger.debug( + `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} is not valid for the current date ${ + isDate(currentDate) ? currentDate.toISOString() : currentDate.toString() + }` + ) + return false + } + if (chargingSchedule.startSchedule == null || chargingSchedule.duration == null) { + logger.error( + `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has no startSchedule or duration defined` + ) + return false + } + + if (!isValidDate(chargingSchedule.startSchedule)) { + logger.error( + `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has an invalid startSchedule date defined` + ) + return false + } + if (!Number.isSafeInteger(chargingSchedule.duration)) { + logger.error( + `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfileId} has non integer duration defined` + ) + return false + } + return true +} + +const canProceedRecurringChargingProfile = ( + chargingProfile: ChargingProfile, + logPrefix: string +): boolean => { + const chargingProfileId = getChargingProfileId(chargingProfile) + const chargingSchedule = getSingleChargingSchedule( + chargingProfile, + logPrefix, + 'canProceedRecurringChargingProfile' + ) + if (chargingSchedule == null) { + return false + } + if ( + chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING && + chargingProfile.recurrencyKind == null + ) { + logger.error( + `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfileId} has no recurrencyKind defined` + ) + return false + } + if ( + chargingProfile.chargingProfileKind === ChargingProfileKindType.RECURRING && + chargingSchedule.startSchedule == null + ) { + logger.error( + `${logPrefix} ${moduleName}.canProceedRecurringChargingProfile: Recurring charging profile id ${chargingProfileId} has no startSchedule defined` + ) + return false + } + return true +} + +/** + * Adjust recurring charging profile startSchedule to the current recurrency time interval if needed + * @param chargingProfile - The charging profile to adjust + * @param currentDate - The current date/time + * @param logPrefix - Prefix for logging messages + * @returns Whether the charging profile is active at the given date + */ +const prepareRecurringChargingProfile = ( + chargingProfile: ChargingProfile, + currentDate: Date | number | string, + logPrefix: string +): boolean => { + const chargingProfileId = getChargingProfileId(chargingProfile) + const chargingSchedule = getSingleChargingSchedule( + chargingProfile, + logPrefix, + 'prepareRecurringChargingProfile' + ) + if (chargingSchedule == null) { + return false + } + let recurringIntervalTranslated = false + let recurringInterval: Interval | undefined + switch (chargingProfile.recurrencyKind) { + case RecurrencyKindType.DAILY: { + const startSchedule = chargingSchedule.startSchedule ?? new Date() + recurringInterval = { + end: addDays(startSchedule, 1), + start: startSchedule, + } + checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix) + if ( + !isWithinInterval(currentDate, recurringInterval) && + isBefore(recurringInterval.end, currentDate) + ) { + chargingSchedule.startSchedule = addDays( + recurringInterval.start, + differenceInDays(currentDate, recurringInterval.start) + ) + recurringInterval = { + end: addDays(chargingSchedule.startSchedule, 1), + + start: chargingSchedule.startSchedule, + } + recurringIntervalTranslated = true + } + break + } + case RecurrencyKindType.WEEKLY: { + const startSchedule = chargingSchedule.startSchedule ?? new Date() + recurringInterval = { + end: addWeeks(startSchedule, 1), + start: startSchedule, + } + checkRecurringChargingProfileDuration(chargingProfile, recurringInterval, logPrefix) + if ( + !isWithinInterval(currentDate, recurringInterval) && + isBefore(recurringInterval.end, currentDate) + ) { + chargingSchedule.startSchedule = addWeeks( + recurringInterval.start, + differenceInWeeks(currentDate, recurringInterval.start) + ) + recurringInterval = { + end: addWeeks(chargingSchedule.startSchedule, 1), + + start: chargingSchedule.startSchedule, + } + recurringIntervalTranslated = true + } + break + } + default: + logger.error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${chargingProfile.recurrencyKind} charging profile id ${chargingProfileId} is not supported` + ) + } + if ( + recurringIntervalTranslated && + recurringInterval != null && + !isWithinInterval(currentDate, recurringInterval) + ) { + logger.error( + `${logPrefix} ${moduleName}.prepareRecurringChargingProfile: Recurring ${ + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + chargingProfile.recurrencyKind + } charging profile id ${chargingProfileId} recurrency time interval [${toDate( + recurringInterval.start as Date + ).toISOString()}, ${toDate( + recurringInterval.end as Date + ).toISOString()}] has not been properly translated to current date ${ + isDate(currentDate) ? currentDate.toISOString() : currentDate.toString() + } ` + ) + } + return recurringIntervalTranslated +} + +const checkRecurringChargingProfileDuration = ( + chargingProfile: ChargingProfile, + interval: Interval, + logPrefix: string +): void => { + const chargingProfileId = getChargingProfileId(chargingProfile) + const chargingSchedule = getSingleChargingSchedule( + chargingProfile, + logPrefix, + 'checkRecurringChargingProfileDuration' + ) + if (chargingSchedule == null) { + return + } + if (chargingSchedule.duration == null) { + logger.warn( + `${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${ + chargingProfile.chargingProfileKind + } charging profile id ${chargingProfileId} duration is not defined, set it to the recurrency time interval duration ${differenceInSeconds( + interval.end, + interval.start + ).toString()}` + ) + chargingSchedule.duration = differenceInSeconds(interval.end, interval.start) + } else if (chargingSchedule.duration > differenceInSeconds(interval.end, interval.start)) { + logger.warn( + `${logPrefix} ${moduleName}.checkRecurringChargingProfileDuration: Recurring ${ + chargingProfile.chargingProfileKind + } charging profile id ${chargingProfileId} duration ${chargingSchedule.duration.toString()} is greater than the recurrency time interval duration ${differenceInSeconds( + interval.end, + interval.start + ).toString()}` + ) + chargingSchedule.duration = differenceInSeconds(interval.end, interval.start) + } +} diff --git a/src/charging-station/HelpersConfig.ts b/src/charging-station/HelpersConfig.ts new file mode 100644 index 00000000..409422a2 --- /dev/null +++ b/src/charging-station/HelpersConfig.ts @@ -0,0 +1,374 @@ +// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Configuration and template helpers. + * @description Charging-station configuration validation and template + * normalization: template-file basename derivation, station-info + * invariants, template-to-info projection, per-connector default + * maximum power derivation, configuration-file presence checks, + * station-options overlay, amperage unit divider, default voltage + * selection, and asset-path resolution for `idTagsFile` / + * `evProfilesFile`. Also exports the pure template-topology helpers + * `getMaxNumberOfEvses` / `getMaxNumberOfConnectors` since they are + * consumed by `getDefaultConnectorMaximumPower` and belong to the same + * configuration/template concern (kept in this file to avoid a + * cross-module dependency back into `./Helpers.js`). Re-exported from + * `./Helpers.js` so callers keep the barrel import path + * (`import { validateStationInfo, ... } from './Helpers.js'`). + */ + +import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import type { ChargingStation } from './ChargingStation.js' + +import { BaseError } from '../exception/index.js' +import { + AmpereUnits, + type ChargingStationConfiguration, + type ChargingStationInfo, + type ChargingStationOptions, + type ChargingStationTemplate, + type ConnectorStatus, + CurrentType, + type EvseTemplate, + OCPPVersion, + PowerUnits, + Voltage, +} from '../types/index.js' +import { clone, isEmpty, isNotEmptyArray, logger, secureRandom } from '../utils/index.js' + +const moduleName = 'HelpersConfig' + +/** + * Derives the template basename (path without extension) relative to the + * `assets/station-templates` directory. Absolute paths are converted to + * paths relative to that directory first. + * @param templateFile - Template file path (absolute or relative). + * @returns Template basename without extension. + */ +export const buildTemplateName = (templateFile: string): string => { + if (isAbsolute(templateFile)) { + templateFile = relative( + resolve(join(dirname(fileURLToPath(import.meta.url)), 'assets', 'station-templates')), + templateFile + ) + } + const templateFileParsedPath = parse(templateFile) + return join(templateFileParsedPath.dir, templateFileParsedPath.name) +} + +/** + * Validates the charging-station-info invariants required at startup: + * `chargingStationId`, `hashId`, `templateIndex`, `templateName`, + * `maximumPower`, `maximumAmperage`, plus an OCPP-2.0.x-only rule that + * at least one EVSE be defined. + * @param chargingStation - The charging station whose `stationInfo` should be checked. + * @throws {BaseError} When any invariant is missing. + * @throws {RangeError} When `maximumPower` or `maximumAmperage` is `<= 0`. + */ +export const validateStationInfo = (chargingStation: ChargingStation): void => { + if (chargingStation.stationInfo == null || isEmpty(chargingStation.stationInfo)) { + throw new BaseError('Missing charging station information') + } + if ( + chargingStation.stationInfo.chargingStationId == null || + isEmpty(chargingStation.stationInfo.chargingStationId) + ) { + throw new BaseError('Missing chargingStationId in stationInfo properties') + } + const chargingStationId = chargingStation.stationInfo.chargingStationId + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + chargingStation.stationInfo.hashId == null || + isEmpty(chargingStation.stationInfo.hashId) + ) { + throw new BaseError(`${chargingStationId}: Missing hashId in stationInfo properties`) + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (chargingStation.stationInfo.templateIndex == null) { + throw new BaseError(`${chargingStationId}: Missing templateIndex in stationInfo properties`) + } + if (chargingStation.stationInfo.templateIndex <= 0) { + throw new BaseError( + `${chargingStationId}: Invalid templateIndex value in stationInfo properties` + ) + } + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + chargingStation.stationInfo.templateName == null || + isEmpty(chargingStation.stationInfo.templateName) + ) { + throw new BaseError(`${chargingStationId}: Missing templateName in stationInfo properties`) + } + if (chargingStation.stationInfo.maximumPower == null) { + throw new BaseError(`${chargingStationId}: Missing maximumPower in stationInfo properties`) + } + if (chargingStation.stationInfo.maximumPower <= 0) { + throw new RangeError( + `${chargingStationId}: Invalid maximumPower value in stationInfo properties` + ) + } + if (chargingStation.stationInfo.maximumAmperage == null) { + throw new BaseError(`${chargingStationId}: Missing maximumAmperage in stationInfo properties`) + } + if (chargingStation.stationInfo.maximumAmperage <= 0) { + throw new RangeError( + `${chargingStationId}: Invalid maximumAmperage value in stationInfo properties` + ) + } + switch (chargingStation.stationInfo.ocppVersion) { + case OCPPVersion.VERSION_20: + case OCPPVersion.VERSION_201: + if (chargingStation.getNumberOfEvses() === 0) { + throw new BaseError( + `${chargingStationId}: OCPP ${chargingStation.stationInfo.ocppVersion} requires at least one EVSE defined in the charging station template/configuration` + ) + } + } +} + +/** + * Returns the number of EVSEs defined in a template `Evses` record. + * @param evses - `Evses` template block. + * @returns Count; `-1` when the block is `null`/`undefined`, `0` when empty. + */ +export const getMaxNumberOfEvses = (evses: Record | undefined): number => { + if (evses == null) { + return -1 + } + return isEmpty(evses) ? 0 : Object.keys(evses).length +} + +/** + * Returns the number of connectors defined in a template `Connectors` record. + * @param connectors - `Connectors` template block. + * @returns Count; `-1` when the block is `null`/`undefined`, `0` when empty. + */ +export const getMaxNumberOfConnectors = ( + connectors: Record | undefined +): number => { + if (connectors == null) { + return -1 + } + return isEmpty(connectors) ? 0 : Object.keys(connectors).length +} + +/** + * Derives the default per-connector maximum power from `stationTemplate.power`. + * When `powerSharedByConnectors` is `true`, the full station power is + * returned; otherwise the value is divided by the static connector count + * (excluding connector `0`). + * @param stationTemplate - Source charging-station template. + * @returns Per-connector maximum power in W, or `undefined` when `power` is not set or the divisor is `<= 0`. + */ +export const getDefaultConnectorMaximumPower = ( + stationTemplate: ChargingStationTemplate +): number | undefined => { + let maximumPower: number | undefined + if (isNotEmptyArray(stationTemplate.power)) { + const powerArrayRandomIndex = Math.floor(secureRandom() * stationTemplate.power.length) + maximumPower = + stationTemplate.powerUnit === PowerUnits.KILO_WATT + ? stationTemplate.power[powerArrayRandomIndex] * 1000 + : stationTemplate.power[powerArrayRandomIndex] + } else if (typeof stationTemplate.power === 'number') { + maximumPower = + stationTemplate.powerUnit === PowerUnits.KILO_WATT + ? stationTemplate.power * 1000 + : stationTemplate.power + } + if (maximumPower == null) { + return undefined + } + if (stationTemplate.powerSharedByConnectors === true) { + return maximumPower + } + const staticCount = + stationTemplate.Evses != null + ? getMaxNumberOfEvses(stationTemplate.Evses) - + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (stationTemplate.Evses['0'] != null ? 1 : 0) + : getMaxNumberOfConnectors(stationTemplate.Connectors) - + (stationTemplate.Connectors?.['0'] != null ? 1 : 0) + return staticCount > 0 ? maximumPower / staticCount : undefined +} + +/** + * Verifies the on-disk configuration file was parsed into a non-empty object. + * @param stationConfiguration - Parsed configuration (or `undefined` on read failure). + * @param logPrefix - Log prefix. + * @param configurationFile - Path to the configuration file (used in error messages). + * @throws {BaseError} When the configuration is `null`/`undefined` or empty. + */ +export const checkConfiguration = ( + stationConfiguration: ChargingStationConfiguration | undefined, + logPrefix: string, + configurationFile: string +): void => { + if (stationConfiguration == null) { + const errorMsg = `Failed to read charging station configuration file ${configurationFile}` + logger.error(`${logPrefix} ${moduleName}.checkConfiguration: ${errorMsg}`) + throw new BaseError(errorMsg) + } + if (isEmpty(stationConfiguration)) { + const errorMsg = `Empty charging station configuration from file ${configurationFile}` + logger.error(`${logPrefix} ${moduleName}.checkConfiguration: ${errorMsg}`) + throw new BaseError(errorMsg) + } +} + +/** + * Applies user-supplied station options over the template-derived station-info. + * Each option is copied only when non-null; `persistentConfiguration` fans + * out across all three per-domain persistence flags. + * @param stationInfo - Station-info to mutate. + * @param options - User-supplied overrides. + * @returns The same `stationInfo` reference for chaining. + */ +export const setChargingStationOptions = ( + stationInfo: ChargingStationInfo, + options?: ChargingStationOptions +): ChargingStationInfo => { + if (options?.supervisionUrls != null) { + stationInfo.supervisionUrls = options.supervisionUrls + } + if (options?.supervisionUser != null) { + stationInfo.supervisionUser = options.supervisionUser + } + if (options?.supervisionPassword != null) { + stationInfo.supervisionPassword = options.supervisionPassword + } + if (options?.persistentConfiguration != null) { + stationInfo.stationInfoPersistentConfiguration = options.persistentConfiguration + stationInfo.ocppPersistentConfiguration = options.persistentConfiguration + stationInfo.automaticTransactionGeneratorPersistentConfiguration = + options.persistentConfiguration + } + if (options?.autoStart != null) { + stationInfo.autoStart = options.autoStart + } + if (options?.autoRegister != null) { + stationInfo.autoRegister = options.autoRegister + } + if (options?.enableStatistics != null) { + stationInfo.enableStatistics = options.enableStatistics + } + if (options?.ocppStrictCompliance != null) { + stationInfo.ocppStrictCompliance = options.ocppStrictCompliance + } + if (options?.stopTransactionsOnStopped != null) { + stationInfo.stopTransactionsOnStopped = options.stopTransactionsOnStopped + } + if (options?.baseName != null) { + stationInfo.baseName = options.baseName + } + if (options?.fixedName != null) { + stationInfo.fixedName = options.fixedName + } + if (options?.nameSuffix != null) { + stationInfo.nameSuffix = options.nameSuffix + } + return stationInfo +} + +/** + * Projects a charging-station template into a station-info by dropping the + * template-only fields (`power`, `powerUnit`, connector/evse maps, the + * `Configuration` and `AutomaticTransactionGenerator` sections, and the + * serial-number prefixes). + * @param stationTemplate - Source template. + * @returns Station-info clone with template-only fields removed. + */ +export const stationTemplateToStationInfo = ( + stationTemplate: ChargingStationTemplate +): ChargingStationInfo => { + stationTemplate = clone(stationTemplate) + delete stationTemplate.power + delete stationTemplate.powerUnit + delete stationTemplate.Connectors + delete stationTemplate.Evses + delete stationTemplate.Configuration + delete stationTemplate.AutomaticTransactionGenerator + delete stationTemplate.numberOfConnectors + delete stationTemplate.chargeBoxSerialNumberPrefix + delete stationTemplate.chargePointSerialNumberPrefix + delete stationTemplate.meterSerialNumberPrefix + return stationTemplate as ChargingStationInfo +} + +/** + * Returns the divisor mapping the station-configured amperage unit onto + * amperes: 1 for `A`, 10 for `dA`, 100 for `cA`, 1000 for `mA`. + * @param stationInfo - Station-info carrying `amperageLimitationUnit`. + * @returns Divisor to apply to the raw OCPP-configured amperage limit. + */ +export const getAmperageLimitationUnitDivider = (stationInfo: ChargingStationInfo): number => { + let unitDivider = 1 + switch (stationInfo.amperageLimitationUnit) { + case AmpereUnits.CENTI_AMPERE: + unitDivider = 100 + break + case AmpereUnits.DECI_AMPERE: + unitDivider = 10 + break + case AmpereUnits.MILLI_AMPERE: + unitDivider = 1000 + break + } + return unitDivider +} + +/** + * Returns the default output voltage for the given current type: + * 230 V for AC, 400 V for DC. + * @param currentType - Current type from the template. + * @param logPrefix - Log prefix. + * @param templateFile - Path to the template file (used in error messages). + * @throws {BaseError} When `currentType` is neither AC nor DC. + * @returns Default voltage. + */ +export const getDefaultVoltageOut = ( + currentType: CurrentType, + logPrefix: string, + templateFile: string +): Voltage => { + const errorMsg = `Unknown ${currentType} currentOutType in template file ${templateFile}, cannot define default voltage out` + let defaultVoltageOut: number + switch (currentType) { + case CurrentType.AC: + defaultVoltageOut = Voltage.VOLTAGE_230 + break + case CurrentType.DC: + defaultVoltageOut = Voltage.VOLTAGE_400 + break + default: + logger.error(`${logPrefix} ${moduleName}.getDefaultVoltageOut: ${errorMsg}`) + throw new BaseError(errorMsg) + } + return defaultVoltageOut +} + +/** + * Resolves the on-disk path to the RFID id-tags file referenced by + * `stationInfo.idTagsFile`, rooted at the bundled `assets` directory. + * @param stationInfo - Station-info carrying `idTagsFile`. + * @returns Absolute path, or `undefined` when `idTagsFile` is not set. + */ +export const getIdTagsFile = (stationInfo: ChargingStationInfo): string | undefined => { + return stationInfo.idTagsFile != null + ? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.idTagsFile)) + : undefined +} + +/** + * Resolves the on-disk path to the EV profiles file referenced by + * `stationInfo.evProfilesFile`, rooted at the bundled `assets` directory. + * @param stationInfo - Station-info carrying `evProfilesFile`. + * @returns Absolute path, or `undefined` when `evProfilesFile` is not set. + */ +export const getEvProfilesFile = (stationInfo: ChargingStationInfo): string | undefined => { + return stationInfo.evProfilesFile != null + ? join(dirname(fileURLToPath(import.meta.url)), 'assets', basename(stationInfo.evProfilesFile)) + : undefined +} diff --git a/src/charging-station/HelpersConnectorStatus.ts b/src/charging-station/HelpersConnectorStatus.ts new file mode 100644 index 00000000..08b829c9 --- /dev/null +++ b/src/charging-station/HelpersConnectorStatus.ts @@ -0,0 +1,264 @@ +// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Connector-status helpers. + * @description Connector-status lifecycle helpers: boot-time status + * resolution, connectors-map construction, connectors-map + * initialization, per-connector reset, authorize-state reset, and + * post-load rehydration. Re-exported from `./Helpers.js` so callers + * keep the barrel import path + * (`import { buildConnectorsMap, ... } from './Helpers.js'`). + */ + +import type { ChargingStation } from './ChargingStation.js' + +import { + AvailabilityType, + ChargingProfilePurposeType, + type ConnectorStatus, + ConnectorStatusEnum, +} from '../types/index.js' +import { clone, convertToDate, convertToInt, isNotEmptyArray, logger } from '../utils/index.js' +import { getSingleChargingSchedule } from './HelpersChargingProfile.js' +import { getMaxNumberOfConnectors } from './HelpersConfig.js' + +const moduleName = 'HelpersConnectorStatus' + +/** + * Boot-time connector status derivation. + * - When the station is unavailable OR the specific connector is unavailable, returns `ConnectorStatusEnum.Unavailable`. + * - Otherwise, when a transaction was running with a persisted `status`, returns that status so mid-transaction state survives a restart. + * - Otherwise, when a `bootStatus` is configured on the connector, returns it. + * - Otherwise, returns `ConnectorStatusEnum.Available` so a fresh connector boots ready to accept a session. + * @param chargingStation - Owning charging station. + * @param connectorId - Target connector id. + * @param connectorStatus - Persisted connector status. + * @returns Boot-time {@link ConnectorStatusEnum}. + */ +export const getBootConnectorStatus = ( + chargingStation: ChargingStation, + connectorId: number, + connectorStatus: ConnectorStatus +): ConnectorStatusEnum => { + if ( + !chargingStation.isChargingStationAvailable() || + !chargingStation.isConnectorAvailable(connectorId) + ) { + return ConnectorStatusEnum.Unavailable + } + if (connectorStatus.transactionStarted === true && connectorStatus.status != null) { + return connectorStatus.status + } + if (connectorStatus.bootStatus != null) { + return connectorStatus.bootStatus + } + return ConnectorStatusEnum.Available +} + +/** + * Warn-and-strip pass on template-supplied connector status: a `status` + * field on a template connector is ambiguous (should the station boot + * into that status or observe it live?), so the field is logged and + * removed before the connector is materialized. + * @param connectorId - Connector id (for the warning message). + * @param connectorStatus - Template-derived connector status to normalize in place. + * @param logPrefix - Log prefix. + * @param templateFile - Template file path (for the warning message). + */ +export const checkStationInfoConnectorStatus = ( + connectorId: number, + connectorStatus: ConnectorStatus, + logPrefix: string, + templateFile: string +): void => { + if (connectorStatus.status != null) { + logger.warn( + `${logPrefix} ${moduleName}.checkStationInfoConnectorStatus: Charging station information from template ${templateFile} with connector id ${connectorId.toString()} status configuration defined, removing it` + ) + delete connectorStatus.status + } +} + +/** + * Materializes a `Record` template block into a + * numeric-keyed `Map`. Each entry is cloned so runtime mutations do not + * leak back into the template, and each connector is normalized via + * {@link checkStationInfoConnectorStatus} before insertion. + * @param connectors - Template `Connectors` record. + * @param logPrefix - Log prefix. + * @param templateFile - Template file path (for the warning message). + * @returns Materialized connectors map keyed by numeric connector id. + */ +export const buildConnectorsMap = ( + connectors: Record, + logPrefix: string, + templateFile: string +): Map => { + const connectorsMap = new Map() + if (getMaxNumberOfConnectors(connectors) > 0) { + for (const [connectorKey, connectorStatus] of Object.entries(connectors)) { + const connectorId = convertToInt(connectorKey) + checkStationInfoConnectorStatus(connectorId, connectorStatus, logPrefix, templateFile) + connectorsMap.set(connectorId, clone(connectorStatus)) + } + } else { + logger.warn( + `${logPrefix} ${moduleName}.buildConnectorsMap: Charging station information from template ${templateFile} with no connectors, cannot build connectors map` + ) + } + return connectorsMap +} + +/** + * Post-materialization pass over the connectors map. + * - Connector 0 (station scope) is normalized: `availability` set to `Operative` and `chargingProfiles` defaulted to `[]` when unset. + * - Connector id `> 0` with `transactionStarted === true` and no live `transactionId` (or in `Finishing`): the stale transaction is dropped via the module-private `resetConnectorStatus`, and `locked` is cleared. A warning is logged. + * - Connector id `> 0` with `transactionStarted === true` and a live `transactionId`: state is preserved and only a warning is logged. + * - Connector id `> 0` with `transactionStarted` unset: the connector is fully initialized via the module-private `initializeConnectorStatus`. + * @param connectors - Materialized connectors map (mutated in place). + * @param logPrefix - Log prefix for the stale-transaction and live-transaction warnings. + * @param defaultMaximumPower - Optional default per-connector maximum power forwarded to the connector initializer. + */ +export const initializeConnectorsMapStatus = ( + connectors: Map, + logPrefix: string, + defaultMaximumPower?: number +): void => { + for (const [connectorId, connectorStatus] of connectors) { + if (connectorId > 0 && connectorStatus.transactionStarted === true) { + if ( + connectorStatus.transactionId == null || + connectorStatus.status === ConnectorStatusEnum.Finishing + ) { + resetConnectorStatus(connectorStatus) + connectorStatus.locked = false + logger.warn( + `${logPrefix} ${moduleName}.initializeConnectorsMapStatus: Connector id ${connectorId.toString()} at initialization has stale transaction state, resetting` + ) + } else { + logger.warn( + `${logPrefix} ${moduleName}.initializeConnectorsMapStatus: Connector id ${connectorId.toString()} at initialization has a transaction started with id ${connectorStatus.transactionId.toString()}` + ) + } + } + if (connectorId === 0) { + connectorStatus.availability = AvailabilityType.Operative + connectorStatus.chargingProfiles ??= [] + } else if (connectorId > 0 && connectorStatus.transactionStarted == null) { + initializeConnectorStatus(connectorStatus, defaultMaximumPower) + } + } +} + +/** + * Clears the connector's authorization state (both local and remote) + * and drops any pending id-tag associations. Used after a transaction + * completes or when authorization is revoked mid-session. + * @param connectorStatus - Target connector status to reset in place. + */ +export const resetAuthorizeConnectorStatus = (connectorStatus: ConnectorStatus): void => { + connectorStatus.idTagLocalAuthorized = false + connectorStatus.idTagAuthorized = false + delete connectorStatus.localAuthorizeIdTag + delete connectorStatus.authorizeIdTag +} + +/** + * Full connector reset: drops the transaction bookkeeping and the + * transaction-scoped energy counter, filters out non-station-scope + * charging profiles, and clears authorization + reservation state. The + * station-scoped `energyActiveImportRegisterValue` is deliberately + * preserved (persistent energy register per OCPP), and `availability` is + * untouched. Safe to call on a `null` / `undefined` connector (no-op). + * @param connectorStatus - Target connector status to reset in place, or `null` / `undefined` for a no-op. + */ +export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefined): void => { + if (connectorStatus == null) { + return + } + if (isNotEmptyArray(connectorStatus.chargingProfiles)) { + connectorStatus.chargingProfiles = connectorStatus.chargingProfiles.filter( + chargingProfile => + (chargingProfile.chargingProfilePurpose === ChargingProfilePurposeType.TX_PROFILE && + chargingProfile.transactionId != null && + connectorStatus.transactionId != null && + chargingProfile.transactionId !== connectorStatus.transactionId) || + chargingProfile.chargingProfilePurpose !== ChargingProfilePurposeType.TX_PROFILE + ) + } + resetAuthorizeConnectorStatus(connectorStatus) + connectorStatus.transactionPending = false + connectorStatus.transactionRemoteStarted = false + connectorStatus.transactionStarted = false + delete connectorStatus.transactionStart + delete connectorStatus.transactionId + delete connectorStatus.transactionIdTag + delete connectorStatus.transactionGroupIdToken + connectorStatus.transactionEnergyActiveImportRegisterValue = 0 + delete connectorStatus.transactionBeginMeterValue + delete connectorStatus.transactionEndedMeterValues + if (connectorStatus.transactionEndedMeterValuesSetInterval != null) { + clearInterval(connectorStatus.transactionEndedMeterValuesSetInterval) + delete connectorStatus.transactionEndedMeterValuesSetInterval + } + delete connectorStatus.transactionSeqNo + delete connectorStatus.publicKeySentInTransaction + delete connectorStatus.transactionEvseSent + delete connectorStatus.transactionIdTokenSent + delete connectorStatus.transactionDeauthorized + delete connectorStatus.transactionDeauthorizedEnergyWh +} + +/** + * Post-load rehydration hook: coerces the persisted reservation + * `expiryDate` back into a `Date` instance (or drops the reservation + * when the value cannot be parsed), and returns the same reference so + * callers can chain. + * @param connectorStatus - Target connector status to rehydrate in place. + * @returns The same `connectorStatus` reference, after rehydration. + */ +export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): ConnectorStatus => { + if (connectorStatus.reservation != null) { + const reservationExpiryDate = convertToDate(connectorStatus.reservation.expiryDate) + if (reservationExpiryDate != null) { + connectorStatus.reservation.expiryDate = reservationExpiryDate + } else { + delete connectorStatus.reservation + } + } + if (isNotEmptyArray(connectorStatus.chargingProfiles)) { + connectorStatus.chargingProfiles = connectorStatus.chargingProfiles + .filter( + chargingProfile => + chargingProfile.chargingProfilePurpose !== ChargingProfilePurposeType.TX_PROFILE + ) + .map(chargingProfile => { + const chargingSchedule = getSingleChargingSchedule(chargingProfile) + if (chargingSchedule != null) { + chargingSchedule.startSchedule = + convertToDate(chargingSchedule.startSchedule) ?? new Date() + } + chargingProfile.validFrom = convertToDate(chargingProfile.validFrom) + chargingProfile.validTo = convertToDate(chargingProfile.validTo) + return chargingProfile + }) + } + return connectorStatus +} + +const initializeConnectorStatus = ( + connectorStatus: ConnectorStatus, + defaultMaximumPower?: number +): void => { + connectorStatus.availability = AvailabilityType.Operative + connectorStatus.idTagLocalAuthorized = false + connectorStatus.idTagAuthorized = false + connectorStatus.transactionRemoteStarted = false + connectorStatus.transactionStarted = false + connectorStatus.energyActiveImportRegisterValue = 0 + connectorStatus.transactionEnergyActiveImportRegisterValue = 0 + connectorStatus.chargingProfiles ??= [] + if (defaultMaximumPower != null) { + connectorStatus.maximumPower ??= defaultMaximumPower + } +} diff --git a/src/charging-station/HelpersId.ts b/src/charging-station/HelpersId.ts new file mode 100644 index 00000000..db5de33d --- /dev/null +++ b/src/charging-station/HelpersId.ts @@ -0,0 +1,189 @@ +// Partial Copyright Jerome Benoit. 2021-2025. All Rights Reserved. + +/** + * @file Serial-number / identity helpers. + * @description Charging-station identity generation: composed + * `chargingStationId`, deterministic `hashId`, serial-number creation + * from template prefixes plus a random suffix, and cross-configuration + * serial-number propagation. Re-exported from `./Helpers.js` so + * callers keep the barrel import path + * (`import { getChargingStationId, getHashId, ... } from './Helpers.js'`). + * + * Note: no `moduleName` constant is declared here because every helper + * in this file is pure (no `logger.*` calls); adding an unused constant + * would violate the codebase's no-dead-code convention. Sibling files + * that log their own error paths (e.g. `HelpersConfig.ts`) declare it. + */ + +import { hash, randomBytes } from 'node:crypto' +import { env } from 'node:process' + +import type { ChargingStationInfo, ChargingStationTemplate } from '../types/index.js' + +import { BaseError } from '../exception/index.js' +import { Constants, isNotEmptyString } from '../utils/index.js' + +/** + * Structural subset of {@link ChargingStationTemplate} carrying only the + * three fields that shape the composed `chargingStationId`. Kept as a + * separate type so callers passing user-supplied overrides do not have + * to satisfy the full template shape. Wrapped in `Readonly` to make + * `getChargingStationId`'s pure-read contract explicit at the type + * level. + */ +export type ChargingStationNameTemplate = Readonly< + Pick +> + +/** + * Composes the runtime `chargingStationId` from a template's name fields. + * When `fixedName` is `true` the `baseName` is returned unchanged; otherwise + * the id is `-<4-digit padded index>`. + * @param index - Zero-based instance index within the template. + * @param nameTemplate - Template subset carrying `baseName` / `fixedName` / `nameSuffix`; when `null` / `undefined` the sentinel `"Unknown 'chargingStationId'"` is returned. + * @returns Composed charging-station id, or the sentinel when `nameTemplate` is missing. + */ +export const getChargingStationId = ( + index: number, + nameTemplate: ChargingStationNameTemplate | undefined +): string => { + if (nameTemplate == null) { + return "Unknown 'chargingStationId'" + } + // In case of multiple instances: add instance index to charging station id + const instanceIndex = env.CF_INSTANCE_INDEX ?? 0 + const idSuffix = nameTemplate.nameSuffix ?? '' + const idStr = `000000000${index.toString()}` + return nameTemplate.fixedName === true + ? nameTemplate.baseName + : `${nameTemplate.baseName}-${instanceIndex.toString()}${idStr.substring( + idStr.length - 4 + )}${idSuffix}` +} + +/** + * Deterministic content hash keying a station's persisted configuration + * on the template shape. The hash covers vendor/model + serial-number + * prefixes + meter type + the composed `chargingStationId`, so a + * template change that alters any of these fields yields a distinct + * `hashId` and therefore a distinct on-disk configuration file. + * @param index - Zero-based instance index within the template. + * @param stationTemplate - Template carrying the identity-relevant fields. + * @param chargingStationIdOverride - Optional override for the composed id (used when a runtime rename must be reflected in the hash). + * @returns Hex-encoded {@link Constants.DEFAULT_HASH_ALGORITHM} digest. + */ +export const getHashId = ( + index: number, + stationTemplate: ChargingStationTemplate, + chargingStationIdOverride?: string +): string => { + const chargingStationInfo = { + chargePointModel: stationTemplate.chargePointModel, + chargePointVendor: stationTemplate.chargePointVendor, + ...(stationTemplate.chargeBoxSerialNumberPrefix != null && { + chargeBoxSerialNumber: stationTemplate.chargeBoxSerialNumberPrefix, + }), + ...(stationTemplate.chargePointSerialNumberPrefix != null && { + chargePointSerialNumber: stationTemplate.chargePointSerialNumberPrefix, + }), + ...(stationTemplate.meterSerialNumberPrefix != null && { + meterSerialNumber: stationTemplate.meterSerialNumberPrefix, + }), + ...(stationTemplate.meterType != null && { + meterType: stationTemplate.meterType, + }), + } + return hash( + Constants.DEFAULT_HASH_ALGORITHM, + `${JSON.stringify(chargingStationInfo)}${ + chargingStationIdOverride ?? getChargingStationId(index, stationTemplate) + }`, + 'hex' + ) +} + +/** + * Populates `stationInfo`'s serial-number fields from the template's + * prefixes and a shared random suffix. Only the serial-number channels + * whose prefix is declared in `stationTemplate` are set on + * `stationInfo`; the suffix defaults to a random hex string in upper + * case unless `params.randomSerialNumberUpperCase === false`, or the + * empty string when `params.randomSerialNumber === false`. + * @param stationTemplate - Template declaring which serial-number channels exist. + * @param stationInfo - Station info to mutate. + * @param params - Random-suffix knobs (defaults: enabled + upper case). + * @param params.randomSerialNumber - When `true` (default), the suffix is a random hex string; when `false`, the suffix is the empty string. + * @param params.randomSerialNumberUpperCase - When `true` (default), the random hex suffix is emitted in upper case; when `false`, lower case is used. + */ +export const createSerialNumber = ( + stationTemplate: ChargingStationTemplate, + stationInfo: ChargingStationInfo, + params?: { + randomSerialNumber?: boolean + randomSerialNumberUpperCase?: boolean + } +): void => { + params = { + ...{ randomSerialNumber: true, randomSerialNumberUpperCase: true }, + ...params, + } + const serialNumberSuffix = params.randomSerialNumber + ? getRandomSerialNumberSuffix({ + upperCase: params.randomSerialNumberUpperCase, + }) + : '' + isNotEmptyString(stationTemplate.chargePointSerialNumberPrefix) && + (stationInfo.chargePointSerialNumber = `${stationTemplate.chargePointSerialNumberPrefix}${serialNumberSuffix}`) + isNotEmptyString(stationTemplate.chargeBoxSerialNumberPrefix) && + (stationInfo.chargeBoxSerialNumber = `${stationTemplate.chargeBoxSerialNumberPrefix}${serialNumberSuffix}`) + isNotEmptyString(stationTemplate.meterSerialNumberPrefix) && + (stationInfo.meterSerialNumber = `${stationTemplate.meterSerialNumberPrefix}${serialNumberSuffix}`) +} + +/** + * Copies serial numbers from an existing station info (typically a + * pre-existing on-disk configuration) to a fresh station info derived + * from a template. Each of the three serial-number channels + * (`chargePointSerialNumber`, `chargeBoxSerialNumber`, `meterSerialNumber`) + * is copied only when the template declares the corresponding prefix + * and the source carries a value; otherwise the destination field is + * cleared. + * @param stationTemplate - Template driving which serial-number channels are declared. + * @param stationInfoSrc - Source station info carrying the existing serial numbers. + * @param stationInfoDst - Destination station info to update in place. + * @throws {BaseError} When either `stationTemplate` or `stationInfoSrc` is `null` / `undefined`. + */ +export const propagateSerialNumber = ( + stationTemplate: ChargingStationTemplate | undefined, + stationInfoSrc: ChargingStationInfo | undefined, + stationInfoDst: ChargingStationInfo +): void => { + if (stationInfoSrc == null || stationTemplate == null) { + throw new BaseError( + 'Missing charging station template or existing configuration to propagate serial number' + ) + } + stationTemplate.chargePointSerialNumberPrefix != null && + stationInfoSrc.chargePointSerialNumber != null + ? (stationInfoDst.chargePointSerialNumber = stationInfoSrc.chargePointSerialNumber) + : stationInfoDst.chargePointSerialNumber != null && + delete stationInfoDst.chargePointSerialNumber + stationTemplate.chargeBoxSerialNumberPrefix != null && + stationInfoSrc.chargeBoxSerialNumber != null + ? (stationInfoDst.chargeBoxSerialNumber = stationInfoSrc.chargeBoxSerialNumber) + : stationInfoDst.chargeBoxSerialNumber != null && delete stationInfoDst.chargeBoxSerialNumber + stationTemplate.meterSerialNumberPrefix != null && stationInfoSrc.meterSerialNumber != null + ? (stationInfoDst.meterSerialNumber = stationInfoSrc.meterSerialNumber) + : stationInfoDst.meterSerialNumber != null && delete stationInfoDst.meterSerialNumber +} + +const getRandomSerialNumberSuffix = (params?: { + randomBytesLength?: number + upperCase?: boolean +}): string => { + const randomSerialNumberSuffix = randomBytes(params?.randomBytesLength ?? 16).toString('hex') + if (params?.upperCase) { + return randomSerialNumberSuffix.toUpperCase() + } + return randomSerialNumberSuffix +} diff --git a/src/charging-station/meter-values/CoherentMeterValueBuilder.ts b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts index 01f2f61e..2a0a7c42 100644 --- a/src/charging-station/meter-values/CoherentMeterValueBuilder.ts +++ b/src/charging-station/meter-values/CoherentMeterValueBuilder.ts @@ -226,6 +226,15 @@ const applyRegisterValuesWithoutPhases = ( * @param numberOfPhases - Session phase count. * @param connectorStatus - Connector status (for the energy register). * @returns Value to emit, or `undefined` if the combination is unsupported. + * + * Supported measurands: `Current.Import`, `Energy.Active.Import.Register`, + * `Power.Active.Import`, `SoC`, `Voltage`. Other OCPP-defined measurands + * (notably `Power.Factor`, `Power.Reactive.Import`, `Frequency`, + * `Temperature`) return `undefined` so the emission path logs and skips + * the template. `EvProfile.powerFactor` scales the AC current/power chain + * but is NOT emitted as a `Power.Factor` measurand; templates configuring + * these unsupported measurands under coherent mode are skipped with a + * warning in `buildCoherentMeterValue`. */ const resolvePhasedValue = ( measurand: MeterValueMeasurand, diff --git a/src/charging-station/meter-values/CoherentSampleComputer.ts b/src/charging-station/meter-values/CoherentSampleComputer.ts index 59b6dcd6..b9c2dd6b 100644 --- a/src/charging-station/meter-values/CoherentSampleComputer.ts +++ b/src/charging-station/meter-values/CoherentSampleComputer.ts @@ -9,15 +9,19 @@ * {@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-1**: AC: `P = V × I × phases × powerFactor`; DC: `P = V × I` + * (DC has no reactive component, so `powerFactor` is pinned to `1` + * regardless of the profile field). `powerFactor` (cos φ) defaults to + * `1` (unity) on AC so pre-M-09 profiles are unchanged. Emitted + * `powerW` is recomputed from the rounded emitted current, voltage, + * and `powerFactor` so `|P - V·I·phases·powerFactor|` stays within + * the `ROUNDING_SCALE` half-width (≤ 0.005 W scalar bound) regardless + * of V, phases, or `powerFactor`. 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 · powerFactor|` 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 @@ -182,6 +186,49 @@ const fluctuate = (base: number, percent: number, prng: () => number): number => return base * (1 + (prng() * 2 - 1) * percent) } +/** + * Steepness constant of the logistic used by {@link sigmoidRamp}. `k=10` + * yields `raw(0) ≈ 6.69e-3` and `raw(1) ≈ 0.993`, so the shift-scale + * normalization below places interior values within `1e-2` of the ideal + * `{0, 1}` at the boundaries. Bit-exact endpoints are pinned by the + * short-circuit paths in {@link sigmoidRamp}; the normalization aligns + * the open interval `(0, 1)` so the transition into the short-circuit is + * continuous. + */ +const SIGMOID_STEEPNESS = 10 + +const SIGMOID_RAW_AT_0 = 1 / (1 + Math.exp(SIGMOID_STEEPNESS * 0.5)) +const SIGMOID_RAW_AT_1 = 1 / (1 + Math.exp(-SIGMOID_STEEPNESS * 0.5)) +const SIGMOID_RANGE = SIGMOID_RAW_AT_1 - SIGMOID_RAW_AT_0 + +/** + * S-shaped ramp fraction over normalized progress `p ∈ [0, 1]`. Uses a + * shifted-and-scaled logistic: + * + * ``` + * raw(p) = 1 / (1 + exp(-k · (p - 0.5))) + * f(p) = (raw(p) - raw(0)) / (raw(1) - raw(0)) + * ``` + * + * Endpoints `f(0) = 0` and `f(1) = 1` are pinned bit-exact by the + * short-circuit paths below; the normalization aligns the open-interval + * values so approaching either boundary matches the pinned endpoint + * within floating-point ε. Progress outside `[0, 1]` is clamped so the + * ramp saturates like the linear branch. + * @param progress - Normalized progress `elapsedMs / rampUpDurationMs`. + * @returns Ramp fraction in `[0, 1]`. + */ +const sigmoidRamp = (progress: number): number => { + if (progress <= 0) { + return 0 + } + if (progress >= 1) { + return 1 + } + const raw = 1 / (1 + Math.exp(-SIGMOID_STEEPNESS * (progress - 0.5))) + return (raw - SIGMOID_RAW_AT_0) / SIGMOID_RANGE +} + /** * Unconditionally advances the connector energy registers by `deltaEnergyWh`. * The coherent path owns register updates so `meterStop` stays correct even @@ -212,7 +259,9 @@ export const advanceEnergyRegister = ( * model. * * Physics chain (INV-1/INV-2/INV-3 hold by construction): - * 1. `rampFactor = min(1, elapsed / rampUp)` - immutable session start. + * 1. `rampFactor = min(1, elapsed / rampUp)` for `rampShape='linear'` (default) + * or the {@link sigmoidRamp} S-curve for `rampShape='sigmoid'` - + * immutable session start. * 2. `V` - nominal ± small seed-derived noise. * 3. `evAcceptanceW = curve(SoC) × profile.maxPowerW`. * 4. `powerW = rampFactor × min(EVSE_max, evAcceptance) × socCap`. @@ -220,13 +269,14 @@ export const advanceEnergyRegister = ( * {@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)`; + * 6. `currentAExact = powerW / (V_round · phases · powerFactor)` - exact + * fraction (phases=1 for DC; `powerFactor` defaults to 1). Emitted + * current is rounded to `ROUNDING_SCALE`. + * 7. Emitted `powerW = round(V_round × currentA_round × phases × powerFactor)`; * 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). + * `powerW` (active) so the register integrates the capacity-clamped + * active power exactly (INV-3), independent of `powerFactor`. * 9. `ΔSoC = ΔE / capacity × 100`; `socPercent = min(100, soc + ΔSoC)`. * @param context - Charging-station context. * @param connectorStatus - Connector status. @@ -288,10 +338,20 @@ export const computeCoherentSample = ( // 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 + // Ramp shape defaults to `'linear'` (fraction rises linearly with elapsed + // time). `'sigmoid'` selects the {@link sigmoidRamp} S-curve which better + // matches CCS/CHAdeMO handshake and pre-charge behavior; both shapes saturate + // at 1 for progress ≥ 1 so downstream physics is unchanged past ramp-up. + let rampFactor: number + if (session.rampUpDurationMs > 0 && Number.isFinite(session.rampUpDurationMs)) { + const rampProgress = elapsedMs / session.rampUpDurationMs + rampFactor = + session.profile.rampShape === 'sigmoid' + ? sigmoidRamp(rampProgress) + : Math.min(1, rampProgress) + } else { + rampFactor = 1 + } // Voltage: nominal ± small seed-derived noise. The voltage PRNG lives on // module-scope runtime state (not on the serializable session) so its @@ -340,19 +400,31 @@ export const computeCoherentSample = ( powerW = Math.min(powerW, maxPowerFromCapacityW) // Physics: derive per-phase current as an exact fraction so - // V_round · currentAExact · phases = powerW + // V_round · currentAExact · phases · powerFactor = 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 + // single branch covers both currents. `powerFactor` (cos φ) is AC-only; + // DC has no reactive component (`P = V·I`), so the field is pinned to 1 + // on DC profiles even if the caller set it. On AC it defaults to 1 + // (unity) so existing profiles are unchanged; when `< 1` the divisor + // shrinks and `I` rises inversely proportional to `powerFactor` for a + // given `powerW` (active). 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 powerFactor = + session.currentType === CurrentType.AC ? (session.profile.powerFactor ?? 1) : 1 + const divisor = roundedV * numberOfPhases * powerFactor 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. + // Emission: round current to `ROUNDING_SCALE`, then derive emitted + // active power from `V · I · phases · powerFactor` so INV-1 + // (P_active = V·I·phases·powerFactor) holds within `ROUNDING_SCALE` + // half-width (≤ 0.005 W) regardless of V, phases, or powerFactor. When + // `powerFactor = 1` (default) this reduces to the original identity. const roundedCurrent = roundTo(currentAExact, ROUNDING_SCALE) - const roundedPower = roundTo(roundedV * roundedCurrent * numberOfPhases, ROUNDING_SCALE) + const roundedPower = roundTo( + roundedV * roundedCurrent * numberOfPhases * powerFactor, + ROUNDING_SCALE + ) // Energy accounting uses the clamped (pre-rounding) `powerW` so INV-3 // holds within floating-point ε and the capacity budget is respected diff --git a/src/charging-station/meter-values/types.ts b/src/charging-station/meter-values/types.ts index bd028fe3..e882dd4e 100644 --- a/src/charging-station/meter-values/types.ts +++ b/src/charging-station/meter-values/types.ts @@ -31,6 +31,17 @@ export interface ChargingCurvePoint { * A weighted EV profile. `weight` biases random per-transaction selection. * `batteryCapacityWh` bounds ΔSoC per ΔE. `maxPowerW` bounds acceptance. * `chargingCurve` is a sorted-by-`socPercent` piecewise-linear taper. + * + * Optional physics refinements: + * - `powerFactor` (AC only, ignored on DC): the cos φ factor between real + * and apparent power. Real onboard chargers sit around `0.98..1.0`. + * When present on an AC profile, the per-phase current derivation + * becomes `I = P / (V · phases · powerFactor)`, so `I` rises as + * `powerFactor` shrinks for the same delivered `P`. DC has no reactive + * component (`P = V·I`), so the field is ignored on DC profiles. + * Absent ⇒ 1 (unity) ⇒ current behavior preserved. + * - `rampShape` selects the ramp-up curve; see {@link EvRampShape}. Absent + * ⇒ `linear` ⇒ current behavior preserved. */ export interface EvProfile { batteryCapacityWh: number @@ -39,6 +50,8 @@ export interface EvProfile { initialSocPercentMax: number initialSocPercentMin: number maxPowerW: number + powerFactor?: number + rampShape?: EvRampShape weight: number } @@ -49,6 +62,15 @@ export interface EvProfilesFile { profiles: EvProfile[] } +/** + * Ramp-up shape between session start and full-power acceptance. + * - `linear` (default): progress fraction rises linearly with elapsed time. + * - `sigmoid`: S-shaped logistic curve pinned at f(0)=0 and f(1)=1; models + * real CCS/CHAdeMO handshake and pre-charge behavior more faithfully than + * a pure linear ramp. + */ +export type EvRampShape = 'linear' | 'sigmoid' + /** * Zod schema for {@link ChargingCurvePoint}. `socPercent` in [0, 100], * `powerFraction` in [0, 1]. @@ -79,6 +101,20 @@ export const EvProfileSchema = z.object({ initialSocPercentMax: z.number().min(0).max(100), initialSocPercentMin: z.number().min(0).max(100), maxPowerW: z.number().positive(), + /** + * Optional cos φ ∈ [0.5, 1]. Absent ⇒ 1. AC only: multiplies the divisor + * in the per-phase current derivation `I = P / (V · phases · powerFactor)` + * so `I` rises as `powerFactor` shrinks for the same delivered `P`. The + * lower bound at `0.5` blocks configuration values that would drive the + * divisor toward zero and produce non-physical current; real onboard + * chargers sit at `0.98..1.0`. Ignored on DC profiles (`P = V·I`). + */ + powerFactor: z.number().min(0.5).max(1).optional(), + /** + * Optional ramp-up shape. Absent ⇒ `'linear'` (preserves existing golden + * tests). See {@link EvRampShape}. + */ + rampShape: z.enum(['linear', 'sigmoid']).optional(), weight: z.number().nonnegative(), }) diff --git a/tests/charging-station/Helpers.test.ts b/tests/charging-station/Helpers.test.ts index d9f7ecb5..540c052c 100644 --- a/tests/charging-station/Helpers.test.ts +++ b/tests/charging-station/Helpers.test.ts @@ -7,10 +7,12 @@ import assert from 'node:assert/strict' import { afterEach, beforeEach, describe, it } from 'node:test' import { + type ChargingStationNameTemplate, checkChargingStationState, checkConfiguration, checkStationInfoConnectorStatus, getBootConnectorStatus, + getChargingStationChargingProfilesLimit, getChargingStationId, getHashId, getMaxConfiguredNumberOfConnectors, @@ -23,16 +25,20 @@ import { resetConnectorStatus, setChargingStationOptions, validateStationInfo, -} from '../../src/charging-station/index.js' +} from '../../src/charging-station/Helpers.js' +import { getSingleChargingSchedule } from '../../src/charging-station/HelpersChargingProfile.js' import { AvailabilityType, type ChargingProfile, ChargingProfilePurposeType, + ChargingRateUnitType, + type ChargingSchedule, type ChargingStationInfo, type ChargingStationOptions, type ChargingStationTemplate, type ConnectorStatus, ConnectorStatusEnum, + CurrentType, OCPPVersion, type Reservation, type SampledValueTemplate, @@ -78,6 +84,22 @@ await describe('Helpers', async () => { ) }) + await it('should reject property mutation on ChargingStationNameTemplate at compile time (Readonly contract)', () => { + const nameTemplate: ChargingStationNameTemplate = { + baseName: 'READONLY-STATION', + fixedName: true, + nameSuffix: '', + } + // @ts-expect-error - Readonly> must prevent baseName mutation. + // If the Readonly wrapper is silently removed from ChargingStationNameTemplate, + // this directive becomes unused and typecheck (`tsc --noEmit`) fails with + // "Unused '@ts-expect-error' directive", catching the regression at build time. + nameTemplate.baseName = 'MUTATED' + // Runtime tolerates the mutation (Readonly is compile-time only); the + // compile-time directive above is the regression lock. + assert.strictEqual(nameTemplate.baseName, 'MUTATED') + }) + await it('should return baseName verbatim when fixedName is true', () => { const template = { baseName: 'DYNAMIC-STATION', @@ -1006,4 +1028,199 @@ await describe('Helpers', async () => { } }) }) + + await describe('getSingleChargingSchedule', async () => { + await it('should return the schedule unchanged for OCPP 1.6 single-schedule shape', () => { + const schedule = { chargingSchedulePeriod: [], duration: 3600 } as unknown as ChargingSchedule + const chargingProfile = { + chargingProfileId: 1, + chargingSchedule: schedule, + } as unknown as ChargingProfile + + const result = getSingleChargingSchedule(chargingProfile) + + assert.strictEqual(result, schedule) + }) + + await it('should unwrap a length-1 OCPP 2.0.x chargingSchedule array', () => { + const schedule = { chargingSchedulePeriod: [], duration: 3600 } as unknown as ChargingSchedule + const chargingProfile = { + chargingProfileId: 1, + chargingSchedule: [schedule], + } as unknown as ChargingProfile + + const result = getSingleChargingSchedule(chargingProfile) + + assert.strictEqual(result, schedule) + }) + + await it('should return undefined for an OCPP 2.0.x chargingSchedule array with 2 entries', () => { + const schedule1 = { + chargingSchedulePeriod: [], + duration: 3600, + } as unknown as ChargingSchedule + const schedule2 = { + chargingSchedulePeriod: [], + duration: 7200, + } as unknown as ChargingSchedule + const chargingProfile = { + chargingProfileId: 1, + chargingSchedule: [schedule1, schedule2], + } as unknown as ChargingProfile + + const result = getSingleChargingSchedule(chargingProfile) + + assert.strictEqual(result, undefined) + }) + + await it('should return undefined for an empty OCPP 2.0.x chargingSchedule array', () => { + const chargingProfile = { + chargingProfileId: 1, + chargingSchedule: [], + } as unknown as ChargingProfile + + const result = getSingleChargingSchedule(chargingProfile) + + assert.strictEqual(result, undefined) + }) + + await it('should return undefined for an OCPP 2.0.x chargingSchedule array with 3 entries (spec upper bound)', () => { + const schedule1 = { + chargingSchedulePeriod: [], + duration: 3600, + } as unknown as ChargingSchedule + const schedule2 = { + chargingSchedulePeriod: [], + duration: 7200, + } as unknown as ChargingSchedule + const schedule3 = { + chargingSchedulePeriod: [], + duration: 10800, + } as unknown as ChargingSchedule + const chargingProfile = { + chargingProfileId: 1, + chargingSchedule: [schedule1, schedule2, schedule3], + } as unknown as ChargingProfile + + const result = getSingleChargingSchedule(chargingProfile) + + assert.strictEqual(result, undefined) + }) + }) + + await describe('getChargingStationChargingProfilesLimit station-scope filter', async () => { + const buildStationScopeProfile = ( + purpose: ChargingProfilePurposeType, + limitW: number, + stackLevel = 0, + chargingProfileId = 1 + ): ChargingProfile => { + return { + chargingProfileId, + chargingProfilePurpose: purpose, + chargingSchedule: { + chargingRateUnit: ChargingRateUnitType.WATT, + chargingSchedulePeriod: [{ limit: limitW, startPeriod: 0 }], + duration: 3600, + startSchedule: new Date(Date.now() - 60_000), + }, + stackLevel, + } as unknown as ChargingProfile + } + + // Test setup rationale: the mock station's `stationInfo` reassignment + // after construction only reaches code paths that read + // `chargingStation.stationInfo` directly (currently the + // `maximumPower` sanity check). Factory methods like + // `getNumberOfPhases` / `getVoltageOut` capture `stationInfoOverrides` + // in a closure at construction time and do NOT re-read the mutated + // `stationInfo`. Tests use `ChargingRateUnitType.WATT` on the schedule + // to bypass AC/DC current-to-power conversion, so `currentOutType` is + // not exercised in this suite; that assignment documents intent for + // future readers rather than driving code paths. + const seedConnectorZeroWithProfiles = (profiles: ChargingProfile[]) => { + const { station } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + }) + station.stationInfo = { + ...(station.stationInfo ?? {}), + currentOutType: CurrentType.AC, + maximumPower: 22_000, + } as unknown as ChargingStationInfo + const connectorZero = station.getConnectorStatus(0) + assert.ok(connectorZero != null, 'connector 0 must exist on the mock station') + connectorZero.chargingProfiles = profiles + return station + } + + await it('should accept OCPP 1.6 CHARGE_POINT_MAX_PROFILE station-scope profile', () => { + const station = seedConnectorZeroWithProfiles([ + buildStationScopeProfile(ChargingProfilePurposeType.CHARGE_POINT_MAX_PROFILE, 5000), + ]) + + const limit = getChargingStationChargingProfilesLimit(station) + + assert.strictEqual(limit, 5000) + }) + + await it('should accept OCPP 2.0.1 ChargingStationMaxProfile station-scope profile', () => { + const station = seedConnectorZeroWithProfiles([ + buildStationScopeProfile(ChargingProfilePurposeType.ChargingStationMaxProfile, 7000), + ]) + + const limit = getChargingStationChargingProfilesLimit(station) + + assert.strictEqual(limit, 7000) + }) + + await it('should accept OCPP 2.0.1 ChargingStationExternalConstraints station-scope profile', () => { + const station = seedConnectorZeroWithProfiles([ + buildStationScopeProfile( + ChargingProfilePurposeType.ChargingStationExternalConstraints, + 6500 + ), + ]) + + const limit = getChargingStationChargingProfilesLimit(station) + + assert.strictEqual(limit, 6500) + }) + + await it('should reject TX_PROFILE (connector-scope) at the station-scope filter', () => { + const station = seedConnectorZeroWithProfiles([ + buildStationScopeProfile(ChargingProfilePurposeType.TX_PROFILE, 4000), + ]) + + const limit = getChargingStationChargingProfilesLimit(station) + + assert.strictEqual(limit, undefined) + }) + + await it('should pick the highest stackLevel when multiple station-scope profiles are seeded', () => { + const station = seedConnectorZeroWithProfiles([ + buildStationScopeProfile(ChargingProfilePurposeType.ChargingStationMaxProfile, 3000, 0, 1), + buildStationScopeProfile(ChargingProfilePurposeType.ChargingStationMaxProfile, 8000, 5, 2), + ]) + + const limit = getChargingStationChargingProfilesLimit(station) + + assert.strictEqual(limit, 8000) + }) + + await it('should drop TX_PROFILE and keep station-scope profile when both are seeded', () => { + const station = seedConnectorZeroWithProfiles([ + buildStationScopeProfile( + ChargingProfilePurposeType.ChargingStationExternalConstraints, + 6000, + 0, + 1 + ), + buildStationScopeProfile(ChargingProfilePurposeType.TX_PROFILE, 9000, 5, 2), + ]) + + const limit = getChargingStationChargingProfilesLimit(station) + + assert.strictEqual(limit, 6000) + }) + }) }) diff --git a/tests/charging-station/helpers/StationHelpers.cleanup.ts b/tests/charging-station/helpers/StationHelpers.cleanup.ts new file mode 100644 index 00000000..3411f1c9 --- /dev/null +++ b/tests/charging-station/helpers/StationHelpers.cleanup.ts @@ -0,0 +1,160 @@ +/** + * @file Cleanup and reset helpers for mock ChargingStation lifecycle in tests. + */ + +import type { ChargingStation } from '../../../src/charging-station/index.js' +import type { ConnectorStatus } from '../../../src/types/index.js' +import type { MockWebSocket } from '../mocks/MockWebSocket.js' + +import { + AvailabilityType, + ConnectorStatusEnum, + RegistrationStatusEnumType, +} from '../../../src/types/index.js' +import { MockIdTagsCache, MockSharedLRUCache } from '../mocks/MockCaches.js' + +/** + * Cleanup a ChargingStation instance to prevent test pollution + * + * Stops all timers, removes event listeners, and clears state. + * Call this in test afterEach() hooks. + * @param station - ChargingStation instance to clean up + * @example + * ```typescript + * afterEach(() => { + * cleanupChargingStation(station) + * }) + * ``` + */ +export function cleanupChargingStation (station: ChargingStation): void { + // Stop heartbeat timer + if (station.heartbeatSetInterval != null) { + clearInterval(station.heartbeatSetInterval) + station.heartbeatSetInterval = undefined + } + + // Stop WebSocket ping timer (private, accessed for cleanup via typed cast) + const stationWithWsTimer = station as unknown as { wsPingSetInterval?: NodeJS.Timeout } + if (stationWithWsTimer.wsPingSetInterval != null) { + clearInterval(stationWithWsTimer.wsPingSetInterval) + stationWithWsTimer.wsPingSetInterval = undefined + } + + // Stop message buffer flush timer (private, accessed for cleanup via typed cast) + const stationWithFlushTimer = station as unknown as { + flushMessageBufferSetInterval?: NodeJS.Timeout + } + if (stationWithFlushTimer.flushMessageBufferSetInterval != null) { + clearInterval(stationWithFlushTimer.flushMessageBufferSetInterval) + stationWithFlushTimer.flushMessageBufferSetInterval = undefined + } + + // Close WebSocket connection + if (station.wsConnection != null) { + try { + station.closeWSConnection() + } catch { + // Ignore errors during cleanup + } + } + + // Clear all event listeners + try { + station.removeAllListeners() + } catch { + // Ignore errors during cleanup + } + + // Clear connector transaction state and timers + for (const { connectorStatus } of station.iterateConnectors()) { + if (connectorStatus.transactionUpdatedMeterValuesSetInterval != null) { + clearInterval(connectorStatus.transactionUpdatedMeterValuesSetInterval) + connectorStatus.transactionUpdatedMeterValuesSetInterval = undefined + } + if (connectorStatus.transactionEndedMeterValuesSetInterval != null) { + clearInterval(connectorStatus.transactionEndedMeterValuesSetInterval) + connectorStatus.transactionEndedMeterValuesSetInterval = undefined + } + } + + // Clear requests map + station.requests.clear() + + // Reset mock singleton instances + MockSharedLRUCache.resetInstance() + MockIdTagsCache.resetInstance() +} + +/** + * Reset a ChargingStation to its initial state + * + * Resets all connector statuses, clears transactions, and restores defaults. + * Useful between test cases when reusing a station instance. + * @param station - ChargingStation instance to reset + * @example + * ```typescript + * beforeEach(() => { + * resetChargingStationState(station) + * }) + * ``` + */ +export function resetChargingStationState (station: ChargingStation): void { + // Reset station state + station.started = false + station.starting = false + + // Reset boot notification response + if (station.bootNotificationResponse != null) { + station.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED + station.bootNotificationResponse.currentTime = new Date() + } + + // Reset connector statuses + for (const { connectorId, connectorStatus } of station.iterateConnectors()) { + resetConnectorStatus(connectorStatus, connectorId === 0) + } + + // Reset EVSE availability + for (const { evseStatus } of station.iterateEvses()) { + evseStatus.availability = AvailabilityType.Operative + } + + // Clear requests + station.requests.clear() + + // Clear WebSocket messages if using MockWebSocket + const ws = station.wsConnection as unknown as MockWebSocket | null + if (ws != null && 'clearMessages' in ws) { + ws.clearMessages() + } +} + +/** + * Reset a single connector status to default values + * @param status - Connector status object to reset + * @param isConnectorZero - Whether this is connector 0 (station-level) + */ +function resetConnectorStatus (status: ConnectorStatus, isConnectorZero: boolean): void { + status.availability = AvailabilityType.Operative + status.status = isConnectorZero ? undefined : ConnectorStatusEnum.Available + status.transactionId = undefined + status.transactionIdTag = undefined + status.transactionStart = undefined + status.transactionStarted = false + status.transactionRemoteStarted = false + status.idTagAuthorized = false + status.idTagLocalAuthorized = false + status.energyActiveImportRegisterValue = 0 + status.transactionEnergyActiveImportRegisterValue = 0 + + if (status.transactionUpdatedMeterValuesSetInterval != null) { + clearInterval(status.transactionUpdatedMeterValuesSetInterval) + status.transactionUpdatedMeterValuesSetInterval = undefined + } + + status.transactionEndedMeterValues = undefined + if (status.transactionEndedMeterValuesSetInterval != null) { + clearInterval(status.transactionEndedMeterValuesSetInterval) + status.transactionEndedMeterValuesSetInterval = undefined + } +} diff --git a/tests/charging-station/helpers/StationHelpers.connector.ts b/tests/charging-station/helpers/StationHelpers.connector.ts new file mode 100644 index 00000000..8a2d7dd6 --- /dev/null +++ b/tests/charging-station/helpers/StationHelpers.connector.ts @@ -0,0 +1,68 @@ +/** + * @file Connector-status helpers for mock ChargingStation construction. + */ + +import type { ConnectorStatus } from '../../../src/types/index.js' +import type { + CreateConnectorStatusOptions, + MockChargingStationOptions, +} from './StationHelpers.types.js' + +import { AvailabilityType, ConnectorStatusEnum, OCPPVersion } from '../../../src/types/index.js' + +/** + * Create a connector status object with default values + * + * This is the canonical factory for creating ConnectorStatus objects in tests. + * @param _connectorId - Connector ID (unused; factory creates default connector status) + * @param options - Optional overrides for default values + * @returns ConnectorStatus with default or customized values + * @example + * ```typescript + * // Default connector status + * const status = createConnectorStatus(1) + * + * // Customized connector status + * const status = createConnectorStatus(1, { availability: AvailabilityType.Inoperative }) + * ``` + */ +export function createConnectorStatus ( + _connectorId: number, + options: CreateConnectorStatusOptions = {} +): ConnectorStatus { + return { + availability: options.availability ?? AvailabilityType.Operative, + bootStatus: ConnectorStatusEnum.Available, + chargingProfiles: [], + energyActiveImportRegisterValue: 0, + idTagAuthorized: false, + idTagLocalAuthorized: false, + MeterValues: [], + status: options.status ?? ConnectorStatusEnum.Available, + transactionEnergyActiveImportRegisterValue: 0, + transactionId: undefined, + transactionIdTag: undefined, + transactionRemoteStarted: false, + transactionStart: undefined, + transactionStarted: false, + } +} + +/** + * Determines whether EVSEs should be used based on configuration + * @param options - Configuration options to check + * @returns True if EVSEs should be used, false otherwise + */ +export function determineEvseUsage (options: MockChargingStationOptions): boolean { + // If explicitly set to 0, don't use EVSEs + if (options.evseConfiguration?.evsesCount === 0) { + return false + } + // Get the ocppVersion from stationInfo overrides or options + const effectiveOcppVersion = options.stationInfo?.ocppVersion ?? options.ocppVersion + return ( + options.evseConfiguration?.evsesCount != null || + effectiveOcppVersion === OCPPVersion.VERSION_20 || + effectiveOcppVersion === OCPPVersion.VERSION_201 + ) +} diff --git a/tests/charging-station/helpers/StationHelpers.factory.ts b/tests/charging-station/helpers/StationHelpers.factory.ts new file mode 100644 index 00000000..434b74b2 --- /dev/null +++ b/tests/charging-station/helpers/StationHelpers.factory.ts @@ -0,0 +1,607 @@ +/** + * @file Factory to construct a mock ChargingStation instance for tests. + */ + +import type { ChargingStation, CoherentSession } from '../../../src/charging-station/index.js' +import type { + ConnectorEntry, + ConnectorStatus, + EvseEntry, + EvseStatus, + Reservation, + ReservationKey, + StopTransactionReason, +} from '../../../src/types/index.js' +import type { + ChargingStationMocks, + CreateConnectorStatusOptions, + MockChargingStationOptions, + MockChargingStationResult, +} from './StationHelpers.types.js' + +import { getConfigurationKey } from '../../../src/charging-station/index.js' +import { + AvailabilityType, + CurrentType, + OCPPVersion, + RegistrationStatusEnumType, + StandardParametersKey, +} from '../../../src/types/index.js' +import { convertToBoolean } from '../../../src/utils/index.js' +import { + TEST_CHARGING_STATION_BASE_NAME, + TEST_CHARGING_STATION_HASH_ID, + TEST_HEARTBEAT_INTERVAL_SECONDS, +} from '../ChargingStationTestConstants.js' +import { MockIdTagsCache, MockSharedLRUCache } from '../mocks/MockCaches.js' +import { MockWebSocket, WebSocketReadyState } from '../mocks/MockWebSocket.js' +import { createConnectorStatus, determineEvseUsage } from './StationHelpers.connector.js' + +/** + * Creates a minimal mock ChargingStation-like object for testing + * + * Due to the complexity of the ChargingStation class and its deep dependencies + * (Bootstrap singleton, file system, WebSocket, worker threads), this factory + * creates a lightweight stub that implements the essential ChargingStation interface + * without requiring the full initialization chain. + * + * This is useful for testing code that depends on ChargingStation methods + * without needing the full OCPP protocol stack. + * @param options - Configuration options for the mock charging station + * @returns Object with mock station instance and mocks for assertion + * @example + * ```typescript + * const { station, mocks } = createMockChargingStation({ connectorsCount: 2 }) + * station.getNumberOfConnectors() // 2 (excludes connector 0) + * station.wsConnection = mocks.webSocket + * mocks.webSocket.simulateMessage('["3","uuid",{}]') + * ``` + */ +export function createMockChargingStation ( + options: MockChargingStationOptions = {} +): MockChargingStationResult { + const { + autoStart = false, + baseName = TEST_CHARGING_STATION_BASE_NAME, + bootNotificationStatus = RegistrationStatusEnumType.ACCEPTED, + connectionTimeout = 30000, + connectorDefaults, + connectorsCount = 2, + evseConfiguration, + heartbeatInterval = TEST_HEARTBEAT_INTERVAL_SECONDS, + index = 1, + ocppConfiguration, + ocppIncomingRequestService, + ocppRequestService, + ocppVersion = OCPPVersion.VERSION_16, + started = false, + starting = false, + stationInfo: stationInfoOverrides, + templateFile = 'test-template.json', + websocketPingInterval = 30, + } = options + + // Determine EVSE usage: explicit config OR OCPP 2.0/2.0.1 auto-detection + const useEvses = determineEvseUsage(options) + const effectiveEvsesCount = evseConfiguration?.evsesCount ?? 0 + + // Initialize mocks + const mockWebSocket = new MockWebSocket(`ws://localhost:8080/${baseName}-${String(index)}`) + const mockSharedLRUCache = MockSharedLRUCache.getInstance() + const mockIdTagsCache = MockIdTagsCache.getInstance() + const parentPortMessages: unknown[] = [] + const writtenFiles = new Map() + const readFiles = new Map() + + // Helper to create connector status with options defaults + const connectorStatusOptions: CreateConnectorStatusOptions = { + availability: connectorDefaults?.availability, + status: connectorDefaults?.status, + } + + // Create connectors map + const connectors = new Map() + + // Connector 0 always exists + connectors.set(0, createConnectorStatus(0, connectorStatusOptions)) + + // Add numbered connectors + for (let i = 1; i <= connectorsCount; i++) { + connectors.set(i, createConnectorStatus(i, connectorStatusOptions)) + } + + // Create EVSEs map if applicable + const evses = new Map() + if (useEvses) { + const resolvedEvsesCount = effectiveEvsesCount > 0 ? effectiveEvsesCount : connectorsCount + // EVSE 0 contains connector 0 (station-level status for availability checks) + const evse0Connectors = new Map() + const connector0Status = connectors.get(0) + if (connector0Status != null) { + evse0Connectors.set(0, connector0Status) + } + evses.set(0, { + availability: AvailabilityType.Operative, + connectors: evse0Connectors, + }) + + // Create EVSEs 1..N with their respective connectors + const connectorsPerEvse = Math.ceil(connectorsCount / resolvedEvsesCount) + for (let evseId = 1; evseId <= resolvedEvsesCount; evseId++) { + const evseConnectors = new Map() + const startId = (evseId - 1) * connectorsPerEvse + 1 + const endId = Math.min(startId + connectorsPerEvse - 1, connectorsCount) + + for (let connId = startId; connId <= endId; connId++) { + const connectorStatus = connectors.get(connId) + if (connectorStatus != null) { + evseConnectors.set(connId, connectorStatus) + } + } + + evses.set(evseId, { + availability: AvailabilityType.Operative, + connectors: evseConnectors, + }) + } + } + + // Create requests map + const requests = new Map() + + // Create the station object that mimics ChargingStation + const station = { + // Reservation methods (mock implementations - eslint disabled for test utilities) + + __injectCoherentSession (transactionId: number | string, session: CoherentSession): void { + this.coherentSessions.set(transactionId, session) + }, + addReservation (reservation: Record): void { + // Check if reservation with same ID exists and remove it + const existingReservation = this.getReservationBy( + 'reservationId', + (reservation as Record).reservationId + ) + if (existingReservation != null) { + this.removeReservation(existingReservation, 'REPLACE_EXISTING') + } + const connectorStatus = this.getConnectorStatus(reservation.connectorId as number) + if (connectorStatus != null) { + connectorStatus.reservation = reservation as unknown as Reservation + } + }, + automaticTransactionGenerator: undefined, + + bootNotificationRequest: undefined, + + bootNotificationResponse: { + currentTime: new Date(), + interval: heartbeatInterval, + status: bootNotificationStatus, + } as + | undefined + | { + currentTime: Date + interval: number + status: RegistrationStatusEnumType + }, + bufferMessage (message: string): void { + this.messageQueue.push(message) + }, + closeWSConnection (): void { + if (this.wsConnection != null) { + this.wsConnection.close() + this.wsConnection = null + } + }, + // Coherent MeterValues session store (real class uses a private Map). + coherentSessions: new Map(), + + connectors, + createCoherentSession ( + _transactionId: number | string, + _connectorId: number + ): CoherentSession | undefined { + // Mock: never auto-create; tests inject sessions directly when needed. + return undefined + }, + async delete (deleteConfiguration = true): Promise { + if (this.started) { + await this.stop() + } + this.requests.clear() + this.connectors.clear() + this.evses.clear() + // Note: deleteConfiguration controls file deletion in real implementation + // Mock doesn't have file system access, so parameter is unused + }, + + destroyCoherentSession (transactionId: number | string | undefined): boolean { + if (transactionId == null) { + return false + } + return this.coherentSessions.delete(transactionId) + }, + // Event emitter methods (minimal implementation) + emit: () => true, + // Empty implementations for interface compatibility + // eslint-disable-next-line @typescript-eslint/no-empty-function + emitChargingStationEvent: () => {}, + evses, + getAuthorizeRemoteTxRequests (): boolean { + return false // Default to false in mock + }, + getCoherentSession (transactionId: number | string): CoherentSession | undefined { + return this.coherentSessions.get(transactionId) + }, + getConnectionTimeout (): number { + return connectionTimeout + }, + getConnectorIdByEvseId (evseId: number): number | undefined { + return this.iterateConnectors().find(({ evseId: id }) => id === evseId)?.connectorId + }, + getConnectorIdByTransactionId (transactionId: number | string | undefined): number | undefined { + if (transactionId == null) { + return undefined + } + return this.iterateConnectors().find( + ({ connectorStatus }) => connectorStatus.transactionId === transactionId + )?.connectorId + }, + getConnectorMaximumAvailablePower (_connectorId: number): number { + return stationInfoOverrides?.maximumPower ?? 22000 + }, + getConnectorStatus (connectorId: number): ConnectorStatus | undefined { + return this.iterateConnectors().find(({ connectorId: id }) => id === connectorId) + ?.connectorStatus + }, + getEnergyActiveImportRegisterByConnectorId (connectorId: number, rounded = false): number { + const connectorStatus = this.getConnectorStatus(connectorId) + if (connectorStatus == null) { + return 0 + } + const value = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0 + return rounded ? Math.round(value) : value + }, + getEnergyActiveImportRegisterByTransactionId ( + transactionId: number | string | undefined, + rounded = false + ): number { + const connectorId = this.getConnectorIdByTransactionId(transactionId) + if (connectorId == null) { + return 0 + } + return this.getEnergyActiveImportRegisterByConnectorId(connectorId, rounded) + }, + getEvseIdByConnectorId (connectorId: number): number | undefined { + return this.iterateConnectors().find(({ connectorId: id }) => id === connectorId)?.evseId + }, + getEvseIdByTransactionId (transactionId: number | string | undefined): number | undefined { + if (transactionId == null) { + return undefined + } + return this.iterateConnectors().find( + ({ connectorStatus }) => connectorStatus.transactionId === transactionId + )?.evseId + }, + getEvseStatus (evseId: number): EvseStatus | undefined { + return evses.get(evseId) + }, + getHeartbeatInterval (): number { + return heartbeatInterval * 1000 // Return in ms + }, + getLocalAuthListEnabled (): boolean { + const key = getConfigurationKey( + this as unknown as ChargingStation, + StandardParametersKey.LocalAuthListEnabled + ) + return key?.value != null ? convertToBoolean(key.value) : false + }, + getNumberOfConnectors (): number { + return this.iterateConnectors(true).reduce(count => count + 1, 0) + }, + getNumberOfEvses (): number { + return evses.has(0) ? evses.size - 1 : evses.size + }, + getNumberOfPhases (): number { + return stationInfoOverrides?.numberOfPhases ?? 3 + }, + getNumberOfRunningTransactions (): number { + return this.iterateConnectors(true).reduce( + (count, { connectorStatus }) => + connectorStatus.transactionStarted === true ? count + 1 : count, + 0 + ) + }, + getReservationBy (filterKey: ReservationKey, value: number | string): Reservation | undefined { + return this.iterateConnectors().find( + ({ connectorStatus }) => connectorStatus.reservation?.[filterKey] === value + )?.connectorStatus.reservation + }, + getTransactionIdTag (transactionId: number): string | undefined { + return this.iterateConnectors().find( + ({ connectorStatus }) => connectorStatus.transactionId === transactionId + )?.connectorStatus.transactionIdTag + }, + getVoltageOut (): number { + return stationInfoOverrides?.voltageOut ?? 230 + }, + getWebSocketPingInterval (): number { + return websocketPingInterval + }, + + hasConnector (connectorId: number): boolean { + return this.iterateConnectors().some(({ connectorId: id }) => id === connectorId) + }, + + hasEvse (evseId: number): boolean { + return evses.has(evseId) + }, + + // Getters + get hasEvses (): boolean { + return useEvses + }, + + heartbeatSetInterval: undefined as NodeJS.Timeout | undefined, + + idTagsCache: mockIdTagsCache as unknown, + + inAcceptedState (): boolean { + return this.bootNotificationResponse?.status === RegistrationStatusEnumType.ACCEPTED + }, + + // Core properties + index, + inPendingState (): boolean { + return this.bootNotificationResponse?.status === RegistrationStatusEnumType.PENDING + }, + + inRejectedState (): boolean { + return this.bootNotificationResponse?.status === RegistrationStatusEnumType.REJECTED + }, + + inUnknownState (): boolean { + return this.bootNotificationResponse?.status == null + }, + + isChargingStationAvailable (): boolean { + return this.getConnectorStatus(0)?.availability === AvailabilityType.Operative + }, + + isConnectorAvailable (connectorId: number): boolean { + return ( + connectorId > 0 && + this.getConnectorStatus(connectorId)?.availability === AvailabilityType.Operative + ) + }, + + isConnectorReservable (reservationId: number, idTag?: string, connectorId?: number): boolean { + if (connectorId === 0) { + return false + } + const reservation = this.getReservationBy('reservationId', reservationId) + return reservation == null + }, + + isWebSocketConnectionOpened (): boolean { + return this.wsConnection?.readyState === WebSocketReadyState.OPEN + }, + + * iterateConnectors (skipZero = false): Generator { + if (useEvses) { + for (const [evseId, evseStatus] of evses) { + if (skipZero && evseId === 0) continue + for (const [connectorId, connectorStatus] of evseStatus.connectors) { + if (skipZero && connectorId === 0) continue + yield { connectorId, connectorStatus, evseId } + } + } + } else { + for (const [connectorId, connectorStatus] of connectors) { + if (skipZero && connectorId === 0) continue + yield { connectorId, connectorStatus, evseId: undefined } + } + } + }, + + * iterateEvses (skipZero = false): Generator { + for (const [evseId, evseStatus] of evses) { + if (skipZero && evseId === 0) continue + yield { evseId, evseStatus } + } + }, + + listenerCount: () => 0, + + lockConnector (connectorId: number): void { + if (connectorId === 0) { + return + } + if (!this.hasConnector(connectorId)) { + return + } + const connectorStatus = this.getConnectorStatus(connectorId) + if (connectorStatus == null) { + return + } + connectorStatus.locked = true + }, + + logPrefix (): string { + return `${this.stationInfo.chargingStationId} |` + }, + + messageQueue: [] as string[], + + ocppConfiguration: ocppConfiguration ?? { + configurationKey: [], + }, + + ocppIncomingRequestService: { + incomingRequestHandler: async () => { + return await Promise.reject( + new Error( + 'ocppIncomingRequestService.incomingRequestHandler not mocked. Define in createMockChargingStation options.' + ) + ) + }, + stop: (): void => { + throw new Error( + 'ocppIncomingRequestService.stop not mocked. Define in createMockChargingStation options.' + ) + }, + ...ocppIncomingRequestService, + }, + + ocppRequestService: { + requestHandler: async () => { + return await Promise.reject( + new Error( + 'ocppRequestService.requestHandler not mocked. Define in createMockChargingStation options.' + ) + ) + }, + sendError: async () => { + return await Promise.reject( + new Error( + 'ocppRequestService.sendError not mocked. Define in createMockChargingStation options.' + ) + ) + }, + sendResponse: async () => { + return await Promise.reject( + new Error( + 'ocppRequestService.sendResponse not mocked. Define in createMockChargingStation options.' + ) + ) + }, + ...ocppRequestService, + }, + + on: () => station, + + once: () => station, + + performanceStatistics: undefined, + + powerDivider: 1, + + removeAllListeners: () => station, + + removeListener: () => station, + + removeReservation (reservation: Record, _reason?: string): void { + const connectorStatus = this.getConnectorStatus(reservation.connectorId as number) + if (connectorStatus != null) { + delete connectorStatus.reservation + } + }, + requests, + + restartHeartbeat (): void { + this.stopHeartbeat() + this.startHeartbeat() + }, + + restartWebSocketPing (): void { + /* empty */ + }, + + saveOcppConfiguration (): void { + /* empty */ + }, + start (): void { + this.started = true + this.starting = false + }, + started, + + startHeartbeat (): void { + this.heartbeatSetInterval ??= setInterval(() => { + /* empty */ + }, 30000) + }, + starting, + + startWebSocketPing (): void { + /* empty */ + }, + stationInfo: { + autoStart, + baseName, + chargingStationId: `${baseName}-${index.toString().padStart(5, '0')}`, + currentOutType: CurrentType.AC, + hashId: TEST_CHARGING_STATION_HASH_ID, + maximumAmperage: 32, + maximumPower: 22000, + numberOfPhases: 3, + ocppVersion: stationInfoOverrides?.ocppVersion ?? ocppVersion, + remoteAuthorization: true, + templateIndex: index, + templateName: templateFile, + voltageOut: 230, + ...stationInfoOverrides, + }, + + async stop (reason?: StopTransactionReason, stopTransactions?: boolean): Promise { + if (this.started && !this.stopping) { + this.stopping = true + // Simulate async stop behavior (immediate resolution for tests) + await Promise.resolve() + this.closeWSConnection() + this.bootNotificationResponse = undefined + this.started = false + this.stopping = false + } + }, + + stopHeartbeat (): void { + if (this.heartbeatSetInterval != null) { + clearInterval(this.heartbeatSetInterval) + delete this.heartbeatSetInterval + } + }, + stopping: false, + + templateFile, + + unlockConnector (connectorId: number): void { + if (connectorId === 0) { + return + } + if (!this.hasConnector(connectorId)) { + return + } + const connectorStatus = this.getConnectorStatus(connectorId) + if (connectorStatus == null) { + return + } + connectorStatus.locked = false + }, + + wsConnection: null as MockWebSocket | null, + wsConnectionRetryCount: 0, + } + + // Set up mock WebSocket connection + station.wsConnection = mockWebSocket + + const mocks: ChargingStationMocks = { + fileSystem: { + readFiles, + writtenFiles, + }, + idTagsCache: mockIdTagsCache, + parentPortMessages, + sharedLRUCache: mockSharedLRUCache, + webSocket: mockWebSocket, + } + + const typedStation: ChargingStation = station as unknown as ChargingStation + + return { + mocks, + station: typedStation, + } +} diff --git a/tests/charging-station/helpers/StationHelpers.template.ts b/tests/charging-station/helpers/StationHelpers.template.ts new file mode 100644 index 00000000..7dc58b46 --- /dev/null +++ b/tests/charging-station/helpers/StationHelpers.template.ts @@ -0,0 +1,20 @@ +/** + * @file Mock ChargingStationTemplate factory for testing. + */ + +import type { ChargingStationTemplate } from '../../../src/types/index.js' + +import { TEST_CHARGING_STATION_BASE_NAME } from '../ChargingStationTestConstants.js' + +/** + * Create a mock charging station template for testing + * @param baseName - Base name for the template + * @returns ChargingStationTemplate with minimal required properties for testing + */ +export function createMockChargingStationTemplate ( + baseName: string = TEST_CHARGING_STATION_BASE_NAME +): ChargingStationTemplate { + return { + baseName, + } as ChargingStationTemplate +} diff --git a/tests/charging-station/helpers/StationHelpers.ts b/tests/charging-station/helpers/StationHelpers.ts index 82f59ba8..57f1cb9b 100644 --- a/tests/charging-station/helpers/StationHelpers.ts +++ b/tests/charging-station/helpers/StationHelpers.ts @@ -1,962 +1,18 @@ /** - * Station helper functions for testing + * @file Barrel re-export for StationHelpers modular files. * - * Factory functions to create mock ChargingStation instances with isolated dependencies. + * Public surface: consumers importing from './StationHelpers.js' receive + * every exported symbol from the sibling modules unchanged. * - * TODO(#1936): file size (≈ 950 LOC) exceeds the 250 LOC ceiling documented - * in AGENTS.md. Modular refactor (StationHelpers.types / .cleanup / .connector / - * .template / .factory) tracked as follow-up; 30+ test files import from - * this path so the split must preserve the public re-export surface. + * Uses `export * from` (rather than explicit name lists like the + * production `src/charging-station/Helpers.ts` barrel) because + * consumers are internal to the test tree and the sibling files' + * natural export shape is the intended public surface. The production + * barrel is stricter to control the external API surface. */ -import type { ChargingStation, CoherentSession } from '../../../src/charging-station/index.js' -import type { - ChargingStationInfo, - ChargingStationOcppConfiguration, - ChargingStationTemplate, - ConnectorEntry, - ConnectorStatus, - EvseEntry, - EvseStatus, - Reservation, - ReservationKey, - StopTransactionReason, -} from '../../../src/types/index.js' - -import { getConfigurationKey } from '../../../src/charging-station/index.js' -import { - AvailabilityType, - ConnectorStatusEnum, - CurrentType, - OCPPVersion, - RegistrationStatusEnumType, - StandardParametersKey, -} from '../../../src/types/index.js' -import { convertToBoolean } from '../../../src/utils/index.js' -import { - TEST_CHARGING_STATION_BASE_NAME, - TEST_CHARGING_STATION_HASH_ID, - TEST_HEARTBEAT_INTERVAL_SECONDS, -} from '../ChargingStationTestConstants.js' -import { MockIdTagsCache, MockSharedLRUCache } from '../mocks/MockCaches.js' -import { MockWebSocket, WebSocketReadyState } from '../mocks/MockWebSocket.js' - -/** - * Collection of all mocks used in a real ChargingStation instance - */ -export interface ChargingStationMocks { - /** Mock file system operations */ - fileSystem: { - readFiles: Map - writtenFiles: Map - } - - /** Mock IdTagsCache */ - idTagsCache: MockIdTagsCache - - /** Mock parentPort messages */ - parentPortMessages: unknown[] - - /** Mock SharedLRUCache */ - sharedLRUCache: MockSharedLRUCache - - /** Mock WebSocket connection */ - webSocket: MockWebSocket -} - -/** - * Options for customizing connector status creation - */ -export interface CreateConnectorStatusOptions { - /** Override availability (default: AvailabilityType.Operative) */ - availability?: AvailabilityType - /** Override status (default: ConnectorStatusEnum.Available) */ - status?: ConnectorStatusEnum -} - -/** - * Mock type combining ChargingStation with optional test-specific properties. - */ -export type MockChargingStation = ChargingStation & { - getNumberOfRunningTransactions?: () => number - reset?: () => Promise -} - -/** - * Options for creating a mock ChargingStation instance - */ -export interface MockChargingStationOptions { - /** Auto-start the station on creation */ - autoStart?: boolean - - /** Station base name */ - baseName?: string - - /** Initial boot notification status */ - bootNotificationStatus?: RegistrationStatusEnumType - - /** Connection timeout in milliseconds */ - connectionTimeout?: number - - /** Default connector status overrides */ - connectorDefaults?: { - availability?: AvailabilityType - status?: ConnectorStatusEnum - } - - /** Number of connectors (default: 2) */ - connectorsCount?: number - - /** EVSE configuration for OCPP 2.0 - enables EVSE mode when present */ - evseConfiguration?: { - evsesCount?: number - } - - /** Heartbeat interval in seconds */ - heartbeatInterval?: number - - /** Station index (default: 1) */ - index?: number - - /** OCPP configuration with configuration keys */ - ocppConfiguration?: ChargingStationOcppConfiguration - - /** Custom OCPP incoming request service for test mocking */ - ocppIncomingRequestService?: Partial - - /** Custom OCPP request service for test mocking */ - ocppRequestService?: Partial - - /** OCPP version (default: '1.6') */ - ocppVersion?: OCPPVersion - - /** Whether station is started */ - started?: boolean - - /** Whether station is starting */ - starting?: boolean - - /** Station info overrides */ - stationInfo?: Partial - - /** Template file path (mocked) */ - templateFile?: string - - /** WebSocket ping interval in seconds */ - websocketPingInterval?: number -} - -/** - * Result of creating a mock ChargingStation instance - */ -export interface MockChargingStationResult { - /** All mocks used by the station for assertion */ - mocks: ChargingStationMocks - - /** The actual ChargingStation instance */ - station: ChargingStation -} - -/** - * Mock OCPP incoming request service interface for testing - * Provides typed access to mock handlers without eslint-disable comments - */ -export interface MockOCPPIncomingRequestService { - incomingRequestHandler: () => Promise - stop: () => void -} - -/** - * Mock OCPP request service interface for testing - * Provides typed access to mock handlers without eslint-disable comments - */ -export interface MockOCPPRequestService { - requestHandler: (...args: unknown[]) => Promise - sendError: (...args: unknown[]) => Promise - sendResponse: (...args: unknown[]) => Promise -} - -/** - * Cleanup a ChargingStation instance to prevent test pollution - * - * Stops all timers, removes event listeners, and clears state. - * Call this in test afterEach() hooks. - * @param station - ChargingStation instance to clean up - * @example - * ```typescript - * afterEach(() => { - * cleanupChargingStation(station) - * }) - * ``` - */ -export function cleanupChargingStation (station: ChargingStation): void { - // Stop heartbeat timer - if (station.heartbeatSetInterval != null) { - clearInterval(station.heartbeatSetInterval) - station.heartbeatSetInterval = undefined - } - - // Stop WebSocket ping timer (private, accessed for cleanup via typed cast) - const stationWithWsTimer = station as unknown as { wsPingSetInterval?: NodeJS.Timeout } - if (stationWithWsTimer.wsPingSetInterval != null) { - clearInterval(stationWithWsTimer.wsPingSetInterval) - stationWithWsTimer.wsPingSetInterval = undefined - } - - // Stop message buffer flush timer (private, accessed for cleanup via typed cast) - const stationWithFlushTimer = station as unknown as { - flushMessageBufferSetInterval?: NodeJS.Timeout - } - if (stationWithFlushTimer.flushMessageBufferSetInterval != null) { - clearInterval(stationWithFlushTimer.flushMessageBufferSetInterval) - stationWithFlushTimer.flushMessageBufferSetInterval = undefined - } - - // Close WebSocket connection - if (station.wsConnection != null) { - try { - station.closeWSConnection() - } catch { - // Ignore errors during cleanup - } - } - - // Clear all event listeners - try { - station.removeAllListeners() - } catch { - // Ignore errors during cleanup - } - - // Clear connector transaction state and timers - for (const { connectorStatus } of station.iterateConnectors()) { - if (connectorStatus.transactionUpdatedMeterValuesSetInterval != null) { - clearInterval(connectorStatus.transactionUpdatedMeterValuesSetInterval) - connectorStatus.transactionUpdatedMeterValuesSetInterval = undefined - } - if (connectorStatus.transactionEndedMeterValuesSetInterval != null) { - clearInterval(connectorStatus.transactionEndedMeterValuesSetInterval) - connectorStatus.transactionEndedMeterValuesSetInterval = undefined - } - } - - // Clear requests map - station.requests.clear() - - // Reset mock singleton instances - MockSharedLRUCache.resetInstance() - MockIdTagsCache.resetInstance() -} - -/** - * Create a connector status object with default values - * - * This is the canonical factory for creating ConnectorStatus objects in tests. - * @param _connectorId - Connector ID (unused; factory creates default connector status) - * @param options - Optional overrides for default values - * @returns ConnectorStatus with default or customized values - * @example - * ```typescript - * // Default connector status - * const status = createConnectorStatus(1) - * - * // Customized connector status - * const status = createConnectorStatus(1, { availability: AvailabilityType.Inoperative }) - * ``` - */ -export function createConnectorStatus ( - _connectorId: number, - options: CreateConnectorStatusOptions = {} -): ConnectorStatus { - return { - availability: options.availability ?? AvailabilityType.Operative, - bootStatus: ConnectorStatusEnum.Available, - chargingProfiles: [], - energyActiveImportRegisterValue: 0, - idTagAuthorized: false, - idTagLocalAuthorized: false, - MeterValues: [], - status: options.status ?? ConnectorStatusEnum.Available, - transactionEnergyActiveImportRegisterValue: 0, - transactionId: undefined, - transactionIdTag: undefined, - transactionRemoteStarted: false, - transactionStart: undefined, - transactionStarted: false, - } -} - -/** - * Creates a minimal mock ChargingStation-like object for testing - * - * Due to the complexity of the ChargingStation class and its deep dependencies - * (Bootstrap singleton, file system, WebSocket, worker threads), this factory - * creates a lightweight stub that implements the essential ChargingStation interface - * without requiring the full initialization chain. - * - * This is useful for testing code that depends on ChargingStation methods - * without needing the full OCPP protocol stack. - * @param options - Configuration options for the mock charging station - * @returns Object with mock station instance and mocks for assertion - * @example - * ```typescript - * const { station, mocks } = createMockChargingStation({ connectorsCount: 2 }) - * station.getNumberOfConnectors() // 2 (excludes connector 0) - * station.wsConnection = mocks.webSocket - * mocks.webSocket.simulateMessage('["3","uuid",{}]') - * ``` - */ -export function createMockChargingStation ( - options: MockChargingStationOptions = {} -): MockChargingStationResult { - const { - autoStart = false, - baseName = TEST_CHARGING_STATION_BASE_NAME, - bootNotificationStatus = RegistrationStatusEnumType.ACCEPTED, - connectionTimeout = 30000, - connectorDefaults, - connectorsCount = 2, - evseConfiguration, - heartbeatInterval = TEST_HEARTBEAT_INTERVAL_SECONDS, - index = 1, - ocppConfiguration, - ocppIncomingRequestService, - ocppRequestService, - ocppVersion = OCPPVersion.VERSION_16, - started = false, - starting = false, - stationInfo: stationInfoOverrides, - templateFile = 'test-template.json', - websocketPingInterval = 30, - } = options - - // Determine EVSE usage: explicit config OR OCPP 2.0/2.0.1 auto-detection - const useEvses = determineEvseUsage(options) - const effectiveEvsesCount = evseConfiguration?.evsesCount ?? 0 - - // Initialize mocks - const mockWebSocket = new MockWebSocket(`ws://localhost:8080/${baseName}-${String(index)}`) - const mockSharedLRUCache = MockSharedLRUCache.getInstance() - const mockIdTagsCache = MockIdTagsCache.getInstance() - const parentPortMessages: unknown[] = [] - const writtenFiles = new Map() - const readFiles = new Map() - - // Helper to create connector status with options defaults - const connectorStatusOptions: CreateConnectorStatusOptions = { - availability: connectorDefaults?.availability, - status: connectorDefaults?.status, - } - - // Create connectors map - const connectors = new Map() - - // Connector 0 always exists - connectors.set(0, createConnectorStatus(0, connectorStatusOptions)) - - // Add numbered connectors - for (let i = 1; i <= connectorsCount; i++) { - connectors.set(i, createConnectorStatus(i, connectorStatusOptions)) - } - - // Create EVSEs map if applicable - const evses = new Map() - if (useEvses) { - const resolvedEvsesCount = effectiveEvsesCount > 0 ? effectiveEvsesCount : connectorsCount - // EVSE 0 contains connector 0 (station-level status for availability checks) - const evse0Connectors = new Map() - const connector0Status = connectors.get(0) - if (connector0Status != null) { - evse0Connectors.set(0, connector0Status) - } - evses.set(0, { - availability: AvailabilityType.Operative, - connectors: evse0Connectors, - }) - - // Create EVSEs 1..N with their respective connectors - const connectorsPerEvse = Math.ceil(connectorsCount / resolvedEvsesCount) - for (let evseId = 1; evseId <= resolvedEvsesCount; evseId++) { - const evseConnectors = new Map() - const startId = (evseId - 1) * connectorsPerEvse + 1 - const endId = Math.min(startId + connectorsPerEvse - 1, connectorsCount) - - for (let connId = startId; connId <= endId; connId++) { - const connectorStatus = connectors.get(connId) - if (connectorStatus != null) { - evseConnectors.set(connId, connectorStatus) - } - } - - evses.set(evseId, { - availability: AvailabilityType.Operative, - connectors: evseConnectors, - }) - } - } - - // Create requests map - const requests = new Map() - - // Create the station object that mimics ChargingStation - const station = { - // Reservation methods (mock implementations - eslint disabled for test utilities) - - __injectCoherentSession (transactionId: number | string, session: CoherentSession): void { - this.coherentSessions.set(transactionId, session) - }, - addReservation (reservation: Record): void { - // Check if reservation with same ID exists and remove it - const existingReservation = this.getReservationBy( - 'reservationId', - (reservation as Record).reservationId - ) - if (existingReservation != null) { - this.removeReservation(existingReservation, 'REPLACE_EXISTING') - } - const connectorStatus = this.getConnectorStatus(reservation.connectorId as number) - if (connectorStatus != null) { - connectorStatus.reservation = reservation as unknown as Reservation - } - }, - automaticTransactionGenerator: undefined, - - bootNotificationRequest: undefined, - - bootNotificationResponse: { - currentTime: new Date(), - interval: heartbeatInterval, - status: bootNotificationStatus, - } as - | undefined - | { - currentTime: Date - interval: number - status: RegistrationStatusEnumType - }, - bufferMessage (message: string): void { - this.messageQueue.push(message) - }, - closeWSConnection (): void { - if (this.wsConnection != null) { - this.wsConnection.close() - this.wsConnection = null - } - }, - // Coherent MeterValues session store (real class uses a private Map). - coherentSessions: new Map(), - - connectors, - createCoherentSession ( - _transactionId: number | string, - _connectorId: number - ): CoherentSession | undefined { - // Mock: never auto-create; tests inject sessions directly when needed. - return undefined - }, - async delete (deleteConfiguration = true): Promise { - if (this.started) { - await this.stop() - } - this.requests.clear() - this.connectors.clear() - this.evses.clear() - // Note: deleteConfiguration controls file deletion in real implementation - // Mock doesn't have file system access, so parameter is unused - }, - - destroyCoherentSession (transactionId: number | string | undefined): boolean { - if (transactionId == null) { - return false - } - return this.coherentSessions.delete(transactionId) - }, - // Event emitter methods (minimal implementation) - emit: () => true, - // Empty implementations for interface compatibility - // eslint-disable-next-line @typescript-eslint/no-empty-function - emitChargingStationEvent: () => {}, - evses, - getAuthorizeRemoteTxRequests (): boolean { - return false // Default to false in mock - }, - getCoherentSession (transactionId: number | string): CoherentSession | undefined { - return this.coherentSessions.get(transactionId) - }, - getConnectionTimeout (): number { - return connectionTimeout - }, - getConnectorIdByEvseId (evseId: number): number | undefined { - return this.iterateConnectors().find(({ evseId: id }) => id === evseId)?.connectorId - }, - getConnectorIdByTransactionId (transactionId: number | string | undefined): number | undefined { - if (transactionId == null) { - return undefined - } - return this.iterateConnectors().find( - ({ connectorStatus }) => connectorStatus.transactionId === transactionId - )?.connectorId - }, - getConnectorMaximumAvailablePower (_connectorId: number): number { - return stationInfoOverrides?.maximumPower ?? 22000 - }, - getConnectorStatus (connectorId: number): ConnectorStatus | undefined { - return this.iterateConnectors().find(({ connectorId: id }) => id === connectorId) - ?.connectorStatus - }, - getEnergyActiveImportRegisterByConnectorId (connectorId: number, rounded = false): number { - const connectorStatus = this.getConnectorStatus(connectorId) - if (connectorStatus == null) { - return 0 - } - const value = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0 - return rounded ? Math.round(value) : value - }, - getEnergyActiveImportRegisterByTransactionId ( - transactionId: number | string | undefined, - rounded = false - ): number { - const connectorId = this.getConnectorIdByTransactionId(transactionId) - if (connectorId == null) { - return 0 - } - return this.getEnergyActiveImportRegisterByConnectorId(connectorId, rounded) - }, - getEvseIdByConnectorId (connectorId: number): number | undefined { - return this.iterateConnectors().find(({ connectorId: id }) => id === connectorId)?.evseId - }, - getEvseIdByTransactionId (transactionId: number | string | undefined): number | undefined { - if (transactionId == null) { - return undefined - } - return this.iterateConnectors().find( - ({ connectorStatus }) => connectorStatus.transactionId === transactionId - )?.evseId - }, - getEvseStatus (evseId: number): EvseStatus | undefined { - return evses.get(evseId) - }, - getHeartbeatInterval (): number { - return heartbeatInterval * 1000 // Return in ms - }, - getLocalAuthListEnabled (): boolean { - const key = getConfigurationKey( - this as unknown as ChargingStation, - StandardParametersKey.LocalAuthListEnabled - ) - return key?.value != null ? convertToBoolean(key.value) : false - }, - getNumberOfConnectors (): number { - return this.iterateConnectors(true).reduce(count => count + 1, 0) - }, - getNumberOfEvses (): number { - return evses.has(0) ? evses.size - 1 : evses.size - }, - getNumberOfPhases (): number { - return stationInfoOverrides?.numberOfPhases ?? 3 - }, - getNumberOfRunningTransactions (): number { - return this.iterateConnectors(true).reduce( - (count, { connectorStatus }) => - connectorStatus.transactionStarted === true ? count + 1 : count, - 0 - ) - }, - getReservationBy (filterKey: ReservationKey, value: number | string): Reservation | undefined { - return this.iterateConnectors().find( - ({ connectorStatus }) => connectorStatus.reservation?.[filterKey] === value - )?.connectorStatus.reservation - }, - getTransactionIdTag (transactionId: number): string | undefined { - return this.iterateConnectors().find( - ({ connectorStatus }) => connectorStatus.transactionId === transactionId - )?.connectorStatus.transactionIdTag - }, - getVoltageOut (): number { - return stationInfoOverrides?.voltageOut ?? 230 - }, - getWebSocketPingInterval (): number { - return websocketPingInterval - }, - - hasConnector (connectorId: number): boolean { - return this.iterateConnectors().some(({ connectorId: id }) => id === connectorId) - }, - - hasEvse (evseId: number): boolean { - return evses.has(evseId) - }, - - // Getters - get hasEvses (): boolean { - return useEvses - }, - - heartbeatSetInterval: undefined as NodeJS.Timeout | undefined, - - idTagsCache: mockIdTagsCache as unknown, - - inAcceptedState (): boolean { - return this.bootNotificationResponse?.status === RegistrationStatusEnumType.ACCEPTED - }, - - // Core properties - index, - inPendingState (): boolean { - return this.bootNotificationResponse?.status === RegistrationStatusEnumType.PENDING - }, - - inRejectedState (): boolean { - return this.bootNotificationResponse?.status === RegistrationStatusEnumType.REJECTED - }, - - inUnknownState (): boolean { - return this.bootNotificationResponse?.status == null - }, - - isChargingStationAvailable (): boolean { - return this.getConnectorStatus(0)?.availability === AvailabilityType.Operative - }, - - isConnectorAvailable (connectorId: number): boolean { - return ( - connectorId > 0 && - this.getConnectorStatus(connectorId)?.availability === AvailabilityType.Operative - ) - }, - - isConnectorReservable (reservationId: number, idTag?: string, connectorId?: number): boolean { - if (connectorId === 0) { - return false - } - const reservation = this.getReservationBy('reservationId', reservationId) - return reservation == null - }, - - isWebSocketConnectionOpened (): boolean { - return this.wsConnection?.readyState === WebSocketReadyState.OPEN - }, - - * iterateConnectors (skipZero = false): Generator { - if (useEvses) { - for (const [evseId, evseStatus] of evses) { - if (skipZero && evseId === 0) continue - for (const [connectorId, connectorStatus] of evseStatus.connectors) { - if (skipZero && connectorId === 0) continue - yield { connectorId, connectorStatus, evseId } - } - } - } else { - for (const [connectorId, connectorStatus] of connectors) { - if (skipZero && connectorId === 0) continue - yield { connectorId, connectorStatus, evseId: undefined } - } - } - }, - - * iterateEvses (skipZero = false): Generator { - for (const [evseId, evseStatus] of evses) { - if (skipZero && evseId === 0) continue - yield { evseId, evseStatus } - } - }, - - listenerCount: () => 0, - - lockConnector (connectorId: number): void { - if (connectorId === 0) { - return - } - if (!this.hasConnector(connectorId)) { - return - } - const connectorStatus = this.getConnectorStatus(connectorId) - if (connectorStatus == null) { - return - } - connectorStatus.locked = true - }, - - logPrefix (): string { - return `${this.stationInfo.chargingStationId} |` - }, - - messageQueue: [] as string[], - - ocppConfiguration: ocppConfiguration ?? { - configurationKey: [], - }, - - ocppIncomingRequestService: { - incomingRequestHandler: async () => { - return await Promise.reject( - new Error( - 'ocppIncomingRequestService.incomingRequestHandler not mocked. Define in createMockChargingStation options.' - ) - ) - }, - stop: (): void => { - throw new Error( - 'ocppIncomingRequestService.stop not mocked. Define in createMockChargingStation options.' - ) - }, - ...ocppIncomingRequestService, - }, - - ocppRequestService: { - requestHandler: async () => { - return await Promise.reject( - new Error( - 'ocppRequestService.requestHandler not mocked. Define in createMockChargingStation options.' - ) - ) - }, - sendError: async () => { - return await Promise.reject( - new Error( - 'ocppRequestService.sendError not mocked. Define in createMockChargingStation options.' - ) - ) - }, - sendResponse: async () => { - return await Promise.reject( - new Error( - 'ocppRequestService.sendResponse not mocked. Define in createMockChargingStation options.' - ) - ) - }, - ...ocppRequestService, - }, - - on: () => station, - - once: () => station, - - performanceStatistics: undefined, - - powerDivider: 1, - - removeAllListeners: () => station, - - removeListener: () => station, - - removeReservation (reservation: Record, _reason?: string): void { - const connectorStatus = this.getConnectorStatus(reservation.connectorId as number) - if (connectorStatus != null) { - delete connectorStatus.reservation - } - }, - requests, - - restartHeartbeat (): void { - this.stopHeartbeat() - this.startHeartbeat() - }, - - restartWebSocketPing (): void { - /* empty */ - }, - - saveOcppConfiguration (): void { - /* empty */ - }, - start (): void { - this.started = true - this.starting = false - }, - started, - - startHeartbeat (): void { - this.heartbeatSetInterval ??= setInterval(() => { - /* empty */ - }, 30000) - }, - starting, - - startWebSocketPing (): void { - /* empty */ - }, - stationInfo: { - autoStart, - baseName, - chargingStationId: `${baseName}-${index.toString().padStart(5, '0')}`, - currentOutType: CurrentType.AC, - hashId: TEST_CHARGING_STATION_HASH_ID, - maximumAmperage: 32, - maximumPower: 22000, - numberOfPhases: 3, - ocppVersion: stationInfoOverrides?.ocppVersion ?? ocppVersion, - remoteAuthorization: true, - templateIndex: index, - templateName: templateFile, - voltageOut: 230, - ...stationInfoOverrides, - }, - - async stop (reason?: StopTransactionReason, stopTransactions?: boolean): Promise { - if (this.started && !this.stopping) { - this.stopping = true - // Simulate async stop behavior (immediate resolution for tests) - await Promise.resolve() - this.closeWSConnection() - this.bootNotificationResponse = undefined - this.started = false - this.stopping = false - } - }, - - stopHeartbeat (): void { - if (this.heartbeatSetInterval != null) { - clearInterval(this.heartbeatSetInterval) - delete this.heartbeatSetInterval - } - }, - stopping: false, - - templateFile, - - unlockConnector (connectorId: number): void { - if (connectorId === 0) { - return - } - if (!this.hasConnector(connectorId)) { - return - } - const connectorStatus = this.getConnectorStatus(connectorId) - if (connectorStatus == null) { - return - } - connectorStatus.locked = false - }, - - wsConnection: null as MockWebSocket | null, - wsConnectionRetryCount: 0, - } - - // Set up mock WebSocket connection - station.wsConnection = mockWebSocket - - const mocks: ChargingStationMocks = { - fileSystem: { - readFiles, - writtenFiles, - }, - idTagsCache: mockIdTagsCache, - parentPortMessages, - sharedLRUCache: mockSharedLRUCache, - webSocket: mockWebSocket, - } - - const typedStation: ChargingStation = station as unknown as ChargingStation - - return { - mocks, - station: typedStation, - } -} - -/** - * Create a mock charging station template for testing - * @param baseName - Base name for the template - * @returns ChargingStationTemplate with minimal required properties for testing - */ -export function createMockChargingStationTemplate ( - baseName: string = TEST_CHARGING_STATION_BASE_NAME -): ChargingStationTemplate { - return { - baseName, - } as ChargingStationTemplate -} - -/** - * Reset a ChargingStation to its initial state - * - * Resets all connector statuses, clears transactions, and restores defaults. - * Useful between test cases when reusing a station instance. - * @param station - ChargingStation instance to reset - * @example - * ```typescript - * beforeEach(() => { - * resetChargingStationState(station) - * }) - * ``` - */ -export function resetChargingStationState (station: ChargingStation): void { - // Reset station state - station.started = false - station.starting = false - - // Reset boot notification response - if (station.bootNotificationResponse != null) { - station.bootNotificationResponse.status = RegistrationStatusEnumType.ACCEPTED - station.bootNotificationResponse.currentTime = new Date() - } - - // Reset connector statuses - for (const { connectorId, connectorStatus } of station.iterateConnectors()) { - resetConnectorStatus(connectorStatus, connectorId === 0) - } - - // Reset EVSE availability - for (const { evseStatus } of station.iterateEvses()) { - evseStatus.availability = AvailabilityType.Operative - } - - // Clear requests - station.requests.clear() - - // Clear WebSocket messages if using MockWebSocket - const ws = station.wsConnection as unknown as MockWebSocket | null - if (ws != null && 'clearMessages' in ws) { - ws.clearMessages() - } -} - -/** - * Determines whether EVSEs should be used based on configuration - * @param options - Configuration options to check - * @returns True if EVSEs should be used, false otherwise - */ -function determineEvseUsage (options: MockChargingStationOptions): boolean { - // If explicitly set to 0, don't use EVSEs - if (options.evseConfiguration?.evsesCount === 0) { - return false - } - // Get the ocppVersion from stationInfo overrides or options - const effectiveOcppVersion = options.stationInfo?.ocppVersion ?? options.ocppVersion - return ( - options.evseConfiguration?.evsesCount != null || - effectiveOcppVersion === OCPPVersion.VERSION_20 || - effectiveOcppVersion === OCPPVersion.VERSION_201 - ) -} - -/** - * Reset a single connector status to default values - * @param status - Connector status object to reset - * @param isConnectorZero - Whether this is connector 0 (station-level) - */ -function resetConnectorStatus (status: ConnectorStatus, isConnectorZero: boolean): void { - status.availability = AvailabilityType.Operative - status.status = isConnectorZero ? undefined : ConnectorStatusEnum.Available - status.transactionId = undefined - status.transactionIdTag = undefined - status.transactionStart = undefined - status.transactionStarted = false - status.transactionRemoteStarted = false - status.idTagAuthorized = false - status.idTagLocalAuthorized = false - status.energyActiveImportRegisterValue = 0 - status.transactionEnergyActiveImportRegisterValue = 0 - - if (status.transactionUpdatedMeterValuesSetInterval != null) { - clearInterval(status.transactionUpdatedMeterValuesSetInterval) - status.transactionUpdatedMeterValuesSetInterval = undefined - } - - status.transactionEndedMeterValues = undefined - if (status.transactionEndedMeterValuesSetInterval != null) { - clearInterval(status.transactionEndedMeterValuesSetInterval) - status.transactionEndedMeterValuesSetInterval = undefined - } -} +export * from './StationHelpers.cleanup.js' +export * from './StationHelpers.connector.js' +export * from './StationHelpers.factory.js' +export * from './StationHelpers.template.js' +export * from './StationHelpers.types.js' diff --git a/tests/charging-station/helpers/StationHelpers.types.ts b/tests/charging-station/helpers/StationHelpers.types.ts new file mode 100644 index 00000000..7384a06e --- /dev/null +++ b/tests/charging-station/helpers/StationHelpers.types.ts @@ -0,0 +1,152 @@ +/** + * @file Shared types for StationHelpers modular files (mocks, options, result). + */ + +import type { ChargingStation } from '../../../src/charging-station/index.js' +import type { + ChargingStationInfo, + ChargingStationOcppConfiguration, +} from '../../../src/types/index.js' +import type { + AvailabilityType, + ConnectorStatusEnum, + OCPPVersion, + RegistrationStatusEnumType, +} from '../../../src/types/index.js' +import type { MockIdTagsCache, MockSharedLRUCache } from '../mocks/MockCaches.js' +import type { MockWebSocket } from '../mocks/MockWebSocket.js' + +/** + * Collection of all mocks used in a real ChargingStation instance + */ +export interface ChargingStationMocks { + /** Mock file system operations */ + fileSystem: { + readFiles: Map + writtenFiles: Map + } + + /** Mock IdTagsCache */ + idTagsCache: MockIdTagsCache + + /** Mock parentPort messages */ + parentPortMessages: unknown[] + + /** Mock SharedLRUCache */ + sharedLRUCache: MockSharedLRUCache + + /** Mock WebSocket connection */ + webSocket: MockWebSocket +} + +/** + * Options for customizing connector status creation + */ +export interface CreateConnectorStatusOptions { + /** Override availability (default: AvailabilityType.Operative) */ + availability?: AvailabilityType + /** Override status (default: ConnectorStatusEnum.Available) */ + status?: ConnectorStatusEnum +} + +/** + * Mock type combining ChargingStation with optional test-specific properties. + */ +export type MockChargingStation = ChargingStation & { + getNumberOfRunningTransactions?: () => number + reset?: () => Promise +} + +/** + * Options for creating a mock ChargingStation instance + */ +export interface MockChargingStationOptions { + /** Auto-start the station on creation */ + autoStart?: boolean + + /** Station base name */ + baseName?: string + + /** Initial boot notification status */ + bootNotificationStatus?: RegistrationStatusEnumType + + /** Connection timeout in milliseconds */ + connectionTimeout?: number + + /** Default connector status overrides */ + connectorDefaults?: { + availability?: AvailabilityType + status?: ConnectorStatusEnum + } + + /** Number of connectors (default: 2) */ + connectorsCount?: number + + /** EVSE configuration for OCPP 2.0 - enables EVSE mode when present */ + evseConfiguration?: { + evsesCount?: number + } + + /** Heartbeat interval in seconds */ + heartbeatInterval?: number + + /** Station index (default: 1) */ + index?: number + + /** OCPP configuration with configuration keys */ + ocppConfiguration?: ChargingStationOcppConfiguration + + /** Custom OCPP incoming request service for test mocking */ + ocppIncomingRequestService?: Partial + + /** Custom OCPP request service for test mocking */ + ocppRequestService?: Partial + + /** OCPP version (default: '1.6') */ + ocppVersion?: OCPPVersion + + /** Whether station is started */ + started?: boolean + + /** Whether station is starting */ + starting?: boolean + + /** Station info overrides */ + stationInfo?: Partial + + /** Template file path (mocked) */ + templateFile?: string + + /** WebSocket ping interval in seconds */ + websocketPingInterval?: number +} + +/** + * Result of creating a mock ChargingStation instance + */ +export interface MockChargingStationResult { + /** All mocks used by the station for assertion */ + mocks: ChargingStationMocks + + /** The actual ChargingStation instance */ + station: ChargingStation +} + +/** + * Mock OCPP incoming request service interface for testing + * Provides typed access to mock handlers without eslint-disable comments + */ +export interface MockOCPPIncomingRequestService { + incomingRequestHandler: () => Promise + stop: () => void +} + +/** + * Mock OCPP request service interface for testing + * Provides typed access to mock handlers without eslint-disable comments + */ +export interface MockOCPPRequestService { + requestHandler: (...args: unknown[]) => Promise + sendError: (...args: unknown[]) => Promise + sendResponse: (...args: unknown[]) => Promise +} diff --git a/tests/charging-station/meter-values/CoherentMeterValues.test.ts b/tests/charging-station/meter-values/CoherentMeterValues.test.ts index 618f5756..e0f082c3 100644 --- a/tests/charging-station/meter-values/CoherentMeterValues.test.ts +++ b/tests/charging-station/meter-values/CoherentMeterValues.test.ts @@ -1591,4 +1591,285 @@ await describe('CoherentMeterValues', async () => { ) }) }) + + await describe('powerFactor', async () => { + await it('should raise emitted current inversely proportional to powerFactor when set to 0.95', () => { + const flatProfile: EvProfile = { + ...baseProfile, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 100 }, + ], + } + const flatProfileWithPF: EvProfile = { ...flatProfile, powerFactor: 0.95 } + const runOnce = (profile: EvProfile): number => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 7400, + numberOfPhases: 1, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [profile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + return computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }).currentA + } + const baselineCurrent = runOnce(flatProfile) + const currentWithPF = runOnce(flatProfileWithPF) + const expectedRatio = 1 / 0.95 + const actualRatio = currentWithPF / baselineCurrent + assert.ok( + Math.abs(actualRatio - expectedRatio) < 0.01, + `powerFactor=0.95 must raise current inversely proportional: ratio=${actualRatio.toString()} expected=${expectedRatio.toString()}` + ) + }) + + await it('should preserve baseline behavior when powerFactor is absent', () => { + const flatProfile: EvProfile = { + ...baseProfile, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 100 }, + ], + } + const flatProfileWithUnity: EvProfile = { ...flatProfile, powerFactor: 1 } + const runOnce = (profile: EvProfile): { currentA: number; powerW: number } => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 22000, + numberOfPhases: 3, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [profile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }) + return { currentA: sample.currentA, powerW: sample.powerW } + } + const baseline = runOnce(flatProfile) + const withUnity = runOnce(flatProfileWithUnity) + assert.strictEqual( + baseline.currentA, + withUnity.currentA, + 'absent powerFactor must produce identical current to powerFactor=1' + ) + assert.strictEqual( + baseline.powerW, + withUnity.powerW, + 'absent powerFactor must produce identical power to powerFactor=1' + ) + }) + + await it('should ignore powerFactor on DC profiles (no reactive component)', () => { + const flatProfile: EvProfile = { + ...baseProfile, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 100 }, + ], + } + const flatProfileWithPF: EvProfile = { ...flatProfile, powerFactor: 0.7 } + const runOnce = (profile: EvProfile): { currentA: number; powerW: number } => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.DC, + evseMaxPowerW: 50000, + numberOfPhases: 0, + voltageOut: 400, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [profile], + rampUpDurationMs: 0, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const sample = computeCoherentSample(context, connectorStatus, session, { + intervalMs: TEST_METER_VALUES_INTERVAL_MS, + nowMs: TEST_METER_VALUES_INTERVAL_MS, + rootSeed: 42, + voltageNoise: false, + }) + return { currentA: sample.currentA, powerW: sample.powerW } + } + const baseline = runOnce(flatProfile) + const withPF = runOnce(flatProfileWithPF) + assert.strictEqual( + baseline.currentA, + withPF.currentA, + 'DC powerFactor=0.7 must produce identical current to DC without powerFactor' + ) + assert.strictEqual( + baseline.powerW, + withPF.powerW, + 'DC powerFactor=0.7 must produce identical power to DC without powerFactor' + ) + }) + }) + + await describe("rampShape 'sigmoid'", async () => { + await it('should pin f(0)=0, f(rampUpDurationMs)=1, and f(midpoint)=0.5 within ±0.01', () => { + const sigmoidProfile: EvProfile = { + ...baseProfile, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 100 }, + ], + rampShape: 'sigmoid', + } + const rampUpDurationMs = 60000 + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 7400, + numberOfPhases: 1, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [sigmoidProfile], + rampUpDurationMs, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const powerAtSocConstant = (nowMs: number): number => { + session.socPercent = 30 + return computeCoherentSample(context, connectorStatus, session, { + intervalMs: 1, + nowMs, + rootSeed: 42, + voltageNoise: false, + }).powerW + } + const fullPower = powerAtSocConstant(rampUpDurationMs) + const atStart = powerAtSocConstant(0) + const atQuarter = powerAtSocConstant(rampUpDurationMs / 4) + const atMid = powerAtSocConstant(rampUpDurationMs / 2) + const atThreeQuarter = powerAtSocConstant((3 * rampUpDurationMs) / 4) + const atEnd = powerAtSocConstant(rampUpDurationMs) + assert.strictEqual(atStart, 0, 'sigmoid must be 0 at t=0 exactly') + assert.ok( + Math.abs(atMid / fullPower - 0.5) < 0.01, + `sigmoid must be 0.5 at midpoint: got ${(atMid / fullPower).toString()}` + ) + assert.ok( + Math.abs(atEnd / fullPower - 1) < 1e-9, + `sigmoid must be 1 at t=rampUpDurationMs: got ${(atEnd / fullPower).toString()}` + ) + // Interior curvature: sigmoid diverges from linear at the quarter + // points. Linear would emit 0.25 and 0.75 of fullPower; sigmoid + // (k=10) emits about 0.07 and 0.93 (analytical value from the + // shift-scale logistic with SIGMOID_STEEPNESS=10). The tolerance + // 0.05 is well below the ~0.18 gap between sigmoid and linear at + // these points, so a silent revert of the sigmoid branch back to + // a linear ramp shape would fail this assertion. + assert.ok( + atQuarter / fullPower < 0.15, + `sigmoid must curve below 0.15 at t=rampUpDurationMs/4 (linear would emit 0.25): got ${(atQuarter / fullPower).toString()}` + ) + assert.ok( + atThreeQuarter / fullPower > 0.85, + `sigmoid must curve above 0.85 at t=3*rampUpDurationMs/4 (linear would emit 0.75): got ${(atThreeQuarter / fullPower).toString()}` + ) + }) + + await it('should preserve linear baseline when rampShape is absent or explicit linear', () => { + const flatProfile: EvProfile = { + ...baseProfile, + chargingCurve: [ + { powerFraction: 1, socPercent: 0 }, + { powerFraction: 1, socPercent: 100 }, + ], + } + const explicitLinearProfile: EvProfile = { ...flatProfile, rampShape: 'linear' } + const rampUpDurationMs = 60000 + const runOnce = (profile: EvProfile): number[] => { + const { connectorStatus, context, sessions } = buildContext({ + currentType: CurrentType.AC, + evseMaxPowerW: 7400, + numberOfPhases: 1, + voltageOut: 230, + }) + const session = createSessionOrFail(context, { + connectorId: 1, + now: 0, + profiles: [profile], + rampUpDurationMs, + rootSeed: 42, + transactionId: 1, + }) + sessions.set(1, session) + const samples: number[] = [] + for (const nowMs of [ + 0, + rampUpDurationMs / 4, + rampUpDurationMs / 2, + (rampUpDurationMs * 3) / 4, + rampUpDurationMs, + ]) { + session.socPercent = 30 + samples.push( + computeCoherentSample(context, connectorStatus, session, { + intervalMs: 1, + nowMs, + rootSeed: 42, + voltageNoise: false, + }).powerW + ) + } + return samples + } + const absentSamples = runOnce(flatProfile) + const explicitSamples = runOnce(explicitLinearProfile) + assert.deepStrictEqual( + absentSamples, + explicitSamples, + 'absent rampShape must produce identical ramp curve to explicit linear' + ) + const fullPower = absentSamples[4] + assert.ok(fullPower > 0, 'full power must be reached at t=rampUpDurationMs') + const quarterRatio = absentSamples[1] / fullPower + const midRatio = absentSamples[2] / fullPower + const threeQuarterRatio = absentSamples[3] / fullPower + // Tolerance covers the two-decimal rounding applied to the emitted + // powerW at each sample (roundedV × roundedCurrent × phases → 2 dp). + const rampTolerance = 0.001 + assert.ok( + Math.abs(quarterRatio - 0.25) < rampTolerance, + `linear ramp must be 0.25 at t=T/4: got ${quarterRatio.toString()}` + ) + assert.ok( + Math.abs(midRatio - 0.5) < rampTolerance, + `linear ramp must be 0.5 at midpoint: got ${midRatio.toString()}` + ) + assert.ok( + Math.abs(threeQuarterRatio - 0.75) < rampTolerance, + `linear ramp must be 0.75 at t=3T/4: got ${threeQuarterRatio.toString()}` + ) + }) + }) })