ca-grow-ops-manager/backend/src/routes/insights.routes.ts
fullsizemalt deadb04803
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s
fix(backend): Add Batch-FacilityPlant relation and fix Prisma queries
- Added bi-directional relation between Batch and FacilityPlant in schema.prisma
- Fixed logic in insights.routes.ts to use simplified plant count query
- Fixed duplicate property in messaging.routes.ts findMany filter
- Fixed null/undefined type mismatch in audit.routes.ts extractClientInfo
2025-12-11 11:43:54 -08:00

462 lines
16 KiB
TypeScript

import { FastifyInstance } from 'fastify';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/**
* AI Insights Service
* Note: This provides the framework for ML predictions.
* In production, integrate with actual ML models (TensorFlow, PyTorch, etc.)
*/
// Simulated prediction based on historical data patterns
function predictYield(params: {
strain: string;
plantCount: number;
stage: string;
daysInStage: number;
avgTemp?: number;
avgHumidity?: number;
co2Level?: number;
}): { prediction: number; confidence: number; factors: any } {
// Base yield per plant (grams) - varies by strain
const strainYields: Record<string, number> = {
'default': 85,
'indica': 90,
'sativa': 80,
'hybrid': 85,
};
const baseYield = strainYields[params.strain.toLowerCase()] || strainYields.default;
let multiplier = 1.0;
let confidence = 0.7; // Base confidence
const factors: Record<string, { impact: number; value: any }> = {};
// Temperature impact
if (params.avgTemp) {
if (params.avgTemp >= 70 && params.avgTemp <= 80) {
multiplier *= 1.05;
factors.temperature = { impact: 0.05, value: params.avgTemp };
} else if (params.avgTemp < 65 || params.avgTemp > 85) {
multiplier *= 0.85;
factors.temperature = { impact: -0.15, value: params.avgTemp };
}
confidence += 0.05;
}
// Humidity impact
if (params.avgHumidity) {
if (params.avgHumidity >= 40 && params.avgHumidity <= 60) {
multiplier *= 1.03;
factors.humidity = { impact: 0.03, value: params.avgHumidity };
} else if (params.avgHumidity > 70) {
multiplier *= 0.90; // Higher risk of mold
factors.humidity = { impact: -0.10, value: params.avgHumidity };
}
confidence += 0.05;
}
// CO2 impact
if (params.co2Level) {
if (params.co2Level >= 1000 && params.co2Level <= 1500) {
multiplier *= 1.15;
factors.co2 = { impact: 0.15, value: params.co2Level };
}
confidence += 0.05;
}
// Stage factor
if (params.stage === 'FLOWER' && params.daysInStage > 21) {
confidence += 0.1; // More accurate later in flower
}
const prediction = Math.round(baseYield * params.plantCount * multiplier);
return {
prediction,
confidence: Math.min(confidence, 0.95),
factors
};
}
// Anomaly detection based on statistical analysis
function detectAnomalies(data: {
type: string;
values: number[];
timestamps: Date[];
thresholds?: { min: number; max: number };
}): { anomalies: any[]; stats: any } {
const { values, timestamps, thresholds } = data;
if (values.length < 5) {
return { anomalies: [], stats: { insufficient: true } };
}
// Calculate statistics
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
const stdDev = Math.sqrt(variance);
const anomalies: any[] = [];
values.forEach((value, index) => {
const zScore = (value - mean) / stdDev;
// Flag if > 2 standard deviations or outside thresholds
if (Math.abs(zScore) > 2 ||
(thresholds && (value < thresholds.min || value > thresholds.max))) {
anomalies.push({
value,
timestamp: timestamps[index],
zScore,
deviation: value - mean,
severity: Math.abs(zScore) > 3 ? 'CRITICAL' : 'WARNING'
});
}
});
return {
anomalies,
stats: {
mean,
stdDev,
min: Math.min(...values),
max: Math.max(...values),
count: values.length
}
};
}
export async function insightsRoutes(fastify: FastifyInstance) {
// Auth middleware
fastify.addHook('onRequest', async (request) => {
try {
await request.jwtVerify();
} catch (err) {
throw err;
}
});
// ==================== YIELD PREDICTIONS ====================
/**
* POST /predict/yield
* Generate yield prediction for a batch
*/
fastify.post('/predict/yield', {
handler: async (request, reply) => {
try {
const { batchId } = request.body as any;
// Get batch info
const batch = await prisma.batch.findUnique({
where: { id: batchId }
});
if (!batch) {
return reply.status(404).send({ error: 'Batch not found' });
}
// Get actual plant count from facility plants
const actualPlantCount = await prisma.facilityPlant.count({
where: { batchId }
});
const effectivePlantCount = actualPlantCount > 0 ? actualPlantCount : (batch.plantCount || 0);
// Get environment data if available
let envData: any = {};
const roomSensors = await prisma.sensor.findMany({
where: { roomId: batch.roomId || undefined, isActive: true },
include: {
readings: {
orderBy: { timestamp: 'desc' },
take: 24 // Last 24 readings
}
}
});
roomSensors.forEach(sensor => {
if (sensor.readings.length > 0) {
const avg = sensor.readings.reduce((sum, r) => sum + r.value, 0) / sensor.readings.length;
if (sensor.type === 'TEMPERATURE') envData.avgTemp = avg;
if (sensor.type === 'HUMIDITY') envData.avgHumidity = avg;
if (sensor.type === 'CO2') envData.co2Level = avg;
}
});
// Days in current stage
const daysInStage = Math.floor(
(Date.now() - new Date(batch.updatedAt).getTime()) / (1000 * 60 * 60 * 24)
);
const result = predictYield({
strain: batch.strain || 'default',
plantCount: effectivePlantCount,
stage: batch.stage || 'VEG',
daysInStage,
...envData
});
// Store prediction
const prediction = await prisma.yieldPrediction.create({
data: {
batchId,
predictedYield: result.prediction,
confidence: result.confidence,
factors: result.factors
}
});
return {
prediction,
batchInfo: {
name: batch.name,
strain: batch.strain,
stage: batch.stage,
plantCount: effectivePlantCount
},
environment: envData
};
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to generate prediction' });
}
}
});
/**
* GET /predictions/:batchId
* Get prediction history for a batch
*/
fastify.get('/predictions/:batchId', {
handler: async (request, reply) => {
try {
const { batchId } = request.params as any;
const predictions = await prisma.yieldPrediction.findMany({
where: { batchId },
orderBy: { predictedAt: 'desc' }
});
return predictions;
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to fetch predictions' });
}
}
});
/**
* POST /predictions/:id/actual
* Record actual yield to improve model
*/
fastify.post('/predictions/:id/actual', {
handler: async (request, reply) => {
try {
const { id } = request.params as any;
const { actualYield } = request.body as any;
const prediction = await prisma.yieldPrediction.findUnique({ where: { id } });
if (!prediction) {
return reply.status(404).send({ error: 'Prediction not found' });
}
const accuracy = 1 - Math.abs(prediction.predictedYield - actualYield) / actualYield;
const updated = await prisma.yieldPrediction.update({
where: { id },
data: {
actualYield,
accuracy: Math.max(0, Math.min(1, accuracy))
}
});
return updated;
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to update prediction' });
}
}
});
// ==================== ANOMALY DETECTION ====================
/**
* POST /detect/anomalies
* Detect anomalies in sensor data
*/
fastify.post('/detect/anomalies', {
handler: async (request, reply) => {
try {
const { sensorId, hours = 24 } = request.body as any;
const sensor = await prisma.sensor.findUnique({
where: { id: sensorId }
});
if (!sensor) {
return reply.status(404).send({ error: 'Sensor not found' });
}
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
const readings = await prisma.sensorReading.findMany({
where: {
sensorId,
timestamp: { gte: since }
},
orderBy: { timestamp: 'asc' }
});
const result = detectAnomalies({
type: sensor.type,
values: readings.map(r => r.value),
timestamps: readings.map(r => r.timestamp),
thresholds: sensor.minThreshold && sensor.maxThreshold
? { min: sensor.minThreshold, max: sensor.maxThreshold }
: undefined
});
// Store detected anomalies
for (const anomaly of result.anomalies) {
await prisma.anomalyDetection.create({
data: {
entityType: 'Sensor',
entityId: sensorId,
anomalyType: 'ENV_UNSTABLE',
severity: anomaly.severity,
description: `${sensor.name} reading of ${anomaly.value} detected (z-score: ${anomaly.zScore.toFixed(2)})`,
data: anomaly
}
});
}
return {
sensor: { id: sensor.id, name: sensor.name, type: sensor.type },
period: { hours, since: since.toISOString() },
...result
};
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to detect anomalies' });
}
}
});
/**
* GET /anomalies
* Get detected anomalies
*/
fastify.get('/anomalies', {
handler: async (request, reply) => {
try {
const { entityType, entityId, resolved, limit = 50 } = request.query as any;
const where: any = {};
if (entityType) where.entityType = entityType;
if (entityId) where.entityId = entityId;
if (resolved !== undefined) where.isResolved = resolved === 'true';
const anomalies = await prisma.anomalyDetection.findMany({
where,
orderBy: { detectedAt: 'desc' },
take: parseInt(limit)
});
return anomalies;
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to fetch anomalies' });
}
}
});
/**
* POST /anomalies/:id/resolve
* Mark anomaly as resolved
*/
fastify.post('/anomalies/:id/resolve', {
handler: async (request, reply) => {
try {
const { id } = request.params as any;
const anomaly = await prisma.anomalyDetection.update({
where: { id },
data: { isResolved: true }
});
return anomaly;
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to resolve anomaly' });
}
}
});
// ==================== INSIGHTS DASHBOARD ====================
/**
* GET /dashboard
* Get AI insights overview
*/
fastify.get('/dashboard', {
handler: async (request, reply) => {
try {
// Get recent predictions accuracy
const predictions = await prisma.yieldPrediction.findMany({
where: { accuracy: { not: null } },
orderBy: { predictedAt: 'desc' },
take: 20
});
const avgAccuracy = predictions.length > 0
? predictions.reduce((sum, p) => sum + (p.accuracy || 0), 0) / predictions.length
: null;
// Get unresolved anomalies
const anomalies = await prisma.anomalyDetection.findMany({
where: { isResolved: false },
orderBy: { detectedAt: 'desc' },
take: 10
});
// Get batch performance insights
const batchCosts = await prisma.batchCost.findMany({
where: { costPerGram: { not: null } },
orderBy: { costPerGram: 'asc' },
take: 5
});
return {
predictions: {
count: predictions.length,
avgAccuracy: avgAccuracy ? (avgAccuracy * 100).toFixed(1) + '%' : 'N/A',
recentAccuracy: predictions.slice(0, 5).map(p => ({
batchId: p.batchId,
accuracy: p.accuracy ? (p.accuracy * 100).toFixed(1) + '%' : 'N/A',
predictedAt: p.predictedAt
}))
},
anomalies: {
unresolved: anomalies.length,
recent: anomalies
},
performance: {
topBatches: batchCosts.map(c => ({
batchId: c.batchId,
costPerGram: c.costPerGram?.toFixed(2),
totalCost: c.totalCost,
yieldGrams: c.yieldGrams
}))
}
};
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to fetch dashboard' });
}
}
});
}