feat: warn if charging profile schedule periods are not sorted
[e-mobility-charging-stations-simulator.git] / src / utils / Utils.ts
1 import { randomBytes, randomInt, randomUUID } from 'node:crypto';
2 import { inspect } from 'node:util';
3
4 import {
5 formatDuration,
6 isDate,
7 millisecondsToHours,
8 millisecondsToMinutes,
9 millisecondsToSeconds,
10 secondsToMilliseconds,
11 } from 'date-fns';
12 import clone from 'just-clone';
13
14 import { Constants } from './Constants';
15 import { type TimestampedData, WebSocketCloseEventStatusString } from '../types';
16
17 export const logPrefix = (prefixString = ''): string => {
18 return `${new Date().toLocaleString()}${prefixString}`;
19 };
20
21 export const generateUUID = (): string => {
22 return randomUUID();
23 };
24
25 export const validateUUID = (uuid: string): boolean => {
26 return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
27 uuid,
28 );
29 };
30
31 export const sleep = async (milliSeconds: number): Promise<NodeJS.Timeout> => {
32 return new Promise<NodeJS.Timeout>((resolve) => setTimeout(resolve as () => void, milliSeconds));
33 };
34
35 export const formatDurationMilliSeconds = (duration: number): string => {
36 duration = convertToInt(duration);
37 const days = Math.floor(duration / (24 * 3600 * 1000));
38 const hours = Math.floor(millisecondsToHours(duration) - days * 24);
39 const minutes = Math.floor(millisecondsToMinutes(duration) - days * 24 * 60 - hours * 60);
40 const seconds = Math.floor(
41 millisecondsToSeconds(duration) - days * 24 * 3600 - hours * 3600 - minutes * 60,
42 );
43 return formatDuration({
44 days,
45 hours,
46 minutes,
47 seconds,
48 });
49 };
50
51 export const formatDurationSeconds = (duration: number): string => {
52 return formatDurationMilliSeconds(secondsToMilliseconds(duration));
53 };
54
55 // More efficient date validation function than the one provided by date-fns
56 export const isValidDate = (date: unknown): boolean => {
57 if (typeof date === 'number') {
58 return !isNaN(date);
59 } else if (isDate(date)) {
60 return !isNaN((date as Date).getTime());
61 }
62 return false;
63 };
64
65 export const convertToDate = (
66 value: Date | string | number | null | undefined,
67 ): Date | null | undefined => {
68 if (isNullOrUndefined(value)) {
69 return value as null | undefined;
70 }
71 if (value instanceof Date) {
72 return value;
73 }
74 if (isString(value) || typeof value === 'number') {
75 return new Date(value!);
76 }
77 return null;
78 };
79
80 export const convertToInt = (value: unknown): number => {
81 if (!value) {
82 return 0;
83 }
84 let changedValue: number = value as number;
85 if (Number.isSafeInteger(value)) {
86 return value as number;
87 }
88 if (typeof value === 'number') {
89 return Math.trunc(value);
90 }
91 if (isString(value)) {
92 changedValue = parseInt(value as string);
93 }
94 if (isNaN(changedValue)) {
95 // eslint-disable-next-line @typescript-eslint/no-base-to-string
96 throw new Error(`Cannot convert to integer: ${value.toString()}`);
97 }
98 return changedValue;
99 };
100
101 export const convertToFloat = (value: unknown): number => {
102 if (!value) {
103 return 0;
104 }
105 let changedValue: number = value as number;
106 if (isString(value)) {
107 changedValue = parseFloat(value as string);
108 }
109 if (isNaN(changedValue)) {
110 // eslint-disable-next-line @typescript-eslint/no-base-to-string
111 throw new Error(`Cannot convert to float: ${value.toString()}`);
112 }
113 return changedValue;
114 };
115
116 export const convertToBoolean = (value: unknown): boolean => {
117 let result = false;
118 if (value) {
119 // Check the type
120 if (typeof value === 'boolean') {
121 return value;
122 } else if (isString(value) && ((value as string).toLowerCase() === 'true' || value === '1')) {
123 result = true;
124 } else if (typeof value === 'number' && value === 1) {
125 result = true;
126 }
127 }
128 return result;
129 };
130
131 export const getRandomFloat = (max = Number.MAX_VALUE, min = 0): number => {
132 if (max < min) {
133 throw new RangeError('Invalid interval');
134 }
135 if (max - min === Infinity) {
136 throw new RangeError('Invalid interval');
137 }
138 return (randomBytes(4).readUInt32LE() / 0xffffffff) * (max - min) + min;
139 };
140
141 export const getRandomInteger = (max = Constants.MAX_RANDOM_INTEGER, min = 0): number => {
142 max = Math.floor(max);
143 if (!isNullOrUndefined(min) && min !== 0) {
144 min = Math.ceil(min);
145 return Math.floor(randomInt(min, max + 1));
146 }
147 return Math.floor(randomInt(max + 1));
148 };
149
150 /**
151 * Rounds the given number to the given scale.
152 * The rounding is done using the "round half away from zero" method.
153 *
154 * @param numberValue - The number to round.
155 * @param scale - The scale to round to.
156 * @returns The rounded number.
157 */
158 export const roundTo = (numberValue: number, scale: number): number => {
159 const roundPower = Math.pow(10, scale);
160 return Math.round(numberValue * roundPower * (1 + Number.EPSILON)) / roundPower;
161 };
162
163 export const getRandomFloatRounded = (max = Number.MAX_VALUE, min = 0, scale = 2): number => {
164 if (min) {
165 return roundTo(getRandomFloat(max, min), scale);
166 }
167 return roundTo(getRandomFloat(max), scale);
168 };
169
170 export const getRandomFloatFluctuatedRounded = (
171 staticValue: number,
172 fluctuationPercent: number,
173 scale = 2,
174 ): number => {
175 if (fluctuationPercent < 0 || fluctuationPercent > 100) {
176 throw new RangeError(
177 `Fluctuation percent must be between 0 and 100. Actual value: ${fluctuationPercent}`,
178 );
179 }
180 if (fluctuationPercent === 0) {
181 return roundTo(staticValue, scale);
182 }
183 const fluctuationRatio = fluctuationPercent / 100;
184 return getRandomFloatRounded(
185 staticValue * (1 + fluctuationRatio),
186 staticValue * (1 - fluctuationRatio),
187 scale,
188 );
189 };
190
191 export const extractTimeSeriesValues = (timeSeries: Array<TimestampedData>): number[] => {
192 return timeSeries.map((timeSeriesItem) => timeSeriesItem.value);
193 };
194
195 export const isObject = (item: unknown): boolean => {
196 return (
197 isNullOrUndefined(item) === false && typeof item === 'object' && Array.isArray(item) === false
198 );
199 };
200
201 export const cloneObject = <T extends object>(object: T): T => {
202 return clone<T>(object);
203 };
204
205 export const hasOwnProp = (object: unknown, property: PropertyKey): boolean => {
206 return isObject(object) && Object.hasOwn(object as object, property);
207 };
208
209 export const isCFEnvironment = (): boolean => {
210 return !isNullOrUndefined(process.env.VCAP_APPLICATION);
211 };
212
213 export const isIterable = <T>(obj: T): boolean => {
214 return !isNullOrUndefined(obj) ? typeof obj[Symbol.iterator as keyof T] === 'function' : false;
215 };
216
217 const isString = (value: unknown): boolean => {
218 return typeof value === 'string';
219 };
220
221 export const isEmptyString = (value: unknown): boolean => {
222 return isNullOrUndefined(value) || (isString(value) && (value as string).trim().length === 0);
223 };
224
225 export const isNotEmptyString = (value: unknown): boolean => {
226 return isString(value) && (value as string).trim().length > 0;
227 };
228
229 export const isUndefined = (value: unknown): boolean => {
230 return value === undefined;
231 };
232
233 export const isNullOrUndefined = (value: unknown): boolean => {
234 // eslint-disable-next-line eqeqeq, no-eq-null
235 return value == null;
236 };
237
238 export const isEmptyArray = (object: unknown): boolean => {
239 return Array.isArray(object) && object.length === 0;
240 };
241
242 export const isNotEmptyArray = (object: unknown): boolean => {
243 return Array.isArray(object) && object.length > 0;
244 };
245
246 export const isEmptyObject = (obj: object): boolean => {
247 if (obj?.constructor !== Object) {
248 return false;
249 }
250 // Iterates over the keys of an object, if
251 // any exist, return false.
252 for (const _ in obj) {
253 return false;
254 }
255 return true;
256 };
257
258 export const insertAt = (str: string, subStr: string, pos: number): string =>
259 `${str.slice(0, pos)}${subStr}${str.slice(pos)}`;
260
261 /**
262 * Computes the retry delay in milliseconds using an exponential backoff algorithm.
263 *
264 * @param retryNumber - the number of retries that have already been attempted
265 * @returns delay in milliseconds
266 */
267 export const exponentialDelay = (retryNumber = 0, maxDelayRatio = 0.2): number => {
268 const delay = Math.pow(2, retryNumber) * 100;
269 const randomSum = delay * maxDelayRatio * secureRandom(); // 0-20% of the delay
270 return delay + randomSum;
271 };
272
273 const isPromisePending = (promise: Promise<unknown>): boolean => {
274 return inspect(promise).includes('pending');
275 };
276
277 export const promiseWithTimeout = async <T>(
278 promise: Promise<T>,
279 timeoutMs: number,
280 timeoutError: Error,
281 timeoutCallback: () => void = () => {
282 /* This is intentional */
283 },
284 ): Promise<T> => {
285 // Create a timeout promise that rejects in timeout milliseconds
286 const timeoutPromise = new Promise<never>((_, reject) => {
287 setTimeout(() => {
288 if (isPromisePending(promise)) {
289 timeoutCallback();
290 // FIXME: The original promise shall be canceled
291 }
292 reject(timeoutError);
293 }, timeoutMs);
294 });
295
296 // Returns a race between timeout promise and the passed promise
297 return Promise.race<T>([promise, timeoutPromise]);
298 };
299
300 /**
301 * Generates a cryptographically secure random number in the [0,1[ range
302 *
303 * @returns
304 */
305 export const secureRandom = (): number => {
306 return randomBytes(4).readUInt32LE() / 0x100000000;
307 };
308
309 export const JSONStringifyWithMapSupport = (
310 obj: Record<string, unknown> | Record<string, unknown>[] | Map<unknown, unknown>,
311 space?: number,
312 ): string => {
313 return JSON.stringify(
314 obj,
315 (_, value: Record<string, unknown>) => {
316 if (value instanceof Map) {
317 return {
318 dataType: 'Map',
319 value: [...value],
320 };
321 }
322 return value;
323 },
324 space,
325 );
326 };
327
328 /**
329 * Converts websocket error code to human readable string message
330 *
331 * @param code - websocket error code
332 * @returns human readable string message
333 */
334 export const getWebSocketCloseEventStatusString = (code: number): string => {
335 if (code >= 0 && code <= 999) {
336 return '(Unused)';
337 } else if (code >= 1016) {
338 if (code <= 1999) {
339 return '(For WebSocket standard)';
340 } else if (code <= 2999) {
341 return '(For WebSocket extensions)';
342 } else if (code <= 3999) {
343 return '(For libraries and frameworks)';
344 } else if (code <= 4999) {
345 return '(For applications)';
346 }
347 }
348 if (
349 !isUndefined(
350 WebSocketCloseEventStatusString[code as keyof typeof WebSocketCloseEventStatusString],
351 )
352 ) {
353 return WebSocketCloseEventStatusString[code as keyof typeof WebSocketCloseEventStatusString];
354 }
355 return '(Unknown)';
356 };
357
358 export const isArraySorted = <T>(elements: T[], compareFn: (a: T, b: T) => number): boolean => {
359 for (let i = 0; i < elements.length - 1; ++i) {
360 if (compareFn(elements[i], elements[i + 1]) > 0) {
361 return false;
362 }
363 }
364 return true;
365 };