- 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
462 lines
16 KiB
TypeScript
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' });
|
|
}
|
|
}
|
|
});
|
|
}
|