/** * Vertical Farm Controller for LocalGreenChain * Manages vertical farm operations, automation, and optimization */ import { VerticalFarm, GrowingZone, ZoneEnvironmentTargets, ZoneEnvironmentReadings, EnvironmentAlert, GrowingRecipe, CropBatch, ResourceUsage, FarmAnalytics, LightSchedule, NutrientRecipe, BatchIssue } from './types'; /** * Vertical Farm Controller */ export class VerticalFarmController { private farms: Map = new Map(); private recipes: Map = new Map(); private batches: Map = new Map(); private resourceLogs: Map = new Map(); constructor() { this.initializeDefaultRecipes(); } /** * Initialize default growing recipes */ private initializeDefaultRecipes(): void { const defaultRecipes: GrowingRecipe[] = [ { id: 'recipe-lettuce-butterhead', name: 'Butterhead Lettuce - Fast Cycle', cropType: 'lettuce', variety: 'butterhead', version: '1.0', stages: [ { name: 'Germination', daysStart: 0, daysEnd: 3, temperature: { day: 20, night: 18 }, humidity: { day: 80, night: 85 }, co2Ppm: 800, lightHours: 18, lightPpfd: 150, nutrientRecipeId: 'nutrient-seedling', targetEc: 0.8, targetPh: 6.0, actions: [] }, { name: 'Seedling', daysStart: 4, daysEnd: 10, temperature: { day: 21, night: 18 }, humidity: { day: 70, night: 75 }, co2Ppm: 1000, lightHours: 18, lightPpfd: 200, nutrientRecipeId: 'nutrient-vegetative', targetEc: 1.2, targetPh: 6.0, actions: [ { day: 10, action: 'transplant', description: 'Transplant to final position', automated: true } ] }, { name: 'Vegetative Growth', daysStart: 11, daysEnd: 28, temperature: { day: 22, night: 18 }, humidity: { day: 65, night: 70 }, co2Ppm: 1200, lightHours: 16, lightPpfd: 300, nutrientRecipeId: 'nutrient-vegetative', targetEc: 1.6, targetPh: 6.0, actions: [] }, { name: 'Finishing', daysStart: 29, daysEnd: 35, temperature: { day: 20, night: 16 }, humidity: { day: 60, night: 65 }, co2Ppm: 800, lightHours: 14, lightPpfd: 250, nutrientRecipeId: 'nutrient-finishing', targetEc: 1.2, targetPh: 6.0, actions: [] } ], expectedDays: 35, expectedYieldGrams: 180, expectedYieldPerSqm: 4000, requirements: { positions: 1, zoneType: 'NFT', minimumPpfd: 200, idealTemperatureC: 21 }, source: 'internal', rating: 4.5, timesUsed: 0 }, { id: 'recipe-basil-genovese', name: 'Genovese Basil - Aromatic', cropType: 'basil', variety: 'genovese', version: '1.0', stages: [ { name: 'Germination', daysStart: 0, daysEnd: 5, temperature: { day: 24, night: 22 }, humidity: { day: 80, night: 85 }, co2Ppm: 800, lightHours: 16, lightPpfd: 100, nutrientRecipeId: 'nutrient-seedling', targetEc: 0.6, targetPh: 6.2, actions: [] }, { name: 'Seedling', daysStart: 6, daysEnd: 14, temperature: { day: 25, night: 22 }, humidity: { day: 70, night: 75 }, co2Ppm: 1000, lightHours: 18, lightPpfd: 200, nutrientRecipeId: 'nutrient-vegetative', targetEc: 1.0, targetPh: 6.2, actions: [ { day: 14, action: 'transplant', description: 'Transplant to growing system', automated: true } ] }, { name: 'Vegetative', daysStart: 15, daysEnd: 35, temperature: { day: 26, night: 22 }, humidity: { day: 65, night: 70 }, co2Ppm: 1200, lightHours: 18, lightPpfd: 400, nutrientRecipeId: 'nutrient-herbs', targetEc: 1.4, targetPh: 6.0, actions: [ { day: 25, action: 'top', description: 'Top plants to encourage bushiness', automated: false } ] }, { name: 'Harvest Ready', daysStart: 36, daysEnd: 42, temperature: { day: 24, night: 20 }, humidity: { day: 60, night: 65 }, co2Ppm: 800, lightHours: 16, lightPpfd: 350, nutrientRecipeId: 'nutrient-finishing', targetEc: 1.0, targetPh: 6.0, actions: [] } ], expectedDays: 42, expectedYieldGrams: 120, expectedYieldPerSqm: 2400, requirements: { positions: 1, zoneType: 'NFT', minimumPpfd: 300, idealTemperatureC: 25 }, source: 'internal', rating: 4.3, timesUsed: 0 }, { id: 'recipe-microgreens-mix', name: 'Microgreens Mix - Quick Turn', cropType: 'microgreens', variety: 'mixed', version: '1.0', stages: [ { name: 'Sowing', daysStart: 0, daysEnd: 2, temperature: { day: 22, night: 20 }, humidity: { day: 90, night: 90 }, co2Ppm: 600, lightHours: 0, lightPpfd: 0, nutrientRecipeId: 'nutrient-none', targetEc: 0, targetPh: 6.0, actions: [] }, { name: 'Germination', daysStart: 3, daysEnd: 5, temperature: { day: 22, night: 20 }, humidity: { day: 80, night: 85 }, co2Ppm: 800, lightHours: 12, lightPpfd: 100, nutrientRecipeId: 'nutrient-none', targetEc: 0, targetPh: 6.0, actions: [] }, { name: 'Growth', daysStart: 6, daysEnd: 12, temperature: { day: 21, night: 19 }, humidity: { day: 65, night: 70 }, co2Ppm: 1000, lightHours: 16, lightPpfd: 250, nutrientRecipeId: 'nutrient-microgreens', targetEc: 0.8, targetPh: 6.0, actions: [] }, { name: 'Harvest', daysStart: 13, daysEnd: 14, temperature: { day: 20, night: 18 }, humidity: { day: 60, night: 65 }, co2Ppm: 600, lightHours: 14, lightPpfd: 200, nutrientRecipeId: 'nutrient-none', targetEc: 0, targetPh: 6.0, actions: [] } ], expectedDays: 14, expectedYieldGrams: 200, expectedYieldPerSqm: 2000, requirements: { positions: 1, zoneType: 'rack_system', minimumPpfd: 150, idealTemperatureC: 21 }, source: 'internal', rating: 4.7, timesUsed: 0 } ]; for (const recipe of defaultRecipes) { this.recipes.set(recipe.id, recipe); } } /** * Register a new vertical farm */ registerFarm(farm: VerticalFarm): void { this.farms.set(farm.id, farm); this.resourceLogs.set(farm.id, []); } /** * Get farm by ID */ getFarm(farmId: string): VerticalFarm | undefined { return this.farms.get(farmId); } /** * Start a new crop batch */ startCropBatch( farmId: string, zoneId: string, recipeId: string, seedBatchId: string, plantCount: number ): CropBatch { const farm = this.farms.get(farmId); if (!farm) throw new Error(`Farm ${farmId} not found`); const zone = farm.zones.find(z => z.id === zoneId); if (!zone) throw new Error(`Zone ${zoneId} not found in farm ${farmId}`); const recipe = this.recipes.get(recipeId); if (!recipe) throw new Error(`Recipe ${recipeId} not found`); const now = new Date(); const expectedHarvest = new Date(now.getTime() + recipe.expectedDays * 24 * 60 * 60 * 1000); const batch: CropBatch = { id: `batch-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, farmId, zoneId, cropType: recipe.cropType, variety: recipe.variety, recipeId, seedBatchId, plantIds: [], plantCount, plantingDate: now.toISOString(), currentStage: recipe.stages[0].name, currentDay: 0, healthScore: 100, expectedHarvestDate: expectedHarvest.toISOString(), expectedYieldKg: (recipe.expectedYieldGrams * plantCount) / 1000, status: 'germinating', issues: [], environmentLog: [] }; // Generate plant IDs for (let i = 0; i < plantCount; i++) { batch.plantIds.push(`${batch.id}-plant-${i}`); } // Update zone zone.currentCrop = recipe.cropType; zone.plantIds = batch.plantIds; zone.plantingDate = batch.plantingDate; zone.expectedHarvestDate = batch.expectedHarvestDate; zone.status = 'planted'; // Set environment targets from recipe const firstStage = recipe.stages[0]; zone.environmentTargets = { temperatureC: { min: firstStage.temperature.night - 2, max: firstStage.temperature.day + 2, target: firstStage.temperature.day }, humidityPercent: { min: firstStage.humidity.day - 10, max: firstStage.humidity.night + 5, target: firstStage.humidity.day }, co2Ppm: { min: firstStage.co2Ppm - 200, max: firstStage.co2Ppm + 200, target: firstStage.co2Ppm }, lightPpfd: { min: firstStage.lightPpfd * 0.8, max: firstStage.lightPpfd * 1.2, target: firstStage.lightPpfd }, lightHours: firstStage.lightHours, nutrientEc: { min: firstStage.targetEc - 0.2, max: firstStage.targetEc + 0.2, target: firstStage.targetEc }, nutrientPh: { min: firstStage.targetPh - 0.3, max: firstStage.targetPh + 0.3, target: firstStage.targetPh }, waterTempC: { min: 18, max: 24, target: 20 } }; this.batches.set(batch.id, batch); recipe.timesUsed++; return batch; } /** * Update batch progress */ updateBatchProgress(batchId: string): CropBatch { const batch = this.batches.get(batchId); if (!batch) throw new Error(`Batch ${batchId} not found`); const recipe = this.recipes.get(batch.recipeId); if (!recipe) throw new Error(`Recipe ${batch.recipeId} not found`); const plantingDate = new Date(batch.plantingDate); const now = new Date(); batch.currentDay = Math.floor((now.getTime() - plantingDate.getTime()) / (24 * 60 * 60 * 1000)); // Determine current stage for (const stage of recipe.stages) { if (batch.currentDay >= stage.daysStart && batch.currentDay <= stage.daysEnd) { batch.currentStage = stage.name; // Update zone targets const farm = this.farms.get(batch.farmId); const zone = farm?.zones.find(z => z.id === batch.zoneId); if (zone) { zone.environmentTargets = { temperatureC: { min: stage.temperature.night - 2, max: stage.temperature.day + 2, target: stage.temperature.day }, humidityPercent: { min: stage.humidity.day - 10, max: stage.humidity.night + 5, target: stage.humidity.day }, co2Ppm: { min: stage.co2Ppm - 200, max: stage.co2Ppm + 200, target: stage.co2Ppm }, lightPpfd: { min: stage.lightPpfd * 0.8, max: stage.lightPpfd * 1.2, target: stage.lightPpfd }, lightHours: stage.lightHours, nutrientEc: { min: stage.targetEc - 0.2, max: stage.targetEc + 0.2, target: stage.targetEc }, nutrientPh: { min: stage.targetPh - 0.3, max: stage.targetPh + 0.3, target: stage.targetPh }, waterTempC: { min: 18, max: 24, target: 20 } }; } break; } } // Update status if (batch.currentDay >= recipe.expectedDays) { batch.status = 'ready'; } else if (batch.currentDay > 3) { batch.status = 'growing'; } return batch; } /** * Record environment reading */ recordEnvironment(zoneId: string, readings: ZoneEnvironmentReadings): EnvironmentAlert[] { const alerts: EnvironmentAlert[] = []; // Find the zone let targetZone: GrowingZone | undefined; let farm: VerticalFarm | undefined; for (const f of this.farms.values()) { const zone = f.zones.find(z => z.id === zoneId); if (zone) { targetZone = zone; farm = f; break; } } if (!targetZone || !farm) return alerts; const targets = targetZone.environmentTargets; // Check temperature if (readings.temperatureC < targets.temperatureC.min) { alerts.push({ parameter: 'temperature', type: readings.temperatureC < targets.temperatureC.min - 5 ? 'critical_low' : 'low', value: readings.temperatureC, threshold: targets.temperatureC.min, timestamp: readings.timestamp, acknowledged: false }); } else if (readings.temperatureC > targets.temperatureC.max) { alerts.push({ parameter: 'temperature', type: readings.temperatureC > targets.temperatureC.max + 5 ? 'critical_high' : 'high', value: readings.temperatureC, threshold: targets.temperatureC.max, timestamp: readings.timestamp, acknowledged: false }); } // Check humidity if (readings.humidityPercent < targets.humidityPercent.min) { alerts.push({ parameter: 'humidity', type: 'low', value: readings.humidityPercent, threshold: targets.humidityPercent.min, timestamp: readings.timestamp, acknowledged: false }); } else if (readings.humidityPercent > targets.humidityPercent.max) { alerts.push({ parameter: 'humidity', type: 'high', value: readings.humidityPercent, threshold: targets.humidityPercent.max, timestamp: readings.timestamp, acknowledged: false }); } // Check EC if (readings.ec < targets.nutrientEc.min) { alerts.push({ parameter: 'ec', type: 'low', value: readings.ec, threshold: targets.nutrientEc.min, timestamp: readings.timestamp, acknowledged: false }); } else if (readings.ec > targets.nutrientEc.max) { alerts.push({ parameter: 'ec', type: 'high', value: readings.ec, threshold: targets.nutrientEc.max, timestamp: readings.timestamp, acknowledged: false }); } // Check pH if (readings.ph < targets.nutrientPh.min) { alerts.push({ parameter: 'ph', type: 'low', value: readings.ph, threshold: targets.nutrientPh.min, timestamp: readings.timestamp, acknowledged: false }); } else if (readings.ph > targets.nutrientPh.max) { alerts.push({ parameter: 'ph', type: 'high', value: readings.ph, threshold: targets.nutrientPh.max, timestamp: readings.timestamp, acknowledged: false }); } // Update zone readings readings.alerts = alerts; targetZone.currentEnvironment = readings; // Log to batch if exists const batch = Array.from(this.batches.values()).find(b => b.zoneId === zoneId && b.status !== 'completed' && b.status !== 'failed' ); if (batch) { batch.environmentLog.push({ timestamp: readings.timestamp, readings }); // Adjust health score based on alerts if (alerts.some(a => a.type.includes('critical'))) { batch.healthScore = Math.max(0, batch.healthScore - 5); } else if (alerts.length > 0) { batch.healthScore = Math.max(0, batch.healthScore - 1); } } return alerts; } /** * Complete harvest */ completeHarvest(batchId: string, actualYieldKg: number, qualityGrade: string): CropBatch { const batch = this.batches.get(batchId); if (!batch) throw new Error(`Batch ${batchId} not found`); batch.actualHarvestDate = new Date().toISOString(); batch.actualYieldKg = actualYieldKg; batch.qualityGrade = qualityGrade; batch.status = 'completed'; // Update zone const farm = this.farms.get(batch.farmId); const zone = farm?.zones.find(z => z.id === batch.zoneId); if (zone) { zone.status = 'cleaning'; zone.currentCrop = ''; zone.plantIds = []; } return batch; } /** * Generate farm analytics */ generateAnalytics(farmId: string, periodDays: number = 30): FarmAnalytics { const farm = this.farms.get(farmId); if (!farm) throw new Error(`Farm ${farmId} not found`); const now = new Date(); const periodStart = new Date(now.getTime() - periodDays * 24 * 60 * 60 * 1000); // Get completed batches in period const completedBatches = Array.from(this.batches.values()).filter(b => b.farmId === farmId && b.status === 'completed' && b.actualHarvestDate && new Date(b.actualHarvestDate) >= periodStart ); const totalYieldKg = completedBatches.reduce((sum, b) => sum + (b.actualYieldKg || 0), 0); const totalExpectedYield = completedBatches.reduce((sum, b) => sum + b.expectedYieldKg, 0); // Calculate yield per sqm per year const growingAreaSqm = farm.specs.growingAreaSqm; const yearlyMultiplier = 365 / periodDays; const yieldPerSqmPerYear = growingAreaSqm > 0 ? (totalYieldKg * yearlyMultiplier) / growingAreaSqm : 0; // Quality breakdown const gradeACounts = completedBatches.filter(b => b.qualityGrade === 'A').length; const gradeAPercent = completedBatches.length > 0 ? (gradeACounts / completedBatches.length) * 100 : 0; // Wastage (difference between expected and actual) const wastageKg = Math.max(0, totalExpectedYield - totalYieldKg); const wastagePercent = totalExpectedYield > 0 ? (wastageKg / totalExpectedYield) * 100 : 0; // Success rate const allBatches = Array.from(this.batches.values()).filter(b => b.farmId === farmId && new Date(b.plantingDate) >= periodStart ); const failedBatches = allBatches.filter(b => b.status === 'failed').length; const cropSuccessRate = allBatches.length > 0 ? ((allBatches.length - failedBatches) / allBatches.length) * 100 : 100; // Resource usage const resourceHistory = this.resourceLogs.get(farmId) || []; const periodResources = resourceHistory.filter(r => new Date(r.periodStart) >= periodStart ); const totalElectricity = periodResources.reduce((sum, r) => sum + r.electricityKwh, 0); const totalWater = periodResources.reduce((sum, r) => sum + r.waterUsageL, 0); const totalCost = periodResources.reduce((sum, r) => sum + r.electricityCostUsd + r.waterCostUsd + r.nutrientCostUsd + r.co2CostUsd, 0 ); // Top crops const cropYields = new Map(); const cropRevenue = new Map(); for (const batch of completedBatches) { const currentYield = cropYields.get(batch.cropType) || 0; cropYields.set(batch.cropType, currentYield + (batch.actualYieldKg || 0)); // Estimate revenue (placeholder - would come from actual sales) const estimatedRevenue = (batch.actualYieldKg || 0) * 10; // $10/kg placeholder const currentRevenue = cropRevenue.get(batch.cropType) || 0; cropRevenue.set(batch.cropType, currentRevenue + estimatedRevenue); } const totalRevenue = Array.from(cropRevenue.values()).reduce((a, b) => a + b, 0); return { farmId, generatedAt: now.toISOString(), period: `${periodDays} days`, totalYieldKg: Math.round(totalYieldKg * 10) / 10, yieldPerSqmPerYear: Math.round(yieldPerSqmPerYear * 10) / 10, cropCyclesCompleted: completedBatches.length, averageCyclesDays: completedBatches.length > 0 ? completedBatches.reduce((sum, b) => { const start = new Date(b.plantingDate); const end = new Date(b.actualHarvestDate!); return sum + (end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000); }, 0) / completedBatches.length : 0, averageQualityScore: completedBatches.length > 0 ? completedBatches.reduce((sum, b) => sum + b.healthScore, 0) / completedBatches.length : 0, gradeAPercent: Math.round(gradeAPercent), wastagePercent: Math.round(wastagePercent * 10) / 10, cropSuccessRate: Math.round(cropSuccessRate), spaceUtilization: farm.currentCapacityUtilization, laborHoursPerKg: totalYieldKg > 0 ? 0.5 : 0, // Placeholder revenueUsd: Math.round(totalRevenue), costUsd: Math.round(totalCost), profitMarginPercent: totalRevenue > 0 ? Math.round(((totalRevenue - totalCost) / totalRevenue) * 100) : 0, revenuePerSqm: growingAreaSqm > 0 ? Math.round((totalRevenue / growingAreaSqm) * yearlyMultiplier) : 0, carbonFootprintKgPerKg: totalYieldKg > 0 ? 0.3 : 0, // Estimated - very low for VF waterUseLPerKg: totalYieldKg > 0 ? totalWater / totalYieldKg : 0, energyUseKwhPerKg: totalYieldKg > 0 ? totalElectricity / totalYieldKg : 0, topCropsByYield: Array.from(cropYields.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([crop, yieldKg]) => ({ crop, yieldKg: Math.round(yieldKg * 10) / 10 })), topCropsByRevenue: Array.from(cropRevenue.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([crop, revenueUsd]) => ({ crop, revenueUsd: Math.round(revenueUsd) })), topCropsByEfficiency: Array.from(cropYields.entries()) .map(([crop, yieldKg]) => { const batches = completedBatches.filter(b => b.cropType === crop); const avgHealth = batches.reduce((sum, b) => sum + b.healthScore, 0) / batches.length; return { crop, efficiencyScore: Math.round(avgHealth) }; }) .sort((a, b) => b.efficiencyScore - a.efficiencyScore) .slice(0, 5) }; } /** * Get all recipes */ getRecipes(): GrowingRecipe[] { return Array.from(this.recipes.values()); } /** * Add custom recipe */ addRecipe(recipe: GrowingRecipe): void { this.recipes.set(recipe.id, recipe); } /** * Export state */ toJSON(): object { return { farms: Array.from(this.farms.entries()), recipes: Array.from(this.recipes.entries()), batches: Array.from(this.batches.entries()), resourceLogs: Array.from(this.resourceLogs.entries()) }; } /** * Import state */ static fromJSON(data: any): VerticalFarmController { const controller = new VerticalFarmController(); if (data.farms) { for (const [key, value] of data.farms) { controller.farms.set(key, value); } } if (data.recipes) { for (const [key, value] of data.recipes) { controller.recipes.set(key, value); } } if (data.batches) { for (const [key, value] of data.batches) { controller.batches.set(key, value); } } if (data.resourceLogs) { for (const [key, value] of data.resourceLogs) { controller.resourceLogs.set(key, value); } } return controller; } } // Singleton let controllerInstance: VerticalFarmController | null = null; export function getVerticalFarmController(): VerticalFarmController { if (!controllerInstance) { controllerInstance = new VerticalFarmController(); } return controllerInstance; }