/** * 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 = { '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 = new Map(); private supplyCommitments: Map = new Map(); private demandSignals: Map = new Map(); private marketMatches: Map = 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; totalWeeklyKg: number; priorities: number[]; certifications: Set; 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(); 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(); 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; }