]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix: resolve #1244 — add per-connector maximum power support (#1843)
authorgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sat, 9 May 2026 17:15:55 +0000 (19:15 +0200)
committerGitHub <noreply@github.com>
Sat, 9 May 2026 17:15:55 +0000 (19:15 +0200)
* feat(charging-station): add per-connector maximum power support

Add maximumPower field to ConnectorStatus representing the physical
limitation of each connector cable/plug (thermal current rating).

Per OCPP Device Model, AvailablePowerMaxLimit is defined at the
Connector component level. The connector maximumPower acts as a
hardware cap in the power computation pipeline alongside the station-
level powerDivider sharing mechanism.

- Add ConnectorStatus.maximumPower?: number (in W)
- Initialize at boot via initializeConnectorsMaximumPower(): default
  is stationPower / staticConnectorCount (using static count, not
  dynamic powerDivider which can be 0 in shared mode at init)
- Clamp in getConnectorMaximumAvailablePower as additional min() term
- Use in getConnectorChargingProfilesLimit as primary cap (falls back
  to stationPower/powerDivider for backward compat)
- Update 6 shared-mode templates with explicit maximumPower per
  connector (= station power for DC shared-bus stations)

Resolves #1244

* chore(sandcastle): update validation and main scripts

* chore: sync release-please manifests and sandcastle prompt

* fix(charging-station): exclude index 0 from staticCount in getDefaultConnectorMaximumPower

The staticCount calculation included EVSE 0 and connector 0, while runtime
getPowerDivider excludes them. This caused connector hardware caps to be
more restrictive than intended (e.g., stationPower/3 instead of
stationPower/2 on a 2-EVSE station with EVSE 0 defined).

* [autofix.ci] apply automated fixes

* refactor(charging-station): add NaN guard to connectorHardwareMaximumPower in min()

Align the connectorHardwareMaximumPower entry with the same null/NaN guard
pattern used by all other entries in the min() call for consistency and
defensive robustness.

* docs: document per-connector maximumPower in template examples

Add maximumPower field to Connectors and Evses section examples.
Clarify powerSharedByConnectors behavior description.

---------

Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
14 files changed:
.sandcastle/main.ts
.sandcastle/plan-prompt.md
.sandcastle/strategies/implement/critic-prompt.md
.sandcastle/validation.ts
README.md
src/assets/station-templates/abb-atg.station-template.json
src/assets/station-templates/abb.station-template.json
src/assets/station-templates/virtual-simple-atg.station-template.json
src/assets/station-templates/virtual-simple-signed.station-template.json
src/assets/station-templates/virtual-simple.station-template.json
src/assets/station-templates/virtual.station-template.json
src/charging-station/ChargingStation.ts
src/charging-station/Helpers.ts
src/types/ConnectorStatus.ts

index f5a21acf7eb44b933a8a9d86291a0bb32a9508b7..bfed3f7ba4b00060f746a662e79e7be0e2ecd6b5 100644 (file)
@@ -84,7 +84,7 @@ if (tasks.length === 0) {
     if (outcome.status === 'rejected') {
       const reason: unknown = outcome.reason
       const msg = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)
-      console.error(`  ✗ #${tasks[i].id} failed: ${msg}`)
+      console.error(`  ✗ #${tasks[i]?.id ?? String(i)} failed: ${msg}`)
     }
   }
 
index 3108d2444d1b6fcfe5ead3c0884eed9697c53a95..547951ded0d4775e8ccf62b9cff96c2b23f611b7 100644 (file)
@@ -26,7 +26,7 @@ Read `AGENTS.md` and `.serena/memories/project_overview`.
    - Classify the issue type: `bug-fix`, `feature`, or `refactor`.
    - Assess your confidence: `high` (clear scope, obvious approach), `medium` (some ambiguity), or `low` (unclear scope, multiple valid approaches).
    - Formulate a root cause hypothesis: what is broken or missing, and why. This is a hypothesis for the implementer to validate — not a directive.
-   - Define 2-4 acceptance criteria: concrete, verifiable conditions that must be true when the implementation is complete. Focus on observable behavior, not implementation details.
+   - Define 2-4 acceptance criteria: concrete, verifiable conditions that must be true when the implementation is complete. Focus on code structure, algorithmic and logic, not runtime behavior.
 
 4. Output the plan in this exact format:
 
@@ -47,7 +47,7 @@ Read `AGENTS.md` and `.serena/memories/project_overview`.
   ```
 
 - Do not implement anything. Only produce the plan.
-- Acceptance criteria must be testable by reading code or running tests — no subjective assessments.
+- Acceptance criteria must be verifiable by static code inspection of the diff.
 - Root cause hypothesis should be specific (mention modules, patterns, or behaviors) — not a restatement of the issue title.
 
 ## Completion
index 2caa8bbc8d9e2af573dbf49c3487d37446ed69c8..d7caf81d3d3f1a36d62a60e6704f869eb5ad4be9 100644 (file)
@@ -12,7 +12,7 @@ Read `AGENTS.md`, `CONTRIBUTING.md` and `.serena/memories/code_style_conventions
 
 {{ACCEPTANCE_CRITERIA}}
 
-If acceptance criteria are listed above, verify that the implementation satisfies each one. Report a HIGH finding for any criterion that is not met. Do NOT evaluate whether the actor followed a specific implementation approach — only whether the observable outcome matches the criteria. If no criteria are listed, skip this section.
+If acceptance criteria are listed above, assess from the diff whether the implementation satisfies each one. Report a HIGH finding for any unmet criterion. Only judge observable outcomes, not implementation approach. If no criteria are listed, skip this section.
 
 ## Output Format
 
index ab4944931e44cce9dadc2915e2fcf27ea3f944a2..ee4730aec2d75e38ccf7a316410a37b199f377ec 100644 (file)
@@ -7,17 +7,26 @@ import { execFileAsync } from './utils.js'
  * Runs the full validation suite.
  * @param cwd - Working directory (worktree path).
  * @param spec - Optional task specification (used for logging).
+ * @param signal - Optional abort signal for cooperative cancellation.
  * @returns `true` if validation passed, `false` otherwise.
  */
-export async function runValidation (cwd: string, spec?: TaskSpec): Promise<boolean> {
+export async function runValidation (
+  cwd: string,
+  spec?: TaskSpec,
+  signal?: AbortSignal
+): Promise<boolean> {
   try {
     await execFileAsync('sh', ['-c', VALIDATION_COMMAND], {
       cwd,
       maxBuffer: 8 * 1024 * 1024,
+      signal,
       timeout: VALIDATION_TIMEOUT_MS,
     })
     return true
   } catch (err: unknown) {
+    if (signal?.aborted === true) {
+      throw err
+    }
     if (err && typeof err === 'object' && 'killed' in err && (err as { killed: boolean }).killed) {
       const label = spec ? `#${spec.id}` : 'mid-loop'
       console.warn(`  ${label}: Validation timed out after ${String(VALIDATION_TIMEOUT_MS)}ms.`)
index cef30888568b28269cfc6a0add0888749e61154c..83184f5d2d12770b0310a76bd5526a4c29350337 100644 (file)
--- a/README.md
+++ b/README.md
@@ -228,7 +228,7 @@ But the modifications to test have to be done to the files in the build target d
 | firmwareVersionPattern                               |               | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string                                                                                                                                                                        | charging stations firmware version pattern                                                                                                                                                                                                        |
 | firmwareVersion                                      |               | undefined                                                                                                                          | string                                                                                                                                                                        | charging stations firmware version                                                                                                                                                                                                                |
 | power                                                |               |                                                                                                                                    | float \| float[]                                                                                                                                                              | charging stations maximum power value(s)                                                                                                                                                                                                          |
-| powerSharedByConnectors                              | true/false    | false                                                                                                                              | boolean                                                                                                                                                                       | charging stations power shared by its connectors                                                                                                                                                                                                  |
+| 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                                                                                                                                                                                                                |
 | voltageOut                                           |               | AC:230/DC:400                                                                                                                      | integer                                                                                                                                                                       | charging stations voltage out                                                                                                                                                                                                                     |
@@ -330,6 +330,7 @@ type AutomaticTransactionGeneratorConfiguration = {
     "0": {},
     "1": {
       "bootStatus": "Available",
+      "maximumPower": 50000,
       "MeterValues": [
         ...
         {
@@ -412,6 +413,7 @@ type AutomaticTransactionGeneratorConfiguration = {
       "Connectors": {
         "1": {
           "bootStatus": "Available",
+          "maximumPower": 22080,
           "MeterValues": [
             ...
             {
index 44e2435c3c775804d8b94bd6e198ac5d5ca28c41..b9c9aaa6ac079def46c5b1c9cf0535ee5fc7487e 100644 (file)
@@ -67,6 +67,7 @@
   "Connectors": {
     "0": {},
     "1": {
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
@@ -94,6 +95,7 @@
       ]
     },
     "2": {
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
index 1930ef5e8a6968187008dc040f399e1f0008d0bf..9a12fc329adf9e57538a1e390484f143b9b4e736 100644 (file)
@@ -67,6 +67,7 @@
   "Connectors": {
     "0": {},
     "1": {
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
@@ -96,6 +97,7 @@
       ]
     },
     "2": {
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
index 6743965796c09af9d2a39f87d317fc415c3ca7a1..8f5276853ba5f86e8e437201e826ab46ac232f40 100644 (file)
@@ -62,6 +62,7 @@
     "0": {},
     "1": {
       "bootStatus": "Available",
+      "maximumPower": 75000,
       "MeterValues": [
         {
           "unit": "Percent",
@@ -77,6 +78,7 @@
     },
     "2": {
       "bootStatus": "Preparing",
+      "maximumPower": 75000,
       "MeterValues": [
         {
           "unit": "Percent",
@@ -92,6 +94,7 @@
     },
     "3": {
       "bootStatus": "Faulted",
+      "maximumPower": 75000,
       "MeterValues": [
         {
           "unit": "Percent",
index f1bd28a42002ef44e7451f19eebcab65fe4d60a4..2b50af934f07131a080e97c357e0fcc292330aa0 100644 (file)
     "0": {},
     "1": {
       "bootStatus": "Available",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
     },
     "2": {
       "bootStatus": "Preparing",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
     },
     "3": {
       "bootStatus": "Faulted",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
index 24fce690a32e366b9e9996757c53a60c55e44b1c..c8a737dc2db2b30cb9e13352f4fde723bd2d9919 100644 (file)
@@ -62,6 +62,7 @@
     "0": {},
     "1": {
       "bootStatus": "Available",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
@@ -77,6 +78,7 @@
     },
     "2": {
       "bootStatus": "Preparing",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
@@ -92,6 +94,7 @@
     },
     "3": {
       "bootStatus": "Faulted",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
index 3692355d5644bb0a32bf36fb37384a81888785e7..46f26238c28a4f35db1e405bb7b8492c1b2c0e94 100644 (file)
@@ -62,6 +62,7 @@
     "0": {},
     "1": {
       "bootStatus": "Available",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
@@ -77,6 +78,7 @@
     },
     "2": {
       "bootStatus": "Preparing",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
@@ -92,6 +94,7 @@
     },
     "3": {
       "bootStatus": "SuspendedEVSE",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
     },
     "4": {
       "bootStatus": "SuspendedEV",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
     },
     "5": {
       "bootStatus": "Finishing",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
     },
     "6": {
       "bootStatus": "Reserved",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
     },
     "7": {
       "bootStatus": "Charging",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
     },
     "8": {
       "bootStatus": "Unavailable",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
     },
     "9": {
       "bootStatus": "Faulted",
+      "maximumPower": 50000,
       "MeterValues": [
         {
           "unit": "Percent",
index 37f1c697754a0e36b14e146cbff2191b7cbf078d..e9d86a7c261bd286a3a756819e62dbf0001ae25a 100644 (file)
@@ -130,6 +130,7 @@ import {
   getChargingStationChargingProfilesLimit,
   getChargingStationId,
   getConnectorChargingProfilesLimit,
+  getDefaultConnectorMaximumPower,
   getDefaultVoltageOut,
   getHashId,
   getIdTagsFile,
@@ -525,12 +526,16 @@ export class ChargingStation extends EventEmitter {
       return Number.POSITIVE_INFINITY
     }
     const connectorMaximumPower = maximumPower / (this.powerDivider ?? 1)
+    const connectorHardwareMaximumPower = this.getConnectorStatus(connectorId)?.maximumPower
     const chargingStationChargingProfilesLimit =
       (getChargingStationChargingProfilesLimit(this) ?? Number.POSITIVE_INFINITY) /
       (this.powerDivider ?? 1)
     const connectorChargingProfilesLimit = getConnectorChargingProfilesLimit(this, connectorId)
     return min(
       Number.isNaN(connectorMaximumPower) ? Number.POSITIVE_INFINITY : connectorMaximumPower,
+      connectorHardwareMaximumPower == null || Number.isNaN(connectorHardwareMaximumPower)
+        ? Number.POSITIVE_INFINITY
+        : connectorHardwareMaximumPower,
       connectorAmperageLimitationLimit == null || Number.isNaN(connectorAmperageLimitationLimit)
         ? Number.POSITIVE_INFINITY
         : connectorAmperageLimitationLimit,
@@ -1897,7 +1902,11 @@ export class ChargingStation extends EventEmitter {
             )
             this.connectors.set(connectorId, clone(connectorStatus))
           }
-          initializeConnectorsMapStatus(this.connectors, this.logPrefix())
+          initializeConnectorsMapStatus(
+            this.connectors,
+            this.logPrefix(),
+            getDefaultConnectorMaximumPower(stationTemplate)
+          )
           this.saveConnectorsStatus()
         } else {
           logger.warn(
@@ -2047,7 +2056,11 @@ export class ChargingStation extends EventEmitter {
               ),
             }
             this.evses.set(evseId, evseStatus)
-            initializeConnectorsMapStatus(evseStatus.connectors, this.logPrefix())
+            initializeConnectorsMapStatus(
+              evseStatus.connectors,
+              this.logPrefix(),
+              getDefaultConnectorMaximumPower(stationTemplate)
+            )
           }
           this.saveEvsesStatus()
         } else {
index c3c980266285e186130fe1c039b958528e8f32cb..b92f58ab3789836781a84791242ed79d1f8ea348 100644 (file)
@@ -44,6 +44,7 @@ import {
   CurrentType,
   type EvseTemplate,
   OCPPVersion,
+  PowerUnits,
   RecurrencyKindType,
   type Reservation,
   ReservationTerminationReason,
@@ -290,6 +291,37 @@ export const getMaxNumberOfEvses = (evses: Record<string, EvseTemplate> | undefi
   return isEmpty(evses) ? 0 : Object.keys(evses).length
 }
 
+export const getDefaultConnectorMaximumPower = (
+  stationTemplate: ChargingStationTemplate
+): number | undefined => {
+  let maximumPower: number | undefined
+  if (isNotEmptyArray<number>(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
+}
+
 const getMaxNumberOfConnectors = (
   connectors: Record<string, ConnectorStatus> | undefined
 ): number => {
@@ -502,7 +534,8 @@ export const buildConnectorsMap = (
 
 export const initializeConnectorsMapStatus = (
   connectors: Map<number, ConnectorStatus>,
-  logPrefix: string
+  logPrefix: string,
+  defaultMaximumPower?: number
 ): void => {
   for (const [connectorId, connectorStatus] of connectors) {
     if (connectorId > 0 && connectorStatus.transactionStarted === true) {
@@ -525,7 +558,7 @@ export const initializeConnectorsMapStatus = (
       connectorStatus.availability = AvailabilityType.Operative
       connectorStatus.chargingProfiles ??= []
     } else if (connectorId > 0 && connectorStatus.transactionStarted == null) {
-      initializeConnectorStatus(connectorStatus)
+      initializeConnectorStatus(connectorStatus, defaultMaximumPower)
     }
   }
 }
@@ -808,7 +841,9 @@ export const getConnectorChargingProfilesLimit = (
       if (maximumPower == null) {
         return limit
       }
-      const connectorMaximumPower = maximumPower / (chargingStation.powerDivider ?? 1)
+      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`,
@@ -957,7 +992,10 @@ const checkTemplateMaxConnectors = (
   }
 }
 
-const initializeConnectorStatus = (connectorStatus: ConnectorStatus): void => {
+const initializeConnectorStatus = (
+  connectorStatus: ConnectorStatus,
+  defaultMaximumPower?: number
+): void => {
   connectorStatus.availability = AvailabilityType.Operative
   connectorStatus.idTagLocalAuthorized = false
   connectorStatus.idTagAuthorized = false
@@ -966,6 +1004,9 @@ const initializeConnectorStatus = (connectorStatus: ConnectorStatus): void => {
   connectorStatus.energyActiveImportRegisterValue = 0
   connectorStatus.transactionEnergyActiveImportRegisterValue = 0
   connectorStatus.chargingProfiles ??= []
+  if (defaultMaximumPower != null) {
+    connectorStatus.maximumPower ??= defaultMaximumPower
+  }
 }
 
 const warnDeprecatedTemplateKey = (
index de1e62aa5b622291ad9d2cc211bef0fb4f516692..ff564a547c7604b6d85a15b74c522f0050699584 100644 (file)
@@ -25,6 +25,7 @@ export interface ConnectorStatus {
   idTagLocalAuthorized?: boolean
   localAuthorizeIdTag?: string
   locked?: boolean
+  maximumPower?: number // In W
   MeterValues: SampledValueTemplate[]
   publicKeySentInTransaction?: boolean
   remoteStartId?: number