diff --git a/package-lock.json b/package-lock.json index 81e9881..2c79553 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "morgan": "^1.10.0", "node-cron": "^3.0.3", "onvif": "^0.6.5", - "openai": "^4.33.1", + "openai": "^4.47.2", "pidusage": "^3.0.0", "sharp": "^0.33.3", "swagger-jsdoc": "^6.1.0", @@ -57,7 +57,6 @@ "nodemon": "^2.0.19", "prettier": "^3.2.5", "prisma": "^5.9.1", - "shelljs": "^0.8.5", "ts-node": "^10.9.1", "tsc-alias": "^1.8.8", "tsconfig-paths": "^4.2.0", @@ -2745,15 +2744,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2805,18 +2795,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3338,9 +3316,9 @@ } }, "node_modules/openai": { - "version": "4.33.1", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.33.1.tgz", - "integrity": "sha512-0DH572aSxGTT1JPOXgJQ9mjiuSPg/7scPot8hLc5I1mfQxPxLXTZWJpWerKaIWOuPkR2nrB0SamGDEehH8RuWA==", + "version": "4.47.2", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.47.2.tgz", + "integrity": "sha512-E3Wq9mYdDSLajmcJm9RO/lCegTKrQ7ilAkMbhob4UgGhTjHwIHI+mXNDNPl5+sGIUp2iVUkpoi772FjYa7JlqA==", "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -3385,12 +3363,6 @@ "node": ">=0.10.0" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -3588,35 +3560,6 @@ "node": ">=8.10.0" } }, - "node_modules/rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", - "dev": true, - "dependencies": { - "resolve": "^1.1.6" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -3809,23 +3752,6 @@ "node": ">=10" } }, - "node_modules/shelljs": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", - "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", - "dev": true, - "dependencies": { - "glob": "^7.0.0", - "interpret": "^1.0.0", - "rechoir": "^0.6.2" - }, - "bin": { - "shjs": "bin/shjs" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -3918,18 +3844,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/swagger-jsdoc": { "version": "6.2.5", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.5.tgz", @@ -6286,12 +6200,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", - "dev": true - }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6325,15 +6233,6 @@ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" }, - "is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "requires": { - "hasown": "^2.0.0" - } - }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6697,9 +6596,9 @@ } }, "openai": { - "version": "4.33.1", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.33.1.tgz", - "integrity": "sha512-0DH572aSxGTT1JPOXgJQ9mjiuSPg/7scPot8hLc5I1mfQxPxLXTZWJpWerKaIWOuPkR2nrB0SamGDEehH8RuWA==", + "version": "4.47.2", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.47.2.tgz", + "integrity": "sha512-E3Wq9mYdDSLajmcJm9RO/lCegTKrQ7ilAkMbhob4UgGhTjHwIHI+mXNDNPl5+sGIUp2iVUkpoi772FjYa7JlqA==", "requires": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -6737,12 +6636,6 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -6867,26 +6760,6 @@ "picomatch": "^2.2.1" } }, - "rechoir": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", - "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", - "dev": true, - "requires": { - "resolve": "^1.1.6" - } - }, - "resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "requires": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -7023,17 +6896,6 @@ } } }, - "shelljs": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", - "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", - "dev": true, - "requires": { - "glob": "^7.0.0", - "interpret": "^1.0.0", - "rechoir": "^0.6.2" - } - }, "side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -7101,12 +6963,6 @@ "has-flag": "^4.0.0" } }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, "swagger-jsdoc": { "version": "6.2.5", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.5.tgz", diff --git a/package.json b/package.json index 2555ccf..5e8523f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "morgan": "^1.10.0", "node-cron": "^3.0.3", "onvif": "^0.6.5", - "openai": "^4.33.1", + "openai": "^4.47.2", "pidusage": "^3.0.0", "sharp": "^0.33.3", "swagger-jsdoc": "^6.1.0", @@ -32,7 +32,7 @@ "uuid": "^9.0.1" }, "scripts": { - "copy-assets": "node tools/copyAssets.js", + "copy-assets": "cp -r src/views src/public dist/.", "build": "prisma generate && tsc && tsc-alias && npm run copy-assets", "dev": "nodemon", "debug": "nodemon --inspect", @@ -76,7 +76,6 @@ "nodemon": "^2.0.19", "prettier": "^3.2.5", "prisma": "^5.9.1", - "shelljs": "^0.8.5", "ts-node": "^10.9.1", "tsc-alias": "^1.8.8", "tsconfig-paths": "^4.2.0", @@ -86,4 +85,4 @@ "engines": { "node": ">=20.0.0" } -} +} \ No newline at end of file diff --git a/prisma/migrations/20240530063454_add_stats_vital/migration.sql b/prisma/migrations/20240530063454_add_stats_vital/migration.sql new file mode 100644 index 0000000..9ac2ba1 --- /dev/null +++ b/prisma/migrations/20240530063454_add_stats_vital/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "VitalsStat" ( + "id" SERIAL NOT NULL, + "imageId" TEXT NOT NULL, + "vitalsFromObservation" JSONB NOT NULL, + "vitalsFromImage" JSONB NOT NULL, + "gptDetails" JSONB NOT NULL, + "accuracy" DOUBLE PRECISION NOT NULL, + "cumulativeAccuracy" DOUBLE PRECISION NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VitalsStat_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20240609170140_change_accuracy_to_json/migration.sql b/prisma/migrations/20240609170140_change_accuracy_to_json/migration.sql new file mode 100644 index 0000000..f977e26 --- /dev/null +++ b/prisma/migrations/20240609170140_change_accuracy_to_json/migration.sql @@ -0,0 +1,16 @@ +-- Rename the existing column to a temporary name +ALTER TABLE "VitalsStat" RENAME COLUMN "accuracy" TO "accuracy_temp"; +ALTER TABLE "VitalsStat" RENAME COLUMN "cumulativeAccuracy" TO "cumulativeAccuracy_temp"; + +-- Add the new column with the Json type +ALTER TABLE "VitalsStat" ADD COLUMN "accuracy" Json; +ALTER TABLE "VitalsStat" ADD COLUMN "cumulativeAccuracy" Json; + +-- Copy the data from the old column to the new column, converting floats to JSON +UPDATE "VitalsStat" SET "accuracy" = json_build_object('overall', "accuracy_temp", 'metrics', '[]'::json); +UPDATE "VitalsStat" SET "cumulativeAccuracy" = json_build_object('overall', "cumulativeAccuracy_temp", 'metrics', '[]'::json); + +-- Drop the temporary column +ALTER TABLE "VitalsStat" DROP COLUMN "accuracy_temp"; +ALTER TABLE "VitalsStat" DROP COLUMN "cumulativeAccuracy_temp"; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78161cb..6b00ef2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,3 +65,15 @@ model DailyRound { time DateTime @default(now()) asset Asset @relation(fields: [assetExternalId], references: [externalId], onDelete: Cascade, onUpdate: Cascade) } + +model VitalsStat { + id Int @id @default(autoincrement()) + imageId String + vitalsFromObservation Json + vitalsFromImage Json + gptDetails Json + accuracy Json + cumulativeAccuracy Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/controller/ObservationController.ts b/src/controller/ObservationController.ts index 15fc69f..d1e6841 100644 --- a/src/controller/ObservationController.ts +++ b/src/controller/ObservationController.ts @@ -1,18 +1,20 @@ import type { Request, Response } from "express"; - - import { BadRequestException } from "@/Exception/BadRequestException"; import { NotFoundException } from "@/Exception/NotFoundException"; -import type { DailyRoundObservation, LastObservationData, Observation, ObservationStatus, ObservationType, ObservationTypeWithWaveformTypes, StaticObservation } from "@/types/observation"; +import type { + LastObservationData, + Observation, + ObservationStatus, + ObservationType, + ObservationTypeWithWaveformTypes, + StaticObservation, +} from "@/types/observation"; import { WebSocket } from "@/types/ws"; import { ObservationsMap } from "@/utils/ObservationsMap"; import { catchAsync } from "@/utils/catchAsync"; -import { hostname } from "@/utils/configs"; -import { makeDataDumpToJson } from "@/utils/makeDataDump"; import { filterClients } from "@/utils/wsUtils"; - export var staticObservations: StaticObservation[] = []; var activeDevices: string[] = []; var lastRequestData = {}; @@ -22,31 +24,10 @@ var logData: { }[] = []; var statusData: ObservationStatus[] = []; var lastObservationData: LastObservationData = {}; -let observationData: { time: Date; data: Observation[][] }[] = []; +export let observationData: { time: Date; data: Observation[][] }[] = []; - -const S3_DATA_DUMP_INTERVAL = 1000 * 60 * 60; const DEFAULT_LISTING_LIMIT = 10; -setInterval(() => { - makeDataDumpToJson( - observationData, - `${hostname}/${new Date().getTime()}.json`, - { - slug: "s3_observations_dump", - options: { - schedule: { - type: "interval", - unit: "minutes", - value: S3_DATA_DUMP_INTERVAL / (1000 * 60), - }, - }, - }, - ); - - observationData = []; -}, S3_DATA_DUMP_INTERVAL); - const getTime = (date: string) => new Date(date.replace(" ", "T").concat("+0530")); @@ -246,7 +227,7 @@ export class ObservationController { updateLastObservationData(flattenedObservations); this.latestObservation.set(flattenedObservations); - filterClients(req.wsInstance.getWss(), "/observations").forEach( + filterClients(req.wsInstance.getWss(), "/observations", undefined).forEach( (client: WebSocket) => { const filteredObservations = flattenedObservations?.filter( (observation: Observation) => @@ -296,4 +277,4 @@ export class ObservationController { filterStatusData(); return res.json(statusData); }); -} \ No newline at end of file +} diff --git a/src/controller/ServerStatusController.ts b/src/controller/ServerStatusController.ts index b11d4cf..09fb974 100644 --- a/src/controller/ServerStatusController.ts +++ b/src/controller/ServerStatusController.ts @@ -1,55 +1,6 @@ import type { Request, Response } from "express"; -import expressWs from "express-ws"; -import { loadavg } from "os"; -import pidusage from "pidusage"; - -import type { WebSocket } from "@/types/ws"; -import { eventType } from "@/utils/eventTypeConstant"; -import { filterClients } from "@/utils/wsUtils"; export class ServerStatusController { - static init(ws: expressWs.Instance) { - const server = ws.getWss(); - let intervalId: NodeJS.Timeout | number | undefined = undefined; - let clients: WebSocket[] = []; - server.on("connection", () => { - clients = filterClients(server, "/logger"); - if (!intervalId && clients.length !== 0) { - intervalId = setInterval(() => { - pidusage(process.pid, (err, stat) => { - if (err) { - console.log(err); - return null; - } - - if (!server.clients?.size) { - clearInterval(intervalId); - intervalId = undefined; - } - - const data = { - type: eventType.Resource, - cpu: Number(stat.cpu).toFixed(2), - memory: Number(stat.memory / 1024 / 1024).toFixed(2), - uptime: stat.elapsed, - load: loadavg()[0] || 0, - }; - - clients.forEach((client) => { - client.send(JSON.stringify(data)); - }); - }); - }, 1000); - } - - clients?.forEach((client) => { - client.on("close", () => { - clients = filterClients(server, "/logger"); - }); - }); - }); - } - static render(req: Request, res: Response) { res.render("pages/serverStatus", { req }); } diff --git a/src/controller/VitalsStatController.ts b/src/controller/VitalsStatController.ts new file mode 100644 index 0000000..9c9faa9 --- /dev/null +++ b/src/controller/VitalsStatController.ts @@ -0,0 +1,21 @@ +import { Request, Response } from "express"; + +import prisma from "@/lib/prisma"; + +export class VitalsStatController { + static latestAccuracy = async (req: Request, res: Response) => { + const vitalsStat = await prisma.vitalsStat.findFirst({ + orderBy: { createdAt: "desc" }, + }); + + if (!vitalsStat) { + return res.status(404).json({ message: "No vitals stat found" }); + } + + return res.status(200).json({ + accuracy: vitalsStat.accuracy, + cumulativeAccuracy: vitalsStat.cumulativeAccuracy, + time: vitalsStat.createdAt, + }); + }; +} diff --git a/src/cron/automatedDailyRounds.ts b/src/cron/automatedDailyRounds.ts index e079211..975928f 100644 --- a/src/cron/automatedDailyRounds.ts +++ b/src/cron/automatedDailyRounds.ts @@ -1,23 +1,29 @@ import axios, { AxiosError, AxiosResponse } from "axios"; +import { randomUUID } from "crypto"; import fs from "fs"; import path from "path"; -import { staticObservations } from "@/controller/ObservationController"; +import { observationData, staticObservations } from "@/controller/ObservationController"; import prisma from "@/lib/prisma"; import { AssetBed } from "@/types/asset"; import { CameraParams } from "@/types/camera"; import { CarePaginatedResponse } from "@/types/care"; -import { DailyRoundObservation, Observation, ObservationType } from "@/types/observation"; +import { + DailyRoundObservation, + Observation, + ObservationType, +} from "@/types/observation"; import { OCRV2Response } from "@/types/ocr"; import { CameraUtils } from "@/utils/CameraUtils"; import { isValid } from "@/utils/ObservationUtils"; import { generateHeaders } from "@/utils/assetUtils"; -import { careApi, openaiApiKey, saveDailyRound } from "@/utils/configs"; +import { careApi, openaiApiKey, openaiApiVersion, openaiVisionModel, saveDailyRound, saveVitalsStat } from "@/utils/configs"; import { getPatientId } from "@/utils/dailyRoundUtils"; import { downloadImage } from "@/utils/downloadImageWithDigestRouter"; import { parseVitalsFromImage } from "@/utils/ocr"; +import { Accuracy, calculateVitalsAccuracy } from "@/utils/vitalsAccuracy"; const UPDATE_INTERVAL = 60 * 60 * 1000; @@ -64,8 +70,8 @@ export async function getMonitorPreset(bedId: string, assetId: string) { export async function saveImageLocally( snapshotUrl: string, camParams: CameraParams, + fileName = `image--${new Date().getTime()}.jpeg`, ) { - const fileName = `image--${new Date().getTime()}.jpeg`; const imagePath = path.resolve("images", fileName); await downloadImage( snapshotUrl, @@ -86,13 +92,12 @@ export async function getVitalsFromImage(imageUrl: string) { return null; } - // const date = data.time_stamp ? new Date(data.time_stamp) : new Date(); - // const isoDate = - // date.toString() !== "Invalid Date" - // ? date.toISOString() - // : new Date().toISOString(); - const isoDate = new Date().toISOString(); - + const date = data.time_stamp ? new Date(data.time_stamp) : new Date(); + const isoDate = + date.toString() !== "Invalid Date" + ? date.toISOString() + : new Date().toISOString(); + const payload = { taken_at: isoDate, spo2: data.spO2?.oxygen_saturation_percentage ?? null, @@ -121,7 +126,7 @@ export async function getVitalsFromImage(imageUrl: string) { payload.bp = {}; } - return payload; + return payloadHasData(payload) ? payload : null; } export async function fileAutomatedDailyRound( @@ -138,7 +143,7 @@ export async function fileAutomatedDailyRound( .catch((error: AxiosError) => error.response); if (saveDailyRound) { - prisma.dailyRound.create({ + await prisma.dailyRound.create({ data: { assetExternalId: assetId, status: response?.statusText ?? "FAILED", @@ -225,7 +230,7 @@ export async function getVitalsFromObservations(assetHostname: string) { } const data = observation.observations; - return { + const vitals = { taken_at: observation.last_updated, spo2: getValueFromData("SpO2", data), ventilator_spo2: getValueFromData("SpO2", data), @@ -242,6 +247,8 @@ export async function getVitalsFromObservations(assetHostname: string) { rounds_type: "AUTOMATED", is_parsed_by_ocr: false, } as DailyRoundObservation; + + return payloadHasData(vitals) ? vitals : null; } export function payloadHasData(payload: Record): boolean { @@ -258,6 +265,72 @@ export function payloadHasData(payload: Record): boolean { }); } +export function getVitalsFromObservationsForAccuracy( + deviceId: string, + time: string, +) { + // TODO: consider optimizing this + const observations = observationData + .reduce((acc, curr) => { + return [...acc, ...curr.data]; + }, [] as Observation[][]) + .find( + (observation) => + observation[0].device_id === deviceId && + new Date(observation[0]["date-time"]).toISOString() === + new Date(time).toISOString(), + ); + + if (!observations) { + return null; + } + + const vitals = observations.reduce( + (acc, curr) => { + switch (curr.observation_id) { + case "SpO2": + return { ...acc, spo2: curr.value, ventilator_spo2: curr.value }; + case "respiratory-rate": + return { ...acc, resp: curr.value }; + case "heart-rate": + return { ...acc, pulse: curr.value ?? acc.pulse }; + case "pulse-rate": + return { ...acc, pulse: acc.pulse ?? curr.value }; + case "body-temperature1": + return { + ...acc, + temperature: curr.value ?? acc.temperature, + temperature_measured_at: curr["date-time"], + }; + case "body-temperature2": + return { + ...acc, + temperature: acc.temperature ?? curr.value, + temperature_measured_at: curr["date-time"], + }; + case "blood-pressure": + return { + ...acc, + bp: { + systolic: curr.systolic.value, + diastolic: curr.diastolic.value, + map: curr.map?.value, + }, + }; + default: + return acc; + } + }, + { + taken_at: time, + rounds_type: "AUTOMATED", + is_parsed_by_ocr: false, + } as DailyRoundObservation, + ); + + return payloadHasData(vitals) ? vitals : null; +} + export async function automatedDailyRounds() { console.log("Automated daily rounds"); const monitors = await prisma.asset.findMany({ @@ -281,13 +354,20 @@ export async function automatedDailyRounds() { return; } - let vitals: DailyRoundObservation | null = await getVitalsFromObservations( - monitor.ipAddress, - ); + const _id = randomUUID(); + let vitals: DailyRoundObservation | null = saveVitalsStat + ? null + : await getVitalsFromObservations(monitor.ipAddress); - console.log(`Vitals from observations: ${JSON.stringify(vitals)}`); + console.log( + saveVitalsStat + ? "Skipping vitals from observations as saving vitals stat is enabled" + : `Vitals from observations: ${JSON.stringify(vitals)}`, + ); if (!vitals && openaiApiKey) { + console.log(`Getting vitals from camera for the patient ${patient_id}`); + if (!asset_beds || asset_beds.length === 0) { console.error( `No asset beds found for the asset ${monitor.externalId}`, @@ -325,7 +405,7 @@ export async function automatedDailyRounds() { const snapshotUrl = await CameraUtils.getSnapshotUri({ camParams: camera, }); - const imageUrl = await saveImageLocally(snapshotUrl.uri, camera); + const imageUrl = await saveImageLocally(snapshotUrl.uri, camera, _id); CameraUtils.unlockCamera(camera.hostname); @@ -333,7 +413,79 @@ export async function automatedDailyRounds() { console.log(`Vitals from image: ${JSON.stringify(vitals)}`); } - if (!vitals || !payloadHasData(vitals)) { + if (vitals && saveVitalsStat) { + const vitalsFromObservation = await getVitalsFromObservationsForAccuracy( + monitor.ipAddress, + new Date(vitals.taken_at!).toISOString(), + ); + console.log( + `Vitals from observations for accuracy: ${JSON.stringify(vitalsFromObservation)}`, + ); + + const accuracy = calculateVitalsAccuracy(vitals, vitalsFromObservation); + + if (accuracy !== null) { + console.log(`Accuracy: ${accuracy.overall}%`); + + const lastVitalRecord = await prisma.vitalsStat.findFirst({ + orderBy: { createdAt: "desc" }, + }); + const weight = lastVitalRecord?.id; // number of records + const cumulativeAccuracy = ( + lastVitalRecord?.cumulativeAccuracy as Accuracy + ).metrics.map((metric) => { + const latestMetric = accuracy.metrics.find( + (m) => m.field === metric.field, + ); + + return { + ...metric, + accuracy: lastVitalRecord + ? (metric.accuracy * weight! + latestMetric?.accuracy!) / + (weight! + 1) + : latestMetric?.accuracy!, + falsePositive: + lastVitalRecord && latestMetric?.falsePositive + ? (metric.falsePositive! * weight! + + latestMetric?.falsePositive!) / + (weight! + 1) + : metric.falsePositive, + falseNegative: + lastVitalRecord && latestMetric?.falseNegative + ? (metric.falseNegative! * weight! + + latestMetric?.falseNegative!) / + (weight! + 1) + : metric.falseNegative, + }; + }); + + prisma.vitalsStat.create({ + data: { + imageId: _id, + vitalsFromImage: JSON.parse(JSON.stringify(vitals)), + vitalsFromObservation: JSON.parse( + JSON.stringify(vitalsFromObservation), + ), + gptDetails: { + model: openaiVisionModel, + version: openaiApiVersion, + }, + accuracy, + cumulativeAccuracy, + }, + }); + } + } + + const vitalsFromObservation = await getVitalsFromObservations( + monitor.ipAddress, + ); + console.log( + `Vitals from observations: ${JSON.stringify(vitalsFromObservation)}`, + ); + vitals = vitalsFromObservation ?? vitals; + + if (!vitals) { console.error(`No vitals found for the patient ${patient_id}`); return; } diff --git a/src/cron/observationsS3Dump.ts b/src/cron/observationsS3Dump.ts new file mode 100644 index 0000000..3f4b254 --- /dev/null +++ b/src/cron/observationsS3Dump.ts @@ -0,0 +1,18 @@ +import { observationData } from "@/controller/ObservationController"; +import { hostname } from "@/utils/configs"; +import { makeDataDumpToJson } from "@/utils/makeDataDump"; + +export async function observationsS3Dump() { + const data = [...observationData]; + makeDataDumpToJson(data, `${hostname}/${new Date().getTime()}.json`, { + slug: "s3_observations_dump", + options: { + schedule: { + type: "crontab", + value: "30 * * * *", + }, + }, + }); + + observationData.splice(0, data.length); +} diff --git a/src/cron/vitalsStatS3Dump.ts b/src/cron/vitalsStatS3Dump.ts new file mode 100644 index 0000000..342e3ac --- /dev/null +++ b/src/cron/vitalsStatS3Dump.ts @@ -0,0 +1,56 @@ +import fs from "fs"; +import path from "path"; + +import prisma from "@/lib/prisma"; +import { deleteVitalsStatOnDump, hostname } from "@/utils/configs"; +import { makeDataDumpToJson } from "@/utils/makeDataDump"; + +export async function vitalsStatS3Dump() { + // TODO: make the date range configurable + const toDate = new Date(); + const fromDate = new Date(toDate.getTime() - 24 * 60 * 60 * 1000); + + const vitalsStats = await prisma.vitalsStat.findMany({ + where: { + createdAt: { + gte: fromDate, + lte: toDate, + }, + }, + }); + + const dumpData = vitalsStats.map((vitalsStat) => { + const imageUrl = path.resolve("images", vitalsStat.imageId); + const image = fs.existsSync(imageUrl) + ? fs.readFileSync(imageUrl).toString("base64") + : null; + + return { + ...vitalsStat, + image, + }; + }); + + makeDataDumpToJson( + dumpData, + `${hostname}/vitals-stats/${fromDate.toISOString()}-${toDate.toISOString()}.json`, + ); + + if (deleteVitalsStatOnDump) { + await prisma.vitalsStat.deleteMany({ + where: { + createdAt: { + gte: fromDate, + lte: toDate, + }, + }, + }); + + vitalsStats.forEach((vitalsStat) => { + const imageUrl = path.resolve("images", vitalsStat.imageId); + if (fs.existsSync(imageUrl)) { + fs.unlinkSync(imageUrl); + } + }); + } +} diff --git a/src/index.ts b/src/index.ts index eb42161..6faa718 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ +import { observationsS3Dump } from "./cron/observationsS3Dump"; +import { vitalsStatS3Dump } from "./cron/vitalsStatS3Dump"; import * as cron from "node-cron"; import { automatedDailyRounds } from "@/cron/automatedDailyRounds"; import { retrieveAssetConfig } from "@/cron/retrieveAssetConfig"; import { initServer } from "@/server"; -import { port } from "@/utils/configs"; +import { port, s3DumpVitalsStat } from "@/utils/configs"; process.env.AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE = "1"; process.env.CHECKPOINT_DISABLE = "1"; @@ -16,9 +18,16 @@ process.env.CHECKPOINT_DISABLE = "1"; setTimeout(() => { retrieveAssetConfig(); - cron.schedule("0 */6 * * *", retrieveAssetConfig); + cron.schedule("0 */6 * * *", retrieveAssetConfig); // every 6 hours - cron.schedule("0 */1 * * *", automatedDailyRounds); + cron.schedule("0 */1 * * *", automatedDailyRounds); // every hour + + // scheduled to run at 30th minute of every hour so that the automatedDailyRounds can use the data without any issues + cron.schedule("30 * * * *", observationsS3Dump); // every hour (30th minute) + + if (s3DumpVitalsStat) { + cron.schedule("0 0 * * *", vitalsStatS3Dump); // every day at midnight + } }, 100); server.listen(port, () => diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 20adfd7..cc7786b 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -105,7 +105,7 @@ export const jwtAuthNoVerify = (): RequestHandler => { req.user.id = payload.sub; } } catch (error: any) { - console.log(error); + console.warn(`JWT verification failed: ${error.code}`); } } next(); diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index bf5f6df..d880c97 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -34,7 +34,9 @@ export const errorHandler: ErrorRequestHandler = (err, req, res, next) => { }; const server = req.wsInstance.getWss(); - filterClients(server, "/logger").forEach((c) => c.send(JSON.stringify(data))); + filterClients(server, "/logger", true).forEach((c) => + c.send(JSON.stringify(data)), + ); if (nodeEnv === "development") { console.error(err); diff --git a/src/middleware/morganWithWs.ts b/src/middleware/morganWithWs.ts index 10ac6e4..c39de1f 100644 --- a/src/middleware/morganWithWs.ts +++ b/src/middleware/morganWithWs.ts @@ -1,6 +1,7 @@ import type { Request } from "express"; import morgan from "morgan"; +import type { WebSocket } from "@/types/ws"; import { eventType } from "@/utils/eventTypeConstant"; import { filterClients } from "@/utils/wsUtils"; @@ -14,9 +15,9 @@ export const morganWithWs = morgan(function (tokens, req: Request, res) { }; const server = req.wsInstance.getWss("/logger"); - filterClients(server, "/logger").forEach((client) => - client.send(JSON.stringify({ type: eventType.Request, ...data })), - ); + filterClients(server, "/logger", true).forEach((client: WebSocket) => { + client.send(JSON.stringify({ type: eventType.Request, ...data })); + }); return Object.values(data).join(" "); }); diff --git a/src/public/assets/styles/style.css b/src/public/assets/styles/style.css deleted file mode 100644 index 254f185..0000000 --- a/src/public/assets/styles/style.css +++ /dev/null @@ -1,3 +0,0 @@ -.btn { - @apply text-base px-4 py-2 border-2; -} diff --git a/src/public/static/css/styles.css b/src/public/static/css/styles.css new file mode 100644 index 0000000..c6569e7 --- /dev/null +++ b/src/public/static/css/styles.css @@ -0,0 +1,15 @@ +#server-status-dot[data-status="red"] .animate-ping { + background-color: #f87171 !important; /* Tailwind CSS bg-red-400 */ +} + +#server-status-dot[data-status="red"] .relative { + background-color: #dc2626 !important; /* Tailwind CSS bg-red-600 */ +} + +#server-status-dot[data-status="green"] .animate-ping { + background-color: #34d399 !important; /* Tailwind CSS bg-green-400 */ +} + +#server-status-dot[data-status="green"] .relative { + background-color: #059669 !important; /* Tailwind CSS bg-green-600 */ +} diff --git a/src/public/assets/js/ws.js b/src/public/static/js/ws.js similarity index 96% rename from src/public/assets/js/ws.js rename to src/public/static/js/ws.js index b3a0a74..ed792ca 100644 --- a/src/public/assets/js/ws.js +++ b/src/public/static/js/ws.js @@ -93,7 +93,7 @@ function connect() { var ws = new WebSocket(url); ws.onopen = function () { console.log("Connected to server"); - serverStatusDot.classList.add("bg-green-500"); + serverStatusDot.setAttribute("data-status", "green"); serverStatusText.innerText = "Connected"; }; const isFirstLog = { @@ -134,8 +134,7 @@ function connect() { e.reason, ); - serverStatusDot.classList.remove("bg-green-500"); - serverStatusDot.classList.add("bg-red-500"); + serverStatusDot.setAttribute("data-status", "red"); serverStatusText.innerText = "Disconnected"; cpuUsage.innerText = `---`; diff --git a/src/router/serverStatusRouter.ts b/src/router/serverStatusRouter.ts index 64fdec6..9e5bdc0 100644 --- a/src/router/serverStatusRouter.ts +++ b/src/router/serverStatusRouter.ts @@ -1,12 +1,9 @@ import express from "express"; import { ServerStatusController } from "@/controller/ServerStatusController"; -import { jwtAuth } from "@/middleware/auth"; const router = express.Router(); -router.use(jwtAuth()); - router.get("", ServerStatusController.render); export { router as serverStatusRouter }; diff --git a/src/router/vitalsStatRouter.ts b/src/router/vitalsStatRouter.ts new file mode 100644 index 0000000..f088b5a --- /dev/null +++ b/src/router/vitalsStatRouter.ts @@ -0,0 +1,9 @@ +import express from "express"; + +import { VitalsStatController } from "@/controller/VitalsStatController"; + +const router = express.Router(); + +router.get("/accuracy", VitalsStatController.latestAccuracy); + +export { router as vitalsStatRouter }; diff --git a/src/server.ts b/src/server.ts index 4b962b5..c32237c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,8 +9,9 @@ import helmet from "helmet"; import path from "path"; import swaggerUi from "swagger-ui-express"; + + import { OpenidConfigController } from "@/controller/OpenidConfigController"; -import { ServerStatusController } from "@/controller/ServerStatusController"; import { randomString } from "@/lib/crypto"; import { errorHandler } from "@/middleware/errorHandler"; import { getWs } from "@/middleware/getWs"; @@ -27,6 +28,7 @@ import { healthRouter } from "@/router/healthRouter"; import { observationRouter } from "@/router/observationRouter"; import { serverStatusRouter } from "@/router/serverStatusRouter"; import { streamAuthApiRouter } from "@/router/streamAuthApiRouter"; +import { vitalsStatRouter } from "@/router/vitalsStatRouter"; import { swaggerSpec } from "@/swagger/swagger"; import type { WebSocket } from "@/types/ws"; import { @@ -35,6 +37,7 @@ import { sentryEnv, sentryTracesSampleRate, } from "@/utils/configs"; +import { sendStatus } from "@/utils/serverStatusUtil"; export function initServer() { const appBase = express(); @@ -52,7 +55,7 @@ export function initServer() { app.set("view engine", "ejs"); app.set("views", path.resolve(__dirname, "views")); - app.use(express.static(path.join(path.resolve(), "src/public"))); + app.use(express.static(path.resolve(__dirname, "public"))); app.use(cookieParser()); @@ -74,6 +77,7 @@ export function initServer() { app.use(flash()); app.use(getWs(ws)); + app.use(morganWithWs); app.use( helmet({ @@ -87,9 +91,6 @@ export function initServer() { app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ extended: true })); - // logger - app.use(morganWithWs); - if (nodeEnv === "debug") { app.use(requestLogger); } @@ -110,6 +111,7 @@ export function initServer() { app.use("/assets", assetConfigRouter); app.use("/api/assets", assetConfigApiRouter); app.use("/api/stream", streamAuthApiRouter); + app.use("/api/vitals-stats", vitalsStatRouter); app.get("/.well-known/jwks.json", OpenidConfigController.publicJWKs); app.get( @@ -118,7 +120,12 @@ export function initServer() { ); app.ws("/logger", (ws: WebSocket, req) => { + ws.user = req.user; ws.route = "/logger"; + const timeout = sendStatus(ws); + ws.on("close", () => { + clearInterval(timeout); + }); }); app.ws("/observations/:ip", (ws: WebSocket, req) => { ws.route = "/observations"; @@ -131,8 +138,5 @@ export function initServer() { app.use(Sentry.Handlers.errorHandler()); app.use(errorHandler); - // Server status monitor - ServerStatusController.init(ws); - return app; -} +} \ No newline at end of file diff --git a/src/types/ws.ts b/src/types/ws.ts index 6e33ad3..c03ebb8 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -1,6 +1,8 @@ +import { User } from "./user"; import type { WebSocket as InitialWebSocket } from "ws"; export interface WebSocket extends InitialWebSocket { - route?: string; params?: Record; + route?: string; + user?: User; } diff --git a/src/utils/configs.ts b/src/utils/configs.ts index 0a5e14f..83ad83b 100644 --- a/src/utils/configs.ts +++ b/src/utils/configs.ts @@ -1,6 +1,5 @@ import * as dotenv from "dotenv"; - dotenv.config(); export const nodeEnv = process.env.NODE_ENV ?? "development"; @@ -19,8 +18,10 @@ export const sentryEnv = process.env.SENTRY_ENV ?? "unknown"; export const sentryTracesSampleRate = parseFloat( process.env.SENTRY_SAMPLE_RATE ?? "0.01", ); - -export const saveDailyRound = Boolean(process.env.SAVE_DAILY_ROUND ?? "true"); +export const saveDailyRound = + (process.env.SAVE_DAILY_ROUND || "true") === "true"; +export const saveVitalsStat = + (process.env.SAVE_VITALS_STAT || "true") === "true"; export const s3Provider = process.env.S3_PROVIDER ?? "AWS"; export const s3Endpoint = @@ -33,4 +34,14 @@ export const s3BucketName = process.env.S3_BUCKET_NAME; export const s3AccessKeyId = process.env.S3_ACCESS_KEY_ID; export const s3SecretAccessKey = process.env.S3_SECRET_ACCESS_KEY; -export const openaiApiKey = process.env.OPENAI_API_KEY ?? ""; \ No newline at end of file +export const s3DumpVitalsStat = + (process.env.S3_DUMP_VITALS_STAT || "false") === "true"; +export const deleteVitalsStatOnDump = + (process.env.DELETE_VITALS_STAT_ON_DUMP || "false") === "true"; + +export const openaiApiKey = process.env.OPENAI_API_KEY ?? ""; +export const openaiEndpoint = process.env.OPENAI_ENDPOINT ?? ""; +export const openaiApiVersion = process.env.OPENAI_API_VERSION ?? "2024-02-01"; +export const openaiVisionModel = + process.env.OPENAI_VISION_MODEL ?? "vision-preview"; +export const openaiUseAzure = openaiEndpoint.includes("azure.com"); diff --git a/src/utils/dailyRoundUtils.ts b/src/utils/dailyRoundUtils.ts index ad17ba8..9c49067 100644 --- a/src/utils/dailyRoundUtils.ts +++ b/src/utils/dailyRoundUtils.ts @@ -52,7 +52,7 @@ export const getPatientId = async (assetExternalId: string) => { // }; export const getBedById = async (bedId: string) => { - return prisma.bed.findFirst({ + return await prisma.bed.findFirst({ where: { externalId: bedId, deleted: false, diff --git a/src/utils/makeDataDump.ts b/src/utils/makeDataDump.ts index 0c52d38..426b6ea 100644 --- a/src/utils/makeDataDump.ts +++ b/src/utils/makeDataDump.ts @@ -1,20 +1,17 @@ import { captureCheckIn } from "@sentry/node"; import AWS from "aws-sdk"; -import { - s3AccessKeyId, - s3BucketName, - s3Endpoint, - s3Provider, - s3SecretAccessKey, -} from "@/utils/configs"; + + +import { s3AccessKeyId, s3BucketName, s3Endpoint, s3Provider, s3SecretAccessKey } from "@/utils/configs"; + export const makeDataDumpToJson = async ( data: Record | any[], key: string, monitorOptions?: { slug: string; - options?: any; + options?: Parameters[1]; }, ) => { let checkInId: string | undefined = undefined; @@ -80,4 +77,4 @@ export const makeDataDumpToJson = async ( } console.log(err); } -}; +}; \ No newline at end of file diff --git a/src/utils/ocr.ts b/src/utils/ocr.ts index f2a6f9f..684d96e 100644 --- a/src/utils/ocr.ts +++ b/src/utils/ocr.ts @@ -1,11 +1,22 @@ -import { openaiApiKey } from "./configs"; -import OpenAI from "openai"; +import { + openaiApiKey, + openaiApiVersion, + openaiEndpoint, + openaiUseAzure, + openaiVisionModel, +} from "./configs"; +import { AzureOpenAI, OpenAI } from "openai"; import sharp from "sharp"; - -const openai = new OpenAI({ - apiKey: openaiApiKey, -}); +const openai = openaiUseAzure + ? new AzureOpenAI({ + apiKey: openaiApiKey, + endpoint: openaiEndpoint, + apiVersion: openaiApiVersion, + }) + : new OpenAI({ + apiKey: openaiApiKey, + }); export async function compressImage(image: Buffer) { return await sharp(image).resize(1000).jpeg({ quality: 80 }).toBuffer(); @@ -20,15 +31,16 @@ export async function parseVitalsFromImage(image: Buffer) { const b64Image = encodeImage(compressedImage); const imageUrl = `data:image/jpeg;base64,${b64Image}`; - const completions = await openai.chat.completions.create({ - model: "gpt-4-turbo", - max_tokens: 4096, - temperature: 0.4, - response_format: { type: "json_object" }, - messages: [ - { - role: "system", - content: ` + try { + const completions = await openai.chat.completions.create({ + model: openaiVisionModel, + max_tokens: 4096, + temperature: 0.4, + // response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: ` You are an expert 5Para Monitor reader of patients. You are given 5Para Monitor image, analyze it and predict patient's reading, you will output the readings in minified JSON format only. Tips to analyze the ocr data: monitor can be zoomed in or zoomed out, most of the times readings that we want are at extreme right of the monitor screen, use expertise in reading 5ParaMonitor to make educated guesses about the correct reading of a field. @@ -36,30 +48,36 @@ export async function parseVitalsFromImage(image: Buffer) { NOTE: Many fields from below example can be missing, you need to output null for those fields. Example output in minified JSON format: - {"time_stamp":"yyyy-mm-ddThh:mm:ss","ecg":{"Heart_Rate_bpm":},"nibp":{"systolic_mmhg":,"diastolic_mmhg":,"mean_arterial_pressure_mmhg":},"spO2":{"oxygen_saturation_percentage":},"respiration_rate":{"breaths_per_minute":},"temperature":{"fahrenheit":}} + {"time_stamp":"yyyy-mm-ddThh:mm:ssZ","ecg":{"Heart_Rate_bpm":},"nibp":{"systolic_mmhg":,"diastolic_mmhg":,"mean_arterial_pressure_mmhg":},"spO2":{"oxygen_saturation_percentage":},"respiration_rate":{"breaths_per_minute":},"temperature":{"fahrenheit":}} + + The output should be minified JSON format only. `.trim(), - }, - { - role: "user", - content: [ - { - type: "image_url", - image_url: { - url: imageUrl, + }, + { + role: "user", + content: [ + { + type: "image_url", + image_url: { + url: imageUrl, + }, }, - }, - ], - }, - ], - }); + ], + }, + ], + }); + + const response = completions.choices.shift()?.message.content; - const response = completions.choices.shift()?.message.content; + if (!response) { + console.error("Failed to get response from OpenAI"); + return null; + } - if (!response) { - console.error("Failed to get response from OpenAI"); + console.log(`[OCR] : ${response}`); + return JSON.parse(response); + } catch (error) { + console.error("Failed to get response from OpenAI", error); return null; } - - console.log(`[OCR] : ${response}`); - return JSON.parse(response); -} \ No newline at end of file +} diff --git a/src/utils/serverStatusUtil.ts b/src/utils/serverStatusUtil.ts new file mode 100644 index 0000000..d42a0b9 --- /dev/null +++ b/src/utils/serverStatusUtil.ts @@ -0,0 +1,35 @@ +import { cpus, loadavg } from "os"; +import pidusage from "pidusage"; + +import { WebSocket } from "@/types/ws"; +import { eventType } from "@/utils/eventTypeConstant"; + +const State = { + type: eventType.Resource, + cpu: "0.00", + memory: "0.00", + uptime: 0, + load: "0.00", +}; + +setInterval(() => { + pidusage(process.pid, (err, stat) => { + if (err) { + console.log(err); + return null; + } + + State["cpu"] = Number(stat.cpu).toFixed(2); + State["memory"] = Number(stat.memory / 1024 / 1024).toFixed(2); + State["uptime"] = stat.elapsed; + State["load"] = (loadavg()?.[1] / cpus()?.length)?.toFixed(2) ?? "0.00"; // 5 minutes load average + }); +}, 1000); + +function sendStatus(client: WebSocket) { + return setInterval(() => { + client.send(JSON.stringify(State)); + }, 1000); +} + +export { State, sendStatus }; diff --git a/src/utils/vitalsAccuracy.ts b/src/utils/vitalsAccuracy.ts new file mode 100644 index 0000000..bd614d8 --- /dev/null +++ b/src/utils/vitalsAccuracy.ts @@ -0,0 +1,107 @@ +import { DailyRoundObservation } from "@/types/observation"; + +type ComparisonType = "relative" | "fixed"; + +type AccuracyMetrics = { + field: string; + accuracy: number; + falsePositive: number; + falseNegative: number; +}; + +export type Accuracy = { overall: number; metrics: AccuracyMetrics[] }; + +function calculateAccuracy( + obj1: Object, + obj2: Object, + keysToCompare: string[], + comparisonType: ComparisonType = "relative", +): AccuracyMetrics[] { + function compareValues( + value1: number, + value2: number, + comparisonType: ComparisonType = "relative", + ) { + if ( + value1 === null || + value2 === null || + value1 === undefined || + value2 === undefined || + isNaN(value1) || + isNaN(value2) + ) { + return 0; + } + + if (comparisonType === "relative") { + const maxDiff = Math.max(Math.abs(value1), Math.abs(value2)); + const diff = Math.abs(value1 - value2); + return maxDiff === 0 ? 1 : 1 - diff / maxDiff; + } else { + return value1 === value2 ? 1 : 0; + } + } + + function getValue(obj: any, key: string): any { + return key.split(".").reduce((o, k) => (o ? o[k] : undefined), obj); + } + + const metrics: AccuracyMetrics[] = []; + + for (const key of keysToCompare) { + const value1 = getValue(obj1, key); + const value2 = getValue(obj2, key); + const accuracy = compareValues(value1, value2, comparisonType); + const falsePositive = + (value1 === null || value1 === undefined) && + value2 !== null && + value2 !== undefined + ? 1 + : 0; + const falseNegative = + value1 !== null && + value1 !== undefined && + (value2 === null || value2 === undefined) + ? 1 + : 0; + + metrics.push({ + field: key, + accuracy, + falsePositive, + falseNegative, + }); + } + + return metrics; +} + +export function calculateVitalsAccuracy( + vitals: DailyRoundObservation | null | undefined, + original: DailyRoundObservation | null | undefined, + type: ComparisonType = "relative", +): Accuracy | null { + if (!vitals || !original) { + return null; + } + + const keysToCompare = [ + "spo2", + "ventilator_spo2", + "resp", + "pulse", + "temperature", + "bp.systolic", + "bp.diastolic", + ]; + + const metrics = calculateAccuracy(vitals, original, keysToCompare, type); + const overall = + metrics.reduce((acc, curr) => acc + curr.accuracy, 0) / + keysToCompare.length; + + return { + overall: overall * 100, + metrics, + }; +} diff --git a/src/utils/wsUtils.ts b/src/utils/wsUtils.ts index 42fb803..78379e5 100644 --- a/src/utils/wsUtils.ts +++ b/src/utils/wsUtils.ts @@ -2,9 +2,17 @@ import type { Server } from "ws"; import type { WebSocket } from "@/types/ws"; -export const filterClients = (ws: Server, path: string) => { - // console.log("CLEINT", ws.clients) - return Array.from(ws?.clients || []).filter( - (client: WebSocket) => client.route === path, - ); +export const filterClients = ( + ws: Server, + path: string, + isAuthenticated: boolean | undefined, +) => { + return Array.from(ws?.clients || []).filter((client: WebSocket) => { + if (isAuthenticated === undefined) { + return client.route === path; + } + return ( + client.route === path && Boolean(client?.user?.id) === isAuthenticated + ); + }); }; diff --git a/src/views/pages/serverStatus.ejs b/src/views/pages/serverStatus.ejs index bd79014..af0c7fd 100644 --- a/src/views/pages/serverStatus.ejs +++ b/src/views/pages/serverStatus.ejs @@ -11,12 +11,12 @@

Server Status :  + + + + -   +   Disconnected

@@ -48,6 +48,8 @@ + <% if (req?.user?.id ?? false) { %> + + + <% } else { %> + # Logs +

Log In to view request logs.

+ <% } %> + <%- include("../partials/footer") %> - + diff --git a/src/views/partials/head.ejs b/src/views/partials/head.ejs index d3b7c68..9ac8598 100644 --- a/src/views/partials/head.ejs +++ b/src/views/partials/head.ejs @@ -12,6 +12,8 @@ + +