feat: make get composite schedule closer to OCPP 1.6 specs
authorJérôme Benoit <jerome.benoit@sap.com>
Tue, 1 Aug 2023 16:47:53 +0000 (18:47 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Tue, 1 Aug 2023 16:47:53 +0000 (18:47 +0200)
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
src/charging-station/Helpers.ts
src/charging-station/index.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts

index 6d2742517fdd387ddbf40da631ddfb4616375473..05773f0159f3f1378dd78af4cf2a46ac3395b373 100644 (file)
@@ -520,18 +520,11 @@ export const getChargingStationConnectorChargingProfilesPowerLimit = (
 ): number | undefined => {
   let limit: number | undefined, matchingChargingProfile: ChargingProfile | undefined;
   // Get charging profiles for connector id and sort by stack level
-  const chargingProfiles =
-    cloneObject<ChargingProfile[]>(
-      chargingStation.getConnectorStatus(connectorId)!.chargingProfiles!,
-    )?.sort((a, b) => b.stackLevel - a.stackLevel) ?? [];
-  // Get charging profiles on connector 0 and sort by stack level
-  if (isNotEmptyArray(chargingStation.getConnectorStatus(0)?.chargingProfiles)) {
-    chargingProfiles.push(
-      ...cloneObject<ChargingProfile[]>(
-        chargingStation.getConnectorStatus(0)!.chargingProfiles!,
-      ).sort((a, b) => b.stackLevel - a.stackLevel),
-    );
-  }
+  const chargingProfiles = cloneObject<ChargingProfile[]>(
+    (chargingStation.getConnectorStatus(connectorId)?.chargingProfiles ?? []).concat(
+      chargingStation.getConnectorStatus(0)?.chargingProfiles ?? [],
+    ),
+  ).sort((a, b) => b.stackLevel - a.stackLevel);
   if (isNotEmptyArray(chargingProfiles)) {
     const result = getLimitFromChargingProfiles(
       chargingStation,
@@ -741,6 +734,12 @@ const getLimitFromChargingProfiles = (
   const debugLogMsg = `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Matching charging profile found for power limitation: %j`;
   const currentDate = new Date();
   const connectorStatus = chargingStation.getConnectorStatus(connectorId);
+  if (!isArraySorted(chargingProfiles, (a, b) => b.stackLevel - a.stackLevel)) {
+    logger.warn(
+      `${logPrefix} ${moduleName}.getLimitFromChargingProfiles: Charging profiles are not sorted by stack level. Trying to sort them`,
+    );
+    chargingProfiles.sort((a, b) => b.stackLevel - a.stackLevel);
+  }
   for (const chargingProfile of chargingProfiles) {
     const chargingSchedule = chargingProfile.chargingSchedule;
     if (connectorStatus?.transactionStarted && isNullOrUndefined(chargingSchedule?.startSchedule)) {
@@ -860,7 +859,7 @@ const getLimitFromChargingProfiles = (
   }
 };
 
-const canProceedChargingProfile = (
+export const canProceedChargingProfile = (
   chargingProfile: ChargingProfile,
   currentDate: Date,
   logPrefix: string,
@@ -879,7 +878,7 @@ const canProceedChargingProfile = (
   const chargingSchedule = chargingProfile.chargingSchedule;
   if (isNullOrUndefined(chargingSchedule?.startSchedule)) {
     logger.error(
-      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId} has (still) no startSchedule defined`,
+      `${logPrefix} ${moduleName}.canProceedChargingProfile: Charging profile id ${chargingProfile.chargingProfileId} has no startSchedule defined`,
     );
     return false;
   }
@@ -892,7 +891,7 @@ const canProceedChargingProfile = (
   return true;
 };
 
-const canProceedRecurringChargingProfile = (
+export const canProceedRecurringChargingProfile = (
   chargingProfile: ChargingProfile,
   logPrefix: string,
 ): boolean => {
@@ -915,7 +914,7 @@ const canProceedRecurringChargingProfile = (
  * @param currentDate -
  * @param logPrefix -
  */
-const prepareRecurringChargingProfile = (
+export const prepareRecurringChargingProfile = (
   chargingProfile: ChargingProfile,
   currentDate: Date,
   logPrefix: string,
index e1627f9917ea8a10f1a88786524d17f59ba57ffe..07dbe9b6cd3ae3cf53f1678f66deca8759fb7cb0 100644 (file)
@@ -6,10 +6,13 @@ export {
   setConfigurationKeyValue,
 } from './ConfigurationKeyUtils';
 export {
+  canProceedChargingProfile,
+  canProceedRecurringChargingProfile,
   checkChargingStation,
   getIdTagsFile,
   hasFeatureProfile,
   hasReservationExpired,
+  prepareRecurringChargingProfile,
   removeExpiredReservations,
   resetConnectorStatus,
 } from './Helpers';
index 81f7540f74cf382c07482347a23adfead9a117a6..e3375d192d1615648f4c0910fcc19aa5a7c84f60 100644 (file)
@@ -6,15 +6,25 @@ 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,
+  isDate,
+  isWithinInterval,
+  maxTime,
+  min,
+  secondsToMilliseconds,
+} from 'date-fns';
 import { create } from 'tar';
 
 import { OCPP16Constants } from './OCPP16Constants';
 import { OCPP16ServiceUtils } from './OCPP16ServiceUtils';
 import {
   type ChargingStation,
+  canProceedChargingProfile,
+  canProceedRecurringChargingProfile,
   checkChargingStation,
   getConfigurationKey,
+  prepareRecurringChargingProfile,
   removeExpiredReservations,
   setConfigurationKeyValue,
 } from '../../../charging-station';
@@ -22,6 +32,8 @@ import { OCPPError } from '../../../exception';
 import {
   type ChangeConfigurationRequest,
   type ChangeConfigurationResponse,
+  ChargingProfileKindType,
+  ChargingRateUnitType,
   type ClearChargingProfileRequest,
   type ClearChargingProfileResponse,
   ErrorType,
@@ -89,6 +101,7 @@ import {
 } from '../../../types';
 import {
   Constants,
+  cloneObject,
   convertToDate,
   convertToInt,
   formatDurationMilliSeconds,
@@ -98,6 +111,7 @@ import {
   isNotEmptyString,
   isNullOrUndefined,
   isUndefined,
+  isValidTime,
   logger,
   sleep,
 } from '../../../utils';
@@ -673,67 +687,120 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       );
       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!) {
+    const chargingProfiles: OCPP16ChargingProfile[] = [];
+    for (const chargingProfile of cloneObject<OCPP16ChargingProfile[]>(
+      (connectorStatus?.chargingProfiles ?? []).concat(
+        chargingStation.getConnectorStatus(0)?.chargingProfiles ?? [],
+      ),
+    ).sort((a, b) => b.stackLevel - a.stackLevel)) {
       if (
-        compositeSchedule?.chargingRateUnit &&
-        compositeSchedule.chargingRateUnit !== chargingProfile.chargingSchedule.chargingRateUnit
+        connectorStatus?.transactionStarted &&
+        isNullOrUndefined(chargingProfile.chargingSchedule?.startSchedule)
       ) {
-        logger.error(
-          `${chargingStation.logPrefix()} Building composite schedule with different charging rate units is not yet supported, skipping charging profile id ${
+        logger.debug(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetCompositeSchedule: Charging profile id ${
             chargingProfile.chargingProfileId
-          }`,
+          } has no startSchedule defined. Trying to set it to the connector current transaction start date`,
         );
-        continue;
+        // OCPP specifies that if startSchedule is not defined, it should be relative to start of the connector transaction
+        chargingProfile.chargingSchedule.startSchedule = connectorStatus?.transactionStart;
+      }
+      if (!isDate(chargingProfile.chargingSchedule?.startSchedule)) {
+        logger.warn(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetCompositeSchedule: Charging profile id ${
+            chargingProfile.chargingProfileId
+          } startSchedule property is not a Date object. Trying to convert it to a Date object`,
+        );
+        chargingProfile.chargingSchedule.startSchedule = convertToDate(
+          chargingProfile.chargingSchedule?.startSchedule,
+        )!;
+      }
+      switch (chargingProfile.chargingProfileKind) {
+        case ChargingProfileKindType.RECURRING:
+          if (!canProceedRecurringChargingProfile(chargingProfile, chargingStation.logPrefix())) {
+            continue;
+          }
+          prepareRecurringChargingProfile(
+            chargingProfile,
+            interval.start as Date,
+            chargingStation.logPrefix(),
+          );
+          break;
+        case ChargingProfileKindType.RELATIVE:
+          connectorStatus?.transactionStarted &&
+            (chargingProfile.chargingSchedule.startSchedule = connectorStatus?.transactionStart);
+          break;
       }
       if (
-        isWithinInterval(chargingProfile.chargingSchedule.startSchedule!, interval) &&
-        isWithinInterval(
-          addSeconds(
-            chargingProfile.chargingSchedule.startSchedule!,
-            chargingProfile.chargingSchedule.duration!,
-          ),
-          interval,
+        !canProceedChargingProfile(
+          chargingProfile,
+          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(chargingProfile.chargingSchedule?.startSchedule) &&
+        isWithinInterval(chargingProfile.chargingSchedule.startSchedule!, interval)
+      ) {
+        chargingProfiles.push(chargingProfile);
       }
     }
+    const compositeSchedule: OCPP16ChargingSchedule = {
+      startSchedule: min(
+        chargingProfiles.map(
+          (chargingProfile) => chargingProfile.chargingSchedule.startSchedule ?? maxTime,
+        ),
+      ),
+      duration: Math.max(
+        ...chargingProfiles.map(
+          (chargingProfile) => chargingProfile.chargingSchedule.duration ?? -Infinity,
+        ),
+      ),
+      chargingRateUnit: chargingProfiles.every(
+        (chargingProfile) =>
+          chargingProfile.chargingSchedule.chargingRateUnit === ChargingRateUnitType.AMPERE,
+      )
+        ? ChargingRateUnitType.AMPERE
+        : chargingProfiles.every(
+            (chargingProfile) =>
+              chargingProfile.chargingSchedule.chargingRateUnit === ChargingRateUnitType.WATT,
+          )
+        ? ChargingRateUnitType.WATT
+        : ChargingRateUnitType.AMPERE,
+      // FIXME: remove overlapping charging schedule periods
+      chargingSchedulePeriod: chargingProfiles
+        .map((chargingProfile) => chargingProfile.chargingSchedule.chargingSchedulePeriod)
+        .reduce(
+          (accumulator, value) =>
+            accumulator.concat(value).sort((a, b) => a.startPeriod - b.startPeriod),
+          [],
+        ),
+      minChargeRate: Math.min(
+        ...chargingProfiles.map(
+          (chargingProfile) => chargingProfile.chargingSchedule.minChargeRate ?? Infinity,
+        ),
+      ),
+    };
     return {
       status: GenericStatus.Accepted,
-      scheduleStart: compositeSchedule?.startSchedule,
+      scheduleStart: compositeSchedule.startSchedule!,
       connectorId,
       chargingSchedule: compositeSchedule,
     };