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
578 lines
19 KiB
TypeScript
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;
|
|
}
|