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
759 lines
24 KiB
TypeScript
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;
|
|
}
|