localgreenchain/lib/demand/forecaster.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

578 lines
19 KiB
TypeScript

/**
* Demand Forecaster for LocalGreenChain
* Aggregates consumer preferences and generates planting recommendations
*/
import {
ConsumerPreference,
DemandSignal,
DemandItem,
PlantingRecommendation,
DemandForecast,
ProduceForecast,
SupplyCommitment,
MarketMatch,
SeasonalPlan,
ProduceCategory,
RiskFactor
} from './types';
// Seasonal growing data for common produce
const SEASONAL_DATA: Record<string, {
categories: ProduceCategory[];
growingDays: number;
seasons: ('spring' | 'summer' | 'fall' | 'winter')[];
yieldPerSqm: number; // kg per sq meter
idealTemp: { min: number; max: number };
}> = {
'lettuce': {
categories: ['leafy_greens'],
growingDays: 45,
seasons: ['spring', 'fall'],
yieldPerSqm: 4,
idealTemp: { min: 10, max: 21 }
},
'tomato': {
categories: ['nightshades', 'fruits'],
growingDays: 80,
seasons: ['summer'],
yieldPerSqm: 8,
idealTemp: { min: 18, max: 29 }
},
'spinach': {
categories: ['leafy_greens'],
growingDays: 40,
seasons: ['spring', 'fall', 'winter'],
yieldPerSqm: 3,
idealTemp: { min: 5, max: 18 }
},
'kale': {
categories: ['leafy_greens', 'brassicas'],
growingDays: 55,
seasons: ['spring', 'fall', 'winter'],
yieldPerSqm: 3.5,
idealTemp: { min: 5, max: 24 }
},
'basil': {
categories: ['herbs'],
growingDays: 30,
seasons: ['spring', 'summer'],
yieldPerSqm: 2,
idealTemp: { min: 18, max: 29 }
},
'microgreens': {
categories: ['microgreens'],
growingDays: 14,
seasons: ['spring', 'summer', 'fall', 'winter'],
yieldPerSqm: 1.5,
idealTemp: { min: 18, max: 24 }
},
'cucumber': {
categories: ['squash'],
growingDays: 60,
seasons: ['summer'],
yieldPerSqm: 10,
idealTemp: { min: 18, max: 30 }
},
'pepper': {
categories: ['nightshades'],
growingDays: 75,
seasons: ['summer'],
yieldPerSqm: 6,
idealTemp: { min: 18, max: 29 }
},
'carrot': {
categories: ['root_vegetables'],
growingDays: 70,
seasons: ['spring', 'fall'],
yieldPerSqm: 5,
idealTemp: { min: 7, max: 24 }
},
'strawberry': {
categories: ['berries', 'fruits'],
growingDays: 90,
seasons: ['spring', 'summer'],
yieldPerSqm: 3,
idealTemp: { min: 15, max: 26 }
}
};
export class DemandForecaster {
private preferences: Map<string, ConsumerPreference> = new Map();
private supplyCommitments: Map<string, SupplyCommitment> = new Map();
private demandSignals: Map<string, DemandSignal> = new Map();
private marketMatches: Map<string, MarketMatch> = new Map();
/**
* Register consumer preference
*/
registerPreference(preference: ConsumerPreference): void {
this.preferences.set(preference.consumerId, preference);
}
/**
* Register supply commitment from grower
*/
registerSupply(commitment: SupplyCommitment): void {
this.supplyCommitments.set(commitment.id, commitment);
}
/**
* Generate demand signal for a region
*/
generateDemandSignal(
centerLat: number,
centerLon: number,
radiusKm: number,
regionName: string,
season: 'spring' | 'summer' | 'fall' | 'winter'
): DemandSignal {
// Find consumers in region
const regionalConsumers = Array.from(this.preferences.values()).filter(pref => {
const distance = this.calculateDistance(
centerLat, centerLon,
pref.location.latitude, pref.location.longitude
);
return distance <= radiusKm;
});
// Aggregate demand by produce type
const demandMap = new Map<string, {
consumers: Set<string>;
totalWeeklyKg: number;
priorities: number[];
certifications: Set<string>;
prices: number[];
}>();
for (const consumer of regionalConsumers) {
for (const item of consumer.preferredItems) {
const existing = demandMap.get(item.produceType) || {
consumers: new Set(),
totalWeeklyKg: 0,
priorities: [],
certifications: new Set(),
prices: []
};
existing.consumers.add(consumer.consumerId);
// Calculate weekly demand based on household size
const weeklyKg = (item.weeklyQuantity || 0.5) * consumer.householdSize;
existing.totalWeeklyKg += weeklyKg;
// Track priority
const priorityValue = item.priority === 'must_have' ? 10 :
item.priority === 'preferred' ? 7 :
item.priority === 'nice_to_have' ? 4 : 2;
existing.priorities.push(priorityValue);
// Track certifications
consumer.certificationPreferences.forEach(cert =>
existing.certifications.add(cert)
);
// Track price expectations
if (consumer.weeklyBudget && consumer.preferredItems.length > 0) {
const avgPricePerItem = consumer.weeklyBudget / consumer.preferredItems.length;
existing.prices.push(avgPricePerItem / weeklyKg);
}
demandMap.set(item.produceType, existing);
}
}
// Convert to demand items
const demandItems: DemandItem[] = Array.from(demandMap.entries()).map(([produceType, data]) => {
const seasonalData = SEASONAL_DATA[produceType.toLowerCase()];
const inSeason = seasonalData?.seasons.includes(season) ?? true;
const avgPriority = data.priorities.length > 0
? data.priorities.reduce((a, b) => a + b, 0) / data.priorities.length
: 5;
const avgPrice = data.prices.length > 0
? data.prices.reduce((a, b) => a + b, 0) / data.prices.length
: 5;
return {
produceType,
category: seasonalData?.categories[0] || 'leafy_greens',
weeklyDemandKg: data.totalWeeklyKg,
monthlyDemandKg: data.totalWeeklyKg * 4,
consumerCount: data.consumers.size,
aggregatePriority: Math.round(avgPriority),
urgency: avgPriority >= 8 ? 'immediate' :
avgPriority >= 6 ? 'this_week' :
avgPriority >= 4 ? 'this_month' : 'next_season',
preferredCertifications: Array.from(data.certifications),
averageWillingPrice: Math.round(avgPrice * 100) / 100,
priceUnit: 'per_kg',
inSeason,
seasonalAvailability: {
spring: seasonalData?.seasons.includes('spring') ?? true,
summer: seasonalData?.seasons.includes('summer') ?? true,
fall: seasonalData?.seasons.includes('fall') ?? true,
winter: seasonalData?.seasons.includes('winter') ?? false
},
matchedSupply: 0,
matchedGrowers: 0,
gapKg: data.totalWeeklyKg
};
});
// Calculate supply matching
const regionalSupply = Array.from(this.supplyCommitments.values()).filter(supply =>
supply.status === 'available' || supply.status === 'partially_committed'
);
for (const item of demandItems) {
const matchingSupply = regionalSupply.filter(s =>
s.produceType.toLowerCase() === item.produceType.toLowerCase()
);
item.matchedSupply = matchingSupply.reduce((sum, s) => sum + s.remainingKg, 0);
item.matchedGrowers = matchingSupply.length;
item.gapKg = Math.max(0, item.weeklyDemandKg - item.matchedSupply);
}
const totalWeeklyDemand = demandItems.reduce((sum, item) => sum + item.weeklyDemandKg, 0);
const totalSupply = demandItems.reduce((sum, item) => sum + item.matchedSupply, 0);
const totalGap = demandItems.reduce((sum, item) => sum + item.gapKg, 0);
const signal: DemandSignal = {
id: `demand-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(),
region: {
centerLat,
centerLon,
radiusKm,
name: regionName
},
periodStart: new Date().toISOString(),
periodEnd: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
seasonalPeriod: season,
demandItems: demandItems.sort((a, b) => b.aggregatePriority - a.aggregatePriority),
totalConsumers: regionalConsumers.length,
totalWeeklyDemandKg: totalWeeklyDemand,
confidenceLevel: Math.min(100, regionalConsumers.length * 2),
currentSupplyKg: totalSupply,
supplyGapKg: totalGap,
supplyStatus: totalGap <= 0 ? 'surplus' :
totalGap < totalWeeklyDemand * 0.1 ? 'balanced' :
totalGap < totalWeeklyDemand * 0.3 ? 'shortage' : 'critical'
};
this.demandSignals.set(signal.id, signal);
return signal;
}
/**
* Generate planting recommendations for a grower
*/
generatePlantingRecommendations(
growerId: string,
growerLat: number,
growerLon: number,
deliveryRadiusKm: number,
availableSpaceSqm: number,
season: 'spring' | 'summer' | 'fall' | 'winter'
): PlantingRecommendation[] {
const recommendations: PlantingRecommendation[] = [];
// Find relevant demand signals
const relevantSignals = Array.from(this.demandSignals.values()).filter(signal => {
const distance = this.calculateDistance(
growerLat, growerLon,
signal.region.centerLat, signal.region.centerLon
);
return distance <= deliveryRadiusKm + signal.region.radiusKm &&
signal.seasonalPeriod === season;
});
// Aggregate demand items across signals
const aggregatedDemand = new Map<string, {
totalGapKg: number;
avgPrice: number;
avgPriority: number;
signalIds: string[];
}>();
for (const signal of relevantSignals) {
for (const item of signal.demandItems) {
if (item.gapKg > 0 && item.inSeason) {
const existing = aggregatedDemand.get(item.produceType) || {
totalGapKg: 0,
avgPrice: 0,
avgPriority: 0,
signalIds: []
};
existing.totalGapKg += item.gapKg;
existing.avgPrice = (existing.avgPrice * existing.signalIds.length + item.averageWillingPrice) /
(existing.signalIds.length + 1);
existing.avgPriority = (existing.avgPriority * existing.signalIds.length + item.aggregatePriority) /
(existing.signalIds.length + 1);
existing.signalIds.push(signal.id);
aggregatedDemand.set(item.produceType, existing);
}
}
}
// Sort by opportunity score (gap * price * priority)
const sortedOpportunities = Array.from(aggregatedDemand.entries())
.map(([produceType, data]) => ({
produceType,
...data,
score: data.totalGapKg * data.avgPrice * data.avgPriority / 100
}))
.sort((a, b) => b.score - a.score);
// Allocate space to top opportunities
let remainingSpace = availableSpaceSqm;
for (const opportunity of sortedOpportunities) {
if (remainingSpace <= 0) break;
const seasonalData = SEASONAL_DATA[opportunity.produceType.toLowerCase()];
if (!seasonalData) continue;
// Calculate space needed
const yieldPerSqm = seasonalData.yieldPerSqm;
const neededSpace = Math.min(
remainingSpace,
opportunity.totalGapKg / yieldPerSqm
);
if (neededSpace < 1) continue;
const expectedYield = neededSpace * yieldPerSqm;
const projectedRevenue = expectedYield * opportunity.avgPrice;
// Assess risks
const riskFactors: RiskFactor[] = [];
if (seasonalData.seasons.length === 1) {
riskFactors.push({
type: 'weather',
severity: 'medium',
description: 'Single season crop with weather sensitivity',
mitigationSuggestion: 'Consider greenhouse/vertical farm growing'
});
}
if (opportunity.totalGapKg > expectedYield * 3) {
riskFactors.push({
type: 'market',
severity: 'low',
description: 'Strong demand exceeds your capacity',
mitigationSuggestion: 'Consider partnering with other growers'
});
}
if (opportunity.totalGapKg < expectedYield * 0.5) {
riskFactors.push({
type: 'oversupply',
severity: 'medium',
description: 'Risk of oversupply if demand doesn\'t grow',
mitigationSuggestion: 'Start with smaller quantity and scale up'
});
}
const overallRisk = riskFactors.some(r => r.severity === 'high') ? 'high' :
riskFactors.some(r => r.severity === 'medium') ? 'medium' : 'low';
const plantByDate = new Date();
const harvestStart = new Date(plantByDate.getTime() + seasonalData.growingDays * 24 * 60 * 60 * 1000);
const harvestEnd = new Date(harvestStart.getTime() + 21 * 24 * 60 * 60 * 1000);
recommendations.push({
id: `rec-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(),
growerId,
produceType: opportunity.produceType,
category: seasonalData.categories[0],
recommendedQuantity: Math.round(neededSpace),
quantityUnit: 'sqm',
expectedYieldKg: Math.round(expectedYield * 10) / 10,
yieldConfidence: 75,
plantByDate: plantByDate.toISOString(),
expectedHarvestStart: harvestStart.toISOString(),
expectedHarvestEnd: harvestEnd.toISOString(),
growingDays: seasonalData.growingDays,
projectedDemandKg: opportunity.totalGapKg,
projectedPricePerKg: Math.round(opportunity.avgPrice * 100) / 100,
projectedRevenue: Math.round(projectedRevenue * 100) / 100,
marketConfidence: Math.min(90, 50 + opportunity.signalIds.length * 10),
riskFactors,
overallRisk,
demandSignalIds: opportunity.signalIds,
explanation: `Based on ${opportunity.signalIds.length} demand signal(s) showing a gap of ${Math.round(opportunity.totalGapKg)}kg ` +
`for ${opportunity.produceType}. With ${Math.round(neededSpace)} sqm, you can produce approximately ${Math.round(expectedYield)}kg ` +
`at an expected price of $${opportunity.avgPrice.toFixed(2)}/kg.`
});
remainingSpace -= neededSpace;
}
return recommendations;
}
/**
* Generate demand forecast
*/
generateForecast(
regionName: string,
forecastWeeks: number = 12
): DemandForecast {
const forecasts: ProduceForecast[] = [];
// Get historical demand signals for the region
const historicalSignals = Array.from(this.demandSignals.values())
.filter(s => s.region.name === regionName)
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
// Aggregate by produce type
const produceHistory = new Map<string, number[]>();
for (const signal of historicalSignals) {
for (const item of signal.demandItems) {
const history = produceHistory.get(item.produceType) || [];
history.push(item.weeklyDemandKg);
produceHistory.set(item.produceType, history);
}
}
// Generate forecasts
for (const [produceType, history] of produceHistory) {
if (history.length === 0) continue;
const avgDemand = history.reduce((a, b) => a + b, 0) / history.length;
const trend = history.length > 1
? (history[history.length - 1] - history[0]) / history.length
: 0;
const seasonalData = SEASONAL_DATA[produceType.toLowerCase()];
const currentSeason = this.getCurrentSeason();
const seasonalFactor = seasonalData?.seasons.includes(currentSeason) ? 1.2 : 0.6;
const predictedDemand = (avgDemand + trend * forecastWeeks) * seasonalFactor;
forecasts.push({
produceType,
category: seasonalData?.categories[0] || 'leafy_greens',
predictedDemandKg: Math.round(predictedDemand * 10) / 10,
confidenceInterval: {
low: Math.round(predictedDemand * 0.7 * 10) / 10,
high: Math.round(predictedDemand * 1.3 * 10) / 10
},
confidence: Math.min(95, 50 + history.length * 5),
trend: trend > 0.1 ? 'increasing' : trend < -0.1 ? 'decreasing' : 'stable',
trendStrength: Math.min(100, Math.abs(trend) * 100),
seasonalFactor,
predictedPricePerKg: 5, // Default price
priceConfidenceInterval: { low: 3, high: 8 },
factors: [
{
name: 'Seasonal adjustment',
type: 'seasonal',
impact: Math.round((seasonalFactor - 1) * 100),
description: seasonalData?.seasons.includes(currentSeason)
? 'In season - higher demand expected'
: 'Out of season - lower demand expected'
},
{
name: 'Historical trend',
type: 'trend',
impact: Math.round(trend * 10),
description: trend > 0 ? 'Growing popularity' : trend < 0 ? 'Declining interest' : 'Stable demand'
}
]
});
}
return {
id: `forecast-${Date.now()}`,
generatedAt: new Date().toISOString(),
region: regionName,
forecastPeriod: {
start: new Date().toISOString(),
end: new Date(Date.now() + forecastWeeks * 7 * 24 * 60 * 60 * 1000).toISOString()
},
forecasts: forecasts.sort((a, b) => b.predictedDemandKg - a.predictedDemandKg),
modelVersion: '1.0.0',
dataPointsUsed: historicalSignals.length,
lastTrainingDate: new Date().toISOString()
};
}
private getCurrentSeason(): 'spring' | 'summer' | 'fall' | 'winter' {
const month = new Date().getMonth();
if (month >= 2 && month <= 4) return 'spring';
if (month >= 5 && month <= 7) return 'summer';
if (month >= 8 && month <= 10) return 'fall';
return 'winter';
}
private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Export state
*/
toJSON(): object {
return {
preferences: Array.from(this.preferences.entries()),
supplyCommitments: Array.from(this.supplyCommitments.entries()),
demandSignals: Array.from(this.demandSignals.entries()),
marketMatches: Array.from(this.marketMatches.entries())
};
}
/**
* Import state
*/
static fromJSON(data: any): DemandForecaster {
const forecaster = new DemandForecaster();
if (data.preferences) {
for (const [key, value] of data.preferences) {
forecaster.preferences.set(key, value);
}
}
if (data.supplyCommitments) {
for (const [key, value] of data.supplyCommitments) {
forecaster.supplyCommitments.set(key, value);
}
}
if (data.demandSignals) {
for (const [key, value] of data.demandSignals) {
forecaster.demandSignals.set(key, value);
}
}
if (data.marketMatches) {
for (const [key, value] of data.marketMatches) {
forecaster.marketMatches.set(key, value);
}
}
return forecaster;
}
}
// Singleton instance
let forecasterInstance: DemandForecaster | null = null;
export function getDemandForecaster(): DemandForecaster {
if (!forecasterInstance) {
forecasterInstance = new DemandForecaster();
}
return forecasterInstance;
}