fix: ensure charging profiles used for power limitation are properly sorted
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / 1.6 / OCPP16IncomingRequestService.ts
index 81f7540f74cf382c07482347a23adfead9a117a6..66a6ee5960a95b7050a1888122e9bcef4cfe7a24 100644 (file)
@@ -6,15 +6,30 @@ import { URL, fileURLToPath } from 'node:url';
 
 import type { JSONSchemaType } from 'ajv';
 import { Client, type FTPResponse } from 'basic-ftp';
-import { addSeconds, isWithinInterval, max, secondsToMilliseconds } from 'date-fns';
+import {
+  addSeconds,
+  differenceInSeconds,
+  isAfter,
+  isBefore,
+  isDate,
+  isWithinInterval,
+  max,
+  maxTime,
+  min,
+  minTime,
+  secondsToMilliseconds,
+} from 'date-fns';
 import { create } from 'tar';
 
 import { OCPP16Constants } from './OCPP16Constants';
 import { OCPP16ServiceUtils } from './OCPP16ServiceUtils';
 import {
   type ChargingStation,
+  canProceedChargingProfile,
   checkChargingStation,
   getConfigurationKey,
+  getConnectorChargingProfiles,
+  prepareChargingProfileKind,
   removeExpiredReservations,
   setConfigurationKeyValue,
 } from '../../../charging-station';
@@ -45,7 +60,9 @@ import {
   OCPP16ChargePointStatus,
   type OCPP16ChargingProfile,
   OCPP16ChargingProfilePurposeType,
+  OCPP16ChargingRateUnitType,
   type OCPP16ChargingSchedule,
+  type OCPP16ChargingSchedulePeriod,
   type OCPP16ClearCacheRequest,
   type OCPP16DataTransferRequest,
   type OCPP16DataTransferResponse,
@@ -98,6 +115,7 @@ import {
   isNotEmptyString,
   isNullOrUndefined,
   isUndefined,
+  isValidTime,
   logger,
   sleep,
 } from '../../../utils';
@@ -668,72 +686,223 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       return OCPP16Constants.OCPP_RESPONSE_REJECTED;
     }
     if (chargingRateUnit) {
-      logger.error(
-        `${chargingStation.logPrefix()} Get composite schedule with a specified rate unit is not yet supported`,
+      logger.warn(
+        `${chargingStation.logPrefix()} Get composite schedule with a specified rate unit is not yet supported, no conversion will be done`,
       );
-      return OCPP16Constants.OCPP_RESPONSE_REJECTED;
     }
-    if (isEmptyArray(chargingStation.getConnectorStatus(connectorId)?.chargingProfiles)) {
+    const connectorStatus = chargingStation.getConnectorStatus(connectorId)!;
+    if (
+      isEmptyArray(
+        connectorStatus?.chargingProfiles &&
+          isEmptyArray(chargingStation.getConnectorStatus(0)?.chargingProfiles),
+      )
+    ) {
       return OCPP16Constants.OCPP_RESPONSE_REJECTED;
     }
-    const startDate = new Date();
+    const currentDate = new Date();
     const interval: Interval = {
-      start: startDate,
-      end: addSeconds(startDate, duration),
+      start: currentDate,
+      end: addSeconds(currentDate, duration),
     };
-    let compositeSchedule: OCPP16ChargingSchedule | undefined;
-    for (const chargingProfile of chargingStation.getConnectorStatus(connectorId)!
-      .chargingProfiles!) {
+    // Get charging profiles sorted by connector id then stack level
+    const storedChargingProfiles: OCPP16ChargingProfile[] = getConnectorChargingProfiles(
+      chargingStation,
+      connectorId,
+    );
+    const chargingProfiles: OCPP16ChargingProfile[] = [];
+    for (const storedChargingProfile of storedChargingProfiles) {
       if (
-        compositeSchedule?.chargingRateUnit &&
-        compositeSchedule.chargingRateUnit !== chargingProfile.chargingSchedule.chargingRateUnit
+        connectorStatus?.transactionStarted &&
+        isNullOrUndefined(storedChargingProfile.chargingSchedule?.startSchedule)
       ) {
-        logger.error(
-          `${chargingStation.logPrefix()} Building composite schedule with different charging rate units is not yet supported, skipping charging profile id ${
-            chargingProfile.chargingProfileId
-          }`,
+        logger.debug(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetCompositeSchedule: Charging profile id ${
+            storedChargingProfile.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
+        storedChargingProfile.chargingSchedule.startSchedule = connectorStatus?.transactionStart;
+      }
+      if (!isDate(storedChargingProfile.chargingSchedule?.startSchedule)) {
+        logger.warn(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetCompositeSchedule: Charging profile id ${
+            storedChargingProfile.chargingProfileId
+          } startSchedule property is not a Date object. Trying to convert it to a Date object`,
+        );
+        storedChargingProfile.chargingSchedule.startSchedule = convertToDate(
+          storedChargingProfile.chargingSchedule?.startSchedule,
+        )!;
+      }
+      if (
+        !prepareChargingProfileKind(
+          connectorStatus,
+          storedChargingProfile,
+          interval.start as Date,
+          chargingStation.logPrefix(),
+        )
+      ) {
         continue;
       }
       if (
-        isWithinInterval(chargingProfile.chargingSchedule.startSchedule!, interval) &&
-        isWithinInterval(
-          addSeconds(
-            chargingProfile.chargingSchedule.startSchedule!,
-            chargingProfile.chargingSchedule.duration!,
-          ),
-          interval,
+        !canProceedChargingProfile(
+          storedChargingProfile,
+          interval.start as Date,
+          chargingStation.logPrefix(),
         )
       ) {
-        compositeSchedule = {
-          startSchedule: max([
-            compositeSchedule?.startSchedule ?? interval.start,
-            chargingProfile.chargingSchedule.startSchedule!,
-          ]),
-          duration: Math.max(
-            compositeSchedule?.duration ?? -Infinity,
-            chargingProfile.chargingSchedule.duration!,
-          ),
-          chargingRateUnit: chargingProfile.chargingSchedule.chargingRateUnit,
-          ...(compositeSchedule?.chargingSchedulePeriod === undefined
-            ? { chargingSchedulePeriod: [] }
-            : {
-                chargingSchedulePeriod: compositeSchedule.chargingSchedulePeriod.concat(
-                  ...chargingProfile.chargingSchedule.chargingSchedulePeriod,
-                ),
-              }),
-          ...(chargingProfile.chargingSchedule.minChargeRate && {
-            minChargeRate: Math.min(
-              compositeSchedule?.minChargeRate ?? Infinity,
-              chargingProfile.chargingSchedule.minChargeRate,
+        continue;
+      }
+      // Add active charging profiles into chargingProfiles array
+      if (
+        isValidTime(storedChargingProfile.chargingSchedule?.startSchedule) &&
+        isWithinInterval(storedChargingProfile.chargingSchedule.startSchedule!, interval)
+      ) {
+        if (isEmptyArray(chargingProfiles)) {
+          if (
+            isAfter(
+              addSeconds(
+                storedChargingProfile.chargingSchedule.startSchedule!,
+                storedChargingProfile.chargingSchedule.duration!,
+              ),
+              interval.end,
+            )
+          ) {
+            storedChargingProfile.chargingSchedule.chargingSchedulePeriod =
+              storedChargingProfile.chargingSchedule.chargingSchedulePeriod.filter(
+                (schedulePeriod) =>
+                  isWithinInterval(
+                    addSeconds(
+                      storedChargingProfile.chargingSchedule.startSchedule!,
+                      schedulePeriod.startPeriod,
+                    )!,
+                    interval,
+                  ),
+              );
+            storedChargingProfile.chargingSchedule.duration = differenceInSeconds(
+              interval.end,
+              storedChargingProfile.chargingSchedule.startSchedule!,
+            );
+          }
+          chargingProfiles.push(storedChargingProfile);
+        } else if (isNotEmptyArray(chargingProfiles)) {
+          const chargingProfilesInterval: Interval = {
+            start: min(
+              chargingProfiles.map(
+                (chargingProfile) => chargingProfile.chargingSchedule.startSchedule ?? maxTime,
+              ),
+            ),
+            end: max(
+              chargingProfiles.map(
+                (chargingProfile) =>
+                  addSeconds(
+                    chargingProfile.chargingSchedule.startSchedule!,
+                    chargingProfile.chargingSchedule.duration!,
+                  ) ?? minTime,
+              ),
             ),
-          }),
-        };
+          };
+          let addChargingProfile = false;
+          if (
+            isBefore(interval.start, chargingProfilesInterval.start) &&
+            isBefore(
+              storedChargingProfile.chargingSchedule.startSchedule!,
+              chargingProfilesInterval.start,
+            )
+          ) {
+            // Remove charging schedule periods that are after the start of the active profiles interval
+            storedChargingProfile.chargingSchedule.chargingSchedulePeriod =
+              storedChargingProfile.chargingSchedule.chargingSchedulePeriod.filter(
+                (schedulePeriod) =>
+                  isWithinInterval(
+                    addSeconds(
+                      storedChargingProfile.chargingSchedule.startSchedule!,
+                      schedulePeriod.startPeriod,
+                    ),
+                    {
+                      start: interval.start,
+                      end: chargingProfilesInterval.start,
+                    },
+                  ),
+              );
+            addChargingProfile = true;
+          }
+          if (
+            isBefore(chargingProfilesInterval.end, interval.end) &&
+            isAfter(
+              addSeconds(
+                storedChargingProfile.chargingSchedule.startSchedule!,
+                storedChargingProfile.chargingSchedule.duration!,
+              ),
+              chargingProfilesInterval.end,
+            )
+          ) {
+            // Remove charging schedule periods that are before the end of the active profiles interval
+            // FIXME: can lead to a gap in the charging schedule: chargingProfilesInterval.end -> first matching schedulePeriod.startPeriod
+            storedChargingProfile.chargingSchedule.chargingSchedulePeriod =
+              storedChargingProfile.chargingSchedule.chargingSchedulePeriod.filter(
+                (schedulePeriod) =>
+                  isWithinInterval(
+                    addSeconds(
+                      storedChargingProfile.chargingSchedule.startSchedule!,
+                      schedulePeriod.startPeriod,
+                    ),
+                    {
+                      start: chargingProfilesInterval.end,
+                      end: interval.end,
+                    },
+                  ),
+              );
+            addChargingProfile = true;
+          }
+          addChargingProfile && chargingProfiles.push(storedChargingProfile);
+        }
       }
     }
+    const compositeScheduleStart: Date = min(
+      chargingProfiles.map(
+        (chargingProfile) => chargingProfile.chargingSchedule.startSchedule ?? maxTime,
+      ),
+    );
+    const compositeScheduleDuration: number = Math.max(
+      ...chargingProfiles.map((chargingProfile) =>
+        isNaN(chargingProfile.chargingSchedule.duration!)
+          ? -Infinity
+          : chargingProfile.chargingSchedule.duration!,
+      ),
+    );
+    const compositeSchedulePeriods: OCPP16ChargingSchedulePeriod[] = chargingProfiles
+      .map((chargingProfile) => chargingProfile.chargingSchedule.chargingSchedulePeriod)
+      .reduce(
+        (accumulator, value) =>
+          accumulator.concat(value).sort((a, b) => a.startPeriod - b.startPeriod),
+        [],
+      );
+    const compositeSchedule: OCPP16ChargingSchedule = {
+      startSchedule: compositeScheduleStart,
+      duration: compositeScheduleDuration,
+      chargingRateUnit: chargingProfiles.every(
+        (chargingProfile) =>
+          chargingProfile.chargingSchedule.chargingRateUnit === OCPP16ChargingRateUnitType.AMPERE,
+      )
+        ? OCPP16ChargingRateUnitType.AMPERE
+        : chargingProfiles.every(
+            (chargingProfile) =>
+              chargingProfile.chargingSchedule.chargingRateUnit === OCPP16ChargingRateUnitType.WATT,
+          )
+        ? OCPP16ChargingRateUnitType.WATT
+        : OCPP16ChargingRateUnitType.AMPERE,
+      chargingSchedulePeriod: compositeSchedulePeriods,
+      minChargeRate: Math.min(
+        ...chargingProfiles.map((chargingProfile) =>
+          isNaN(chargingProfile.chargingSchedule.minChargeRate!)
+            ? Infinity
+            : chargingProfile.chargingSchedule.minChargeRate!,
+        ),
+      ),
+    };
     return {
       status: GenericStatus.Accepted,
-      scheduleStart: compositeSchedule?.startSchedule,
+      scheduleStart: compositeSchedule.startSchedule!,
       connectorId,
       chargingSchedule: compositeSchedule,
     };