fix: avoid gaps in get composite schedule
[e-mobility-charging-stations-simulator.git] / src / charging-station / ocpp / 1.6 / OCPP16IncomingRequestService.ts
index 3305f4c71e7e37fd4665e8a39b7b799d3807e5c1..d24a2d4cd36e2716ab35b375af7b433017e4a9f3 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 { isWithinInterval, 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';
@@ -653,7 +671,7 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
     ) {
       return OCPP16Constants.OCPP_RESPONSE_REJECTED;
     }
-    const { connectorId, duration } = commandPayload;
+    const { connectorId, duration, chargingRateUnit } = commandPayload;
     if (chargingStation.hasConnector(connectorId) === false) {
       logger.error(
         `${chargingStation.logPrefix()} Trying to get composite schedule to a
@@ -661,28 +679,277 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       );
       return OCPP16Constants.OCPP_RESPONSE_REJECTED;
     }
-    if (isEmptyArray(chargingStation.getConnectorStatus(connectorId)?.chargingProfiles)) {
+    if (connectorId === 0) {
+      logger.error(
+        `${chargingStation.logPrefix()} Get composite schedule on connector id ${connectorId} is not yet supported`,
+      );
+      return OCPP16Constants.OCPP_RESPONSE_REJECTED;
+    }
+    if (chargingRateUnit) {
+      logger.warn(
+        `${chargingStation.logPrefix()} Get composite schedule with a specified rate unit is not yet supported, no conversion will be done`,
+      );
+    }
+    const connectorStatus = chargingStation.getConnectorStatus(connectorId)!;
+    if (
+      isEmptyArray(
+        connectorStatus?.chargingProfiles &&
+          isEmptyArray(chargingStation.getConnectorStatus(0)?.chargingProfiles),
+      )
+    ) {
       return OCPP16Constants.OCPP_RESPONSE_REJECTED;
     }
-    const startDate = new Date();
-    const endDate = new Date(startDate.getTime() + secondsToMilliseconds(duration));
-    let compositeSchedule: OCPP16ChargingSchedule | undefined;
-    for (const chargingProfile of chargingStation.getConnectorStatus(connectorId)!
-      .chargingProfiles!) {
-      // FIXME: build the composite schedule including the local power limit, the stack level, the charging rate unit, etc.
+    const currentDate = new Date();
+    const interval: Interval = {
+      start: currentDate,
+      end: addSeconds(currentDate, duration),
+    };
+    // 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 (
-        isWithinInterval(chargingProfile.chargingSchedule.startSchedule!, {
-          start: startDate,
-          end: endDate,
-        })
+        isNullOrUndefined(storedChargingProfile.chargingSchedule?.startSchedule) &&
+        connectorStatus?.transactionStarted
       ) {
-        compositeSchedule = chargingProfile.chargingSchedule;
-        break;
+        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 (
+        !isNullOrUndefined(storedChargingProfile.chargingSchedule?.startSchedule) &&
+        isNullOrUndefined(storedChargingProfile.chargingSchedule?.duration)
+      ) {
+        logger.debug(
+          `${chargingStation.logPrefix()} ${moduleName}.getLimitFromChargingProfiles: Charging profile id ${
+            storedChargingProfile.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
+        storedChargingProfile.chargingSchedule.duration = differenceInSeconds(
+          maxTime,
+          storedChargingProfile.chargingSchedule.startSchedule!,
+        );
+      }
+      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 (
+        !canProceedChargingProfile(
+          storedChargingProfile,
+          interval.start as Date,
+          chargingStation.logPrefix(),
+        )
+      ) {
+        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
+            storedChargingProfile.chargingSchedule.chargingSchedulePeriod =
+              storedChargingProfile.chargingSchedule.chargingSchedulePeriod.filter(
+                (schedulePeriod, index) => {
+                  if (
+                    isWithinInterval(
+                      addSeconds(
+                        storedChargingProfile.chargingSchedule.startSchedule!,
+                        schedulePeriod.startPeriod,
+                      ),
+                      {
+                        start: chargingProfilesInterval.end,
+                        end: interval.end,
+                      },
+                    )
+                  ) {
+                    return true;
+                  }
+                  if (
+                    !isWithinInterval(
+                      addSeconds(
+                        storedChargingProfile.chargingSchedule.startSchedule!,
+                        schedulePeriod.startPeriod,
+                      ),
+                      {
+                        start: chargingProfilesInterval.end,
+                        end: interval.end,
+                      },
+                    ) &&
+                    index <
+                      storedChargingProfile.chargingSchedule.chargingSchedulePeriod.length - 1 &&
+                    isWithinInterval(
+                      addSeconds(
+                        storedChargingProfile.chargingSchedule.startSchedule!,
+                        storedChargingProfile.chargingSchedule.chargingSchedulePeriod[index + 1]
+                          .startPeriod,
+                      ),
+                      {
+                        start: chargingProfilesInterval.end,
+                        end: interval.end,
+                      },
+                    )
+                  ) {
+                    return true;
+                  }
+                  return false;
+                },
+              );
+            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,
     };