localgreenchain/lib/vertical-farming/controller.ts
Claude ac93368e9a
Add seed-to-seed transport tracking, demand forecasting, and vertical farming systems
This comprehensive update implements:

Transport Tracking System:
- Complete seed-to-seed lifecycle tracking with 9 event types
- TransportChain blockchain for immutable transport records
- Carbon footprint calculation per transport method
- Food miles tracking with Haversine distance calculation
- QR code generation for full traceability

Demand Forecasting System:
- Consumer preference registration and aggregation
- Regional demand signal generation
- Supply gap identification and market matching
- Grower planting recommendations with risk assessment
- Seasonal planning integration

Vertical Farming Module:
- Multi-zone facility management
- Environmental control systems (HVAC, CO2, humidity, lighting)
- Growing recipes with stage-based environment targets
- Crop batch tracking with health scoring
- Farm analytics generation

Documentation:
- Complete docs/ folder structure for Turborepo
- Seed-to-seed transport concept documentation
- Demand forecasting and seasonal planning guides
- System architecture and user blockchain design
- Transport API reference
- Vertical farming integration guide

Agent Report:
- AGENT_REPORT.md with 5 parallel agent tasks for continued development
- API routes implementation task
- UI components task
- Vertical farming pages task
- Testing suite task
- Documentation completion task
2025-11-22 18:23:08 +00:00

759 lines
24 KiB
TypeScript

/**
* 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<string, VerticalFarm> = new Map();
private recipes: Map<string, GrowingRecipe> = new Map();
private batches: Map<string, CropBatch> = new Map();
private resourceLogs: Map<string, ResourceUsage[]> = 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<string, number>();
const cropRevenue = new Map<string, number>();
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;
}