diff --git a/package.json b/package.json index 2a3716c..5e8523f 100644 --- a/package.json +++ b/package.json @@ -85,4 +85,4 @@ "engines": { "node": ">=20.0.0" } -} +} \ No newline at end of file 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 bf59840..6b00ef2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,8 +72,8 @@ model VitalsStat { vitalsFromObservation Json vitalsFromImage Json gptDetails Json - accuracy Float - cumulativeAccuracy Float + accuracy Json + cumulativeAccuracy Json createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/src/controller/ObservationController.ts b/src/controller/ObservationController.ts index d99de82..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")); @@ -296,4 +277,4 @@ export class ObservationController { filterStatusData(); return res.json(statusData); }); -} \ No newline at end of file +} diff --git a/src/cron/automatedDailyRounds.ts b/src/cron/automatedDailyRounds.ts index fbdefe0..975928f 100644 --- a/src/cron/automatedDailyRounds.ts +++ b/src/cron/automatedDailyRounds.ts @@ -5,7 +5,7 @@ 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"; @@ -23,7 +23,7 @@ import { careApi, openaiApiKey, openaiApiVersion, openaiVisionModel, saveDailyRo import { getPatientId } from "@/utils/dailyRoundUtils"; import { downloadImage } from "@/utils/downloadImageWithDigestRouter"; import { parseVitalsFromImage } from "@/utils/ocr"; -import { caclculateVitalsAccuracy } from "@/utils/vitalsAccuracy"; +import { Accuracy, calculateVitalsAccuracy } from "@/utils/vitalsAccuracy"; const UPDATE_INTERVAL = 60 * 60 * 1000; @@ -92,12 +92,11 @@ 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, @@ -127,7 +126,7 @@ export async function getVitalsFromImage(imageUrl: string) { payload.bp = {}; } - return payload; + return payloadHasData(payload) ? payload : null; } export async function fileAutomatedDailyRound( @@ -231,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), @@ -248,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 { @@ -264,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({ @@ -293,12 +360,14 @@ export async function automatedDailyRounds() { : await getVitalsFromObservations(monitor.ipAddress); console.log( - saveDailyRound - ? "Skipping vitals from observations as saving daily round is enabled" + 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}`, @@ -344,29 +413,53 @@ export async function automatedDailyRounds() { console.log(`Vitals from image: ${JSON.stringify(vitals)}`); } - if (saveVitalsStat) { - const vitalsFromObservation = await getVitalsFromObservations( + if (vitals && saveVitalsStat) { + const vitalsFromObservation = await getVitalsFromObservationsForAccuracy( monitor.ipAddress, + new Date(vitals.taken_at!).toISOString(), ); console.log( - `Vitals from observations: ${JSON.stringify(vitalsFromObservation)}`, + `Vitals from observations for accuracy: ${JSON.stringify(vitalsFromObservation)}`, ); - const accuracy = caclculateVitalsAccuracy(vitals, vitalsFromObservation); + const accuracy = calculateVitalsAccuracy(vitals, vitalsFromObservation); if (accuracy !== null) { - console.log(`Accuracy: ${accuracy}%`); + console.log(`Accuracy: ${accuracy.overall}%`); const lastVitalRecord = await prisma.vitalsStat.findFirst({ orderBy: { createdAt: "desc" }, }); const weight = lastVitalRecord?.id; // number of records - const cumulativeAccuracy = lastVitalRecord - ? (weight! * lastVitalRecord.cumulativeAccuracy + accuracy) / - (weight! + 1) - : accuracy; + const cumulativeAccuracy = ( + lastVitalRecord?.cumulativeAccuracy as Accuracy + ).metrics.map((metric) => { + const latestMetric = accuracy.metrics.find( + (m) => m.field === metric.field, + ); - await prisma.vitalsStat.create({ + 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)), @@ -382,11 +475,17 @@ export async function automatedDailyRounds() { }, }); } - - vitals = vitalsFromObservation ?? vitals; } - if (!vitals || !payloadHasData(vitals)) { + 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/index.ts b/src/index.ts index 4c189d7..6faa718 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { observationsS3Dump } from "./cron/observationsS3Dump"; import { vitalsStatS3Dump } from "./cron/vitalsStatS3Dump"; import * as cron from "node-cron"; @@ -21,6 +22,9 @@ process.env.CHECKPOINT_DISABLE = "1"; 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 } diff --git a/src/utils/configs.ts b/src/utils/configs.ts index b3c99f1..83ad83b 100644 --- a/src/utils/configs.ts +++ b/src/utils/configs.ts @@ -18,7 +18,6 @@ export const sentryEnv = process.env.SENTRY_ENV ?? "unknown"; export const sentryTracesSampleRate = parseFloat( process.env.SENTRY_SAMPLE_RATE ?? "0.01", ); - export const saveDailyRound = (process.env.SAVE_DAILY_ROUND || "true") === "true"; export const saveVitalsStat = 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 97c7ebd..684d96e 100644 --- a/src/utils/ocr.ts +++ b/src/utils/ocr.ts @@ -48,7 +48,7 @@ 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(), diff --git a/src/utils/vitalsAccuracy.ts b/src/utils/vitalsAccuracy.ts index b662bb4..bd614d8 100644 --- a/src/utils/vitalsAccuracy.ts +++ b/src/utils/vitalsAccuracy.ts @@ -2,12 +2,21 @@ 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, @@ -37,22 +46,41 @@ function calculateAccuracy( return key.split(".").reduce((o, k) => (o ? o[k] : undefined), obj); } - let totalScore = 0; + const metrics: AccuracyMetrics[] = []; for (const key of keysToCompare) { const value1 = getValue(obj1, key); const value2 = getValue(obj2, key); - totalScore += compareValues(value1, value2, comparisonType); + 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 (totalScore / keysToCompare.length) * 100; + return metrics; } -export function caclculateVitalsAccuracy( +export function calculateVitalsAccuracy( vitals: DailyRoundObservation | null | undefined, original: DailyRoundObservation | null | undefined, type: ComparisonType = "relative", -) { +): Accuracy | null { if (!vitals || !original) { return null; } @@ -67,5 +95,13 @@ export function caclculateVitalsAccuracy( "bp.diastolic", ]; - return calculateAccuracy(vitals, original, keysToCompare, type); + const metrics = calculateAccuracy(vitals, original, keysToCompare, type); + const overall = + metrics.reduce((acc, curr) => acc + curr.accuracy, 0) / + keysToCompare.length; + + return { + overall: overall * 100, + metrics, + }; }