From: Jérôme Benoit Date: Tue, 4 Aug 2026 20:04:07 +0000 (+0200) Subject: feat(simulator): add AC/DC conversion efficiency template support (#2063) X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=bd100667af56f495e8458ead117bb7521ba434a6;p=e-mobility-charging-stations-simulator.git feat(simulator): add AC/DC conversion efficiency template support (#2063) Add optional template tunable conversionEfficiency (float, (0, 1], default 1) reducing available charging power on DC stations only (currentOutType === DC). Applied at runtime in getConnectorMaximumAvailablePower to both power-derived bounds; amperage and charging-profile limits unchanged; no reduced value persisted. Absent field keeps existing behavior. Closes #443 --- diff --git a/README.md b/README.md index 15abb2f6..66ffaf39 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ But the modifications to test have to be done to the files in the build target d | powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors. When true, any single connector can draw up to the full station power; when false, each connector is allocated an equal share | | powerUnit | W/kW | W | string | charging stations power unit | | currentOutType | AC/DC | AC | string | charging stations current out type | +| conversionEfficiency | (0, 1] | 1 | float | charging stations AC input to DC output power conversion efficiency, applied to the available charging power on DC stations only (currentOutType DC); leaves maximumPower and maximumAmperage unchanged | | voltageOut | | AC:230/DC:400 | integer | charging stations voltage out | | numberOfPhases | 0/1/3 | AC:3/DC:0 | integer | charging stations number of phase(s) | | numberOfConnectors | | | integer \| integer[] | charging stations number of connector(s) | diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 41fa2cf5..f225b29d 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -618,7 +618,7 @@ export class ChargingStation extends EventEmitter { ) { const voltageOut = this.getVoltageOut() connectorAmperageLimitationLimit = - (this.stationInfo?.currentOutType === CurrentType.AC + (this.getCurrentOutType() === CurrentType.AC ? ACElectricUtils.powerTotal( this.getNumberOfPhases(), voltageOut, @@ -636,8 +636,23 @@ export class ChargingStation extends EventEmitter { ) return Number.POSITIVE_INFINITY } - const connectorMaximumPower = maximumPower / (this.powerDivider ?? 1) - const connectorHardwareMaximumPower = this.getConnectorStatus(connectorId)?.maximumPower + // On DC, the template maximumPower is the AC input-side power; the + // power actually available for charging is input * conversion efficiency. + // AC => 1 (not applied). Absent => 1 (backward compatible). Applied to both + // power-derived bounds: the connector hardware bound is an input-side power + // rating too (its default derives from the AC template power; an explicit + // Connectors[n].maximumPower is configured directly), so it must be reduced + // as well and cannot short-circuit the factor through min(). + const conversionEfficiency = + this.getCurrentOutType() === CurrentType.DC + ? (this.stationInfo?.conversionEfficiency ?? 1) + : 1 + const connectorMaximumPower = (maximumPower / (this.powerDivider ?? 1)) * conversionEfficiency + const connectorHardwareMaximumPowerInput = this.getConnectorStatus(connectorId)?.maximumPower + const connectorHardwareMaximumPower = + connectorHardwareMaximumPowerInput == null + ? undefined + : connectorHardwareMaximumPowerInput * conversionEfficiency const chargingStationChargingProfilesLimit = (getChargingStationChargingProfilesLimit(this) ?? Number.POSITIVE_INFINITY) / (this.powerDivider ?? 1) diff --git a/src/charging-station/TemplateSchema.ts b/src/charging-station/TemplateSchema.ts index 5f22dd66..6a87eaca 100644 --- a/src/charging-station/TemplateSchema.ts +++ b/src/charging-station/TemplateSchema.ts @@ -185,6 +185,7 @@ const BaseTemplateSchema = z.looseObject({ commandsSupport: CommandsSupportSchema.optional(), Configuration: OcppConfigurationSchema.optional(), Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema).optional(), + conversionEfficiency: z.number().positive().max(1).optional(), currentOutType: z.string().optional(), customValueLimitationMeterValues: z.boolean().optional(), enableStatistics: z.boolean().optional(), diff --git a/src/types/ChargingStationTemplate.ts b/src/types/ChargingStationTemplate.ts index b9a2cd09..03b4126d 100644 --- a/src/types/ChargingStationTemplate.ts +++ b/src/types/ChargingStationTemplate.ts @@ -73,6 +73,18 @@ export interface ChargingStationTemplate { commandsSupport?: CommandsSupport Configuration?: ChargingStationOcppConfiguration Connectors?: Record + /** + * AC-input to DC-output power conversion efficiency, in (0, 1]. DC stations + * only (`currentOutType === CurrentType.DC`). Absent ⇒ 1 (no reduction, + * backward compatible). Reduces only the available charging power returned by + * `getConnectorMaximumAvailablePower`, and the DC output-side MeterValues + * derived from it (Power, Current, Energy), which are assumed to be measured + * at the connector outlet (the OCPP default `location` for these measurands). + * `stationInfo.maximumPower` (the AC input-side power) and + * `stationInfo.maximumAmperage` (derived from it as `maximumPower / voltageOut` + * on DC) are left unchanged and are not reduced by this factor. + */ + conversionEfficiency?: number currentOutType?: CurrentType customValueLimitationMeterValues?: boolean enableStatistics?: boolean diff --git a/tests/charging-station/ChargingStation-ConversionEfficiency.test.ts b/tests/charging-station/ChargingStation-ConversionEfficiency.test.ts new file mode 100644 index 00000000..46a28820 --- /dev/null +++ b/tests/charging-station/ChargingStation-ConversionEfficiency.test.ts @@ -0,0 +1,126 @@ +/** + * @file Tests for template AC/DC conversion efficiency. + * @description On DC stations, template `maximumPower` is the AC input-side + * power; the power available for charging is `input * conversionEfficiency`. + * The factor is applied at runtime in `getConnectorMaximumAvailablePower` only + * (DC-only, absent => 1). `stationInfo.maximumPower` (the AC input-side power) + * and `stationInfo.maximumAmperage` (derived from it as `maximumPower / + * voltageOut` on DC) are left unchanged and are not reduced by the factor. + */ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, it } from 'node:test' + +import { ChargingStation } from '../../src/charging-station/ChargingStation.js' +import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' + +const POWER_W = 50000 + +const tmpRoots: string[] = [] + +interface TemplateOverrides { + connectorMaximumPower?: number + conversionEfficiency?: number + currentOutType?: string +} + +// Fresh DC template with powerSharedByConnectors:false (deterministic +// powerDivider = number of connectors). By default the connectors carry no +// explicit maximumPower, so the per-connector default power is derived from the +// station power and the station bound is the binding one; an explicit +// connectorMaximumPower override makes the connector hardware bound binding +// instead, to exercise the second power-derived term of the min(). +const makeTemplate = (overrides: TemplateOverrides = {}): string => { + const root = mkdtempSync(join(tmpdir(), 'cs-conversion-efficiency-')) + tmpRoots.push(root) + mkdirSync(join(root, 'station-templates'), { recursive: true }) + const file = join(root, 'station-templates', 'dc.station-template.json') + const connectorMaximumPower = + overrides.connectorMaximumPower != null ? { maximumPower: overrides.connectorMaximumPower } : {} + const template: Record = { + $schemaVersion: 1, + baseName: 'TEST-CONVERSION-EFFICIENCY', + chargePointModel: 'Simulator simple', + chargePointVendor: 'Simulator', + Connectors: { + 0: {}, + 1: { bootStatus: 'Available', ...connectorMaximumPower }, + 2: { bootStatus: 'Available', ...connectorMaximumPower }, + }, + currentOutType: overrides.currentOutType ?? 'DC', + numberOfConnectors: 2, + power: POWER_W, + powerSharedByConnectors: false, + powerUnit: 'W', + randomConnectors: false, + ...(overrides.conversionEfficiency != null + ? { conversionEfficiency: overrides.conversionEfficiency } + : {}), + } + writeFileSync(file, JSON.stringify(template), 'utf8') + return file +} + +const newStation = (overrides: TemplateOverrides = {}): ChargingStation => + new ChargingStation(1, makeTemplate(overrides), { + autoStart: false, + baseName: 'TEST-CONVERSION-EFFICIENCY', + fixedName: true, + persistentConfiguration: false, + supervisionUrls: 'ws://localhost:9999/', + }) + +await describe('ChargingStation AC/DC conversion efficiency', async () => { + afterEach(() => { + standardCleanup() + for (const root of tmpRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }) + } + }) + + await it('reduces the DC connector available power by the efficiency factor', () => { + const baseline = newStation().getConnectorMaximumAvailablePower(1) + const reduced = newStation({ conversionEfficiency: 0.9 }).getConnectorMaximumAvailablePower(1) + assert.ok(Number.isFinite(baseline) && baseline > 0) + assert.ok(Math.abs(reduced - baseline * 0.9) < 1e-6) + }) + + await it('reduces the binding DC connector hardware power bound by the factor', () => { + // Explicit per-connector hardware bound (10000 W) below the station-derived + // bound (power / connectors = 50000 / 2 = 25000 W) so the hardware term is + // the one selected by min(); it must itself be reduced by the factor. + const baseline = newStation({ + connectorMaximumPower: 10000, + }).getConnectorMaximumAvailablePower(1) + const reduced = newStation({ + connectorMaximumPower: 10000, + conversionEfficiency: 0.9, + }).getConnectorMaximumAvailablePower(1) + assert.strictEqual(baseline, 10000) + assert.ok(Math.abs(reduced - 10000 * 0.9) < 1e-6) + }) + + await it('leaves DC connector available power unchanged when efficiency is absent', () => { + const withoutField = newStation().getConnectorMaximumAvailablePower(1) + const withUnity = newStation({ conversionEfficiency: 1 }).getConnectorMaximumAvailablePower(1) + assert.strictEqual(withoutField, withUnity) + }) + + await it('ignores the efficiency factor on AC stations', () => { + const acBaseline = newStation({ currentOutType: 'AC' }).getConnectorMaximumAvailablePower(1) + const acWithEfficiency = newStation({ + conversionEfficiency: 0.9, + currentOutType: 'AC', + }).getConnectorMaximumAvailablePower(1) + assert.strictEqual(acWithEfficiency, acBaseline) + }) + + await it('does not reduce stationInfo.maximumPower or maximumAmperage', () => { + const baseline = newStation() + const reduced = newStation({ conversionEfficiency: 0.9 }) + assert.strictEqual(reduced.stationInfo?.maximumPower, baseline.stationInfo?.maximumPower) + assert.strictEqual(reduced.stationInfo?.maximumAmperage, baseline.stationInfo?.maximumAmperage) + }) +}) diff --git a/tests/charging-station/TemplateSchema.test.ts b/tests/charging-station/TemplateSchema.test.ts index 855e4aa2..2bbbc7fe 100644 --- a/tests/charging-station/TemplateSchema.test.ts +++ b/tests/charging-station/TemplateSchema.test.ts @@ -72,6 +72,26 @@ await describe('TemplateSchema', async () => { }) }) + await describe('conversionEfficiency', async () => { + await it('should accept an absent conversionEfficiency (backward compatible)', () => { + assert.ok(TemplateSchema.safeParse(buildMinimalTemplate()).success) + }) + + for (const conversionEfficiency of [0.5, 0.9, 1]) { + await it(`should accept conversionEfficiency ${conversionEfficiency.toString()}`, () => { + assert.ok(TemplateSchema.safeParse(buildMinimalTemplate({ conversionEfficiency })).success) + }) + } + + for (const conversionEfficiency of [0, -0.1, 1.5, '0.9']) { + await it(`should reject conversionEfficiency ${JSON.stringify(conversionEfficiency)}`, () => { + const result = TemplateSchema.safeParse(buildMinimalTemplate({ conversionEfficiency })) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.path.includes('conversionEfficiency'))) + }) + } + }) + await describe('deprecated keys rejection', async () => { for (const [legacyKey, legacyValue] of [ ['supervisionUrl', TEST_SUPERVISION_URL],