Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Smart ICU POC #130

Merged
merged 3 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -86,4 +86,4 @@
"engines": {
"node": ">=20.0.0"
}
}
}
14 changes: 14 additions & 0 deletions prisma/migrations/20240530063454_add_stats_vital/migration.sql
Original file line number Diff line number Diff line change
@@ -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")
);
12 changes: 12 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 Float
cumulativeAccuracy Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
21 changes: 21 additions & 0 deletions src/controller/VitalsStatController.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
}
71 changes: 62 additions & 9 deletions src/cron/automatedDailyRounds.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { AxiosError, AxiosResponse } from "axios";
import { randomUUID } from "crypto";
import fs from "fs";
import path from "path";

Expand All @@ -9,15 +10,20 @@ 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 { caclculateVitalsAccuracy } from "@/utils/vitalsAccuracy";


const UPDATE_INTERVAL = 60 * 60 * 1000;
Expand Down Expand Up @@ -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,
Expand All @@ -92,7 +98,7 @@ export async function getVitalsFromImage(imageUrl: string) {
// ? date.toISOString()
// : new Date().toISOString();
const isoDate = new Date().toISOString();

const payload = {
taken_at: isoDate,
spo2: data.spO2?.oxygen_saturation_percentage ?? null,
Expand Down Expand Up @@ -281,11 +287,16 @@ 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(
saveDailyRound
? "Skipping vitals from observations as saving daily round is enabled"
: `Vitals from observations: ${JSON.stringify(vitals)}`,
);

if (!vitals && openaiApiKey) {
if (!asset_beds || asset_beds.length === 0) {
Expand Down Expand Up @@ -325,14 +336,56 @@ 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);

vitals = await getVitalsFromImage(imageUrl);
console.log(`Vitals from image: ${JSON.stringify(vitals)}`);
}

if (saveVitalsStat) {
const vitalsFromObservation = await getVitalsFromObservations(
monitor.ipAddress,
);
console.log(
`Vitals from observations: ${JSON.stringify(vitalsFromObservation)}`,
);

const accuracy = caclculateVitalsAccuracy(vitals, vitalsFromObservation);

if (accuracy !== null) {
console.log(`Accuracy: ${accuracy}%`);

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;

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,
},
});
}

vitals = vitalsFromObservation ?? vitals;
}

if (!vitals || !payloadHasData(vitals)) {
console.error(`No vitals found for the patient ${patient_id}`);
return;
Expand Down
56 changes: 56 additions & 0 deletions src/cron/vitalsStatS3Dump.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
}
11 changes: 8 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
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";
Expand All @@ -16,9 +17,13 @@ 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

if (s3DumpVitalsStat) {
cron.schedule("0 0 * * *", vitalsStatS3Dump); // every day at midnight
}
}, 100);

server.listen(port, () =>
Expand Down
9 changes: 9 additions & 0 deletions src/router/vitalsStatRouter.ts
Original file line number Diff line number Diff line change
@@ -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 };
6 changes: 5 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ 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";
Expand All @@ -27,6 +29,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 {
Expand Down Expand Up @@ -110,6 +113,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(
Expand All @@ -135,4 +139,4 @@ export function initServer() {
ServerStatusController.init(ws);

return app;
}
}
15 changes: 14 additions & 1 deletion src/utils/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const sentryTracesSampleRate = parseFloat(
);

export const saveDailyRound = Boolean(process.env.SAVE_DAILY_ROUND ?? "true");
export const saveVitalsStat = Boolean(process.env.SAVE_VITALS_STAT ?? "true");

export const s3Provider = process.env.S3_PROVIDER ?? "AWS";
export const s3Endpoint =
Expand All @@ -33,4 +34,16 @@ 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 ?? "";
export const s3DumpVitalsStat = Boolean(
process.env.S3_DUMP_VITALS_STAT ?? "false",
);
export const deleteVitalsStatOnDump = Boolean(
process.env.DELETE_VITALS_STAT_ON_DUMP ?? "false",
);

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");
Loading
Loading