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 = { '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 = {}; // 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' }); } } }); }