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
516 lines
15 KiB
TypeScript
516 lines
15 KiB
TypeScript
/**
|
|
* Transport Tracker for LocalGreenChain
|
|
* Manages seed-to-seed transport events and blockchain recording
|
|
*/
|
|
|
|
import crypto from 'crypto';
|
|
import {
|
|
TransportEvent,
|
|
TransportBlock,
|
|
TransportLocation,
|
|
TransportMethod,
|
|
PlantJourney,
|
|
EnvironmentalImpact,
|
|
TransportQRData,
|
|
CARBON_FACTORS,
|
|
SeedAcquisitionEvent,
|
|
PlantingEvent,
|
|
GrowingTransportEvent,
|
|
HarvestEvent,
|
|
ProcessingEvent,
|
|
DistributionEvent,
|
|
ConsumerDeliveryEvent,
|
|
SeedSavingEvent,
|
|
SeedSharingEvent,
|
|
TransportEventType
|
|
} from './types';
|
|
|
|
/**
|
|
* TransportChain - Blockchain for transport events
|
|
*/
|
|
export class TransportChain {
|
|
public chain: TransportBlock[];
|
|
public difficulty: number;
|
|
private eventIndex: Map<string, TransportBlock[]>;
|
|
private plantEvents: Map<string, TransportEvent[]>;
|
|
private batchEvents: Map<string, TransportEvent[]>;
|
|
|
|
constructor(difficulty: number = 3) {
|
|
this.chain = [this.createGenesisBlock()];
|
|
this.difficulty = difficulty;
|
|
this.eventIndex = new Map();
|
|
this.plantEvents = new Map();
|
|
this.batchEvents = new Map();
|
|
}
|
|
|
|
private createGenesisBlock(): TransportBlock {
|
|
const genesisEvent: SeedAcquisitionEvent = {
|
|
id: 'genesis-transport-0',
|
|
timestamp: new Date().toISOString(),
|
|
eventType: 'seed_acquisition',
|
|
fromLocation: {
|
|
latitude: 0,
|
|
longitude: 0,
|
|
locationType: 'seed_bank',
|
|
facilityName: 'LocalGreenChain Genesis'
|
|
},
|
|
toLocation: {
|
|
latitude: 0,
|
|
longitude: 0,
|
|
locationType: 'seed_bank',
|
|
facilityName: 'LocalGreenChain Genesis'
|
|
},
|
|
distanceKm: 0,
|
|
durationMinutes: 0,
|
|
transportMethod: 'walking',
|
|
carbonFootprintKg: 0,
|
|
senderId: 'system',
|
|
receiverId: 'system',
|
|
status: 'verified',
|
|
seedBatchId: 'genesis-seed-batch',
|
|
sourceType: 'seed_bank',
|
|
species: 'Blockchain primordialis',
|
|
quantity: 1,
|
|
quantityUnit: 'seeds',
|
|
generation: 0
|
|
};
|
|
|
|
return {
|
|
index: 0,
|
|
timestamp: new Date().toISOString(),
|
|
transportEvent: genesisEvent,
|
|
previousHash: '0',
|
|
hash: this.calculateHash(0, new Date().toISOString(), genesisEvent, '0', 0),
|
|
nonce: 0,
|
|
cumulativeCarbonKg: 0,
|
|
cumulativeFoodMiles: 0,
|
|
chainLength: 1
|
|
};
|
|
}
|
|
|
|
private calculateHash(
|
|
index: number,
|
|
timestamp: string,
|
|
event: TransportEvent,
|
|
previousHash: string,
|
|
nonce: number
|
|
): string {
|
|
const data = `${index}${timestamp}${JSON.stringify(event)}${previousHash}${nonce}`;
|
|
return crypto.createHash('sha256').update(data).digest('hex');
|
|
}
|
|
|
|
private mineBlock(block: TransportBlock): void {
|
|
const target = '0'.repeat(this.difficulty);
|
|
while (block.hash.substring(0, this.difficulty) !== target) {
|
|
block.nonce++;
|
|
block.hash = this.calculateHash(
|
|
block.index,
|
|
block.timestamp,
|
|
block.transportEvent,
|
|
block.previousHash,
|
|
block.nonce
|
|
);
|
|
}
|
|
}
|
|
|
|
getLatestBlock(): TransportBlock {
|
|
return this.chain[this.chain.length - 1];
|
|
}
|
|
|
|
/**
|
|
* Record a new transport event
|
|
*/
|
|
recordEvent(event: TransportEvent): TransportBlock {
|
|
const latestBlock = this.getLatestBlock();
|
|
|
|
// Calculate carbon footprint if not provided
|
|
if (!event.carbonFootprintKg) {
|
|
event.carbonFootprintKg = this.calculateCarbon(
|
|
event.transportMethod,
|
|
event.distanceKm,
|
|
this.estimateWeight(event)
|
|
);
|
|
}
|
|
|
|
const newBlock: TransportBlock = {
|
|
index: this.chain.length,
|
|
timestamp: new Date().toISOString(),
|
|
transportEvent: event,
|
|
previousHash: latestBlock.hash,
|
|
hash: '',
|
|
nonce: 0,
|
|
cumulativeCarbonKg: latestBlock.cumulativeCarbonKg + event.carbonFootprintKg,
|
|
cumulativeFoodMiles: latestBlock.cumulativeFoodMiles + event.distanceKm,
|
|
chainLength: this.chain.length + 1
|
|
};
|
|
|
|
newBlock.hash = this.calculateHash(
|
|
newBlock.index,
|
|
newBlock.timestamp,
|
|
event,
|
|
newBlock.previousHash,
|
|
newBlock.nonce
|
|
);
|
|
|
|
this.mineBlock(newBlock);
|
|
this.chain.push(newBlock);
|
|
this.indexEvent(event, newBlock);
|
|
|
|
return newBlock;
|
|
}
|
|
|
|
private indexEvent(event: TransportEvent, block: TransportBlock): void {
|
|
// Index by event ID
|
|
const eventBlocks = this.eventIndex.get(event.id) || [];
|
|
eventBlocks.push(block);
|
|
this.eventIndex.set(event.id, eventBlocks);
|
|
|
|
// Index by plant IDs
|
|
const plantIds = this.extractPlantIds(event);
|
|
for (const plantId of plantIds) {
|
|
const events = this.plantEvents.get(plantId) || [];
|
|
events.push(event);
|
|
this.plantEvents.set(plantId, events);
|
|
}
|
|
|
|
// Index by batch IDs
|
|
const batchIds = this.extractBatchIds(event);
|
|
for (const batchId of batchIds) {
|
|
const events = this.batchEvents.get(batchId) || [];
|
|
events.push(event);
|
|
this.batchEvents.set(batchId, events);
|
|
}
|
|
}
|
|
|
|
private extractPlantIds(event: TransportEvent): string[] {
|
|
switch (event.eventType) {
|
|
case 'planting':
|
|
return (event as PlantingEvent).plantIds;
|
|
case 'growing_transport':
|
|
return (event as GrowingTransportEvent).plantIds;
|
|
case 'harvest':
|
|
return (event as HarvestEvent).plantIds;
|
|
case 'seed_saving':
|
|
return (event as SeedSavingEvent).parentPlantIds;
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private extractBatchIds(event: TransportEvent): string[] {
|
|
const batchIds: string[] = [];
|
|
|
|
switch (event.eventType) {
|
|
case 'seed_acquisition':
|
|
batchIds.push((event as SeedAcquisitionEvent).seedBatchId);
|
|
break;
|
|
case 'planting':
|
|
batchIds.push((event as PlantingEvent).seedBatchId);
|
|
break;
|
|
case 'harvest':
|
|
batchIds.push((event as HarvestEvent).harvestBatchId);
|
|
if ((event as HarvestEvent).seedBatchIdCreated) {
|
|
batchIds.push((event as HarvestEvent).seedBatchIdCreated!);
|
|
}
|
|
break;
|
|
case 'processing':
|
|
batchIds.push(...(event as ProcessingEvent).harvestBatchIds);
|
|
batchIds.push((event as ProcessingEvent).processingBatchId);
|
|
break;
|
|
case 'distribution':
|
|
batchIds.push(...(event as DistributionEvent).batchIds);
|
|
break;
|
|
case 'consumer_delivery':
|
|
batchIds.push(...(event as ConsumerDeliveryEvent).batchIds);
|
|
break;
|
|
case 'seed_saving':
|
|
batchIds.push((event as SeedSavingEvent).newSeedBatchId);
|
|
break;
|
|
case 'seed_sharing':
|
|
batchIds.push((event as SeedSharingEvent).seedBatchId);
|
|
break;
|
|
}
|
|
|
|
return batchIds;
|
|
}
|
|
|
|
private estimateWeight(event: TransportEvent): number {
|
|
// Estimate weight in kg based on event type
|
|
switch (event.eventType) {
|
|
case 'seed_acquisition':
|
|
case 'seed_saving':
|
|
case 'seed_sharing':
|
|
return 0.1; // Seeds are light
|
|
case 'planting':
|
|
return 0.5;
|
|
case 'growing_transport':
|
|
return 2;
|
|
case 'harvest':
|
|
return (event as HarvestEvent).netWeight || 5;
|
|
case 'processing':
|
|
return (event as ProcessingEvent).outputWeight || 5;
|
|
case 'distribution':
|
|
case 'consumer_delivery':
|
|
return 5;
|
|
default:
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate carbon footprint
|
|
*/
|
|
calculateCarbon(method: TransportMethod, distanceKm: number, weightKg: number): number {
|
|
const factor = CARBON_FACTORS[method] || 0.1;
|
|
return factor * distanceKm * weightKg;
|
|
}
|
|
|
|
/**
|
|
* Calculate distance between two locations using Haversine formula
|
|
*/
|
|
static calculateDistance(from: TransportLocation, to: TransportLocation): number {
|
|
const R = 6371; // Earth's radius in km
|
|
const dLat = TransportChain.toRadians(to.latitude - from.latitude);
|
|
const dLon = TransportChain.toRadians(to.longitude - from.longitude);
|
|
|
|
const a =
|
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
Math.cos(TransportChain.toRadians(from.latitude)) *
|
|
Math.cos(TransportChain.toRadians(to.latitude)) *
|
|
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
return R * c;
|
|
}
|
|
|
|
private static toRadians(degrees: number): number {
|
|
return degrees * (Math.PI / 180);
|
|
}
|
|
|
|
/**
|
|
* Get complete journey for a plant
|
|
*/
|
|
getPlantJourney(plantId: string): PlantJourney | null {
|
|
const events = this.plantEvents.get(plantId);
|
|
if (!events || events.length === 0) return null;
|
|
|
|
// Sort events by timestamp
|
|
const sortedEvents = [...events].sort(
|
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
);
|
|
|
|
const lastEvent = sortedEvents[sortedEvents.length - 1];
|
|
|
|
// Calculate metrics
|
|
let totalFoodMiles = 0;
|
|
let totalCarbonKg = 0;
|
|
let daysInTransit = 0;
|
|
|
|
for (const event of sortedEvents) {
|
|
totalFoodMiles += event.distanceKm;
|
|
totalCarbonKg += event.carbonFootprintKg;
|
|
daysInTransit += event.durationMinutes / (60 * 24);
|
|
}
|
|
|
|
// Find seed batch origin
|
|
const seedAcquisition = sortedEvents.find(e => e.eventType === 'seed_acquisition') as SeedAcquisitionEvent | undefined;
|
|
const planting = sortedEvents.find(e => e.eventType === 'planting') as PlantingEvent | undefined;
|
|
|
|
// Calculate growing days
|
|
const plantingDate = planting ? new Date(planting.timestamp) : null;
|
|
const lastDate = new Date(lastEvent.timestamp);
|
|
const daysGrowing = plantingDate
|
|
? Math.floor((lastDate.getTime() - plantingDate.getTime()) / (1000 * 60 * 60 * 24))
|
|
: 0;
|
|
|
|
// Determine current stage
|
|
let currentStage: PlantJourney['currentStage'] = 'seed';
|
|
if (sortedEvents.some(e => e.eventType === 'seed_saving')) {
|
|
currentStage = 'seed_saving';
|
|
} else if (sortedEvents.some(e => e.eventType === 'harvest')) {
|
|
currentStage = 'post_harvest';
|
|
} else if (sortedEvents.some(e => e.eventType === 'growing_transport')) {
|
|
const lastGrowing = sortedEvents.filter(e => e.eventType === 'growing_transport').pop() as GrowingTransportEvent;
|
|
currentStage = lastGrowing.plantStage;
|
|
} else if (sortedEvents.some(e => e.eventType === 'planting')) {
|
|
currentStage = 'seedling';
|
|
}
|
|
|
|
return {
|
|
plantId,
|
|
seedBatchOrigin: seedAcquisition?.seedBatchId || planting?.seedBatchId || 'unknown',
|
|
currentCustodian: lastEvent.receiverId,
|
|
currentLocation: lastEvent.toLocation,
|
|
currentStage,
|
|
events: sortedEvents,
|
|
totalFoodMiles,
|
|
totalCarbonKg,
|
|
daysInTransit: Math.round(daysInTransit),
|
|
daysGrowing,
|
|
generation: seedAcquisition?.generation || 0,
|
|
ancestorPlantIds: seedAcquisition?.parentPlantIds || [],
|
|
descendantSeedBatches: sortedEvents
|
|
.filter(e => e.eventType === 'seed_saving')
|
|
.map(e => (e as SeedSavingEvent).newSeedBatchId)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get environmental impact summary for a user
|
|
*/
|
|
getEnvironmentalImpact(userId: string): EnvironmentalImpact {
|
|
const userEvents = this.chain
|
|
.filter(block =>
|
|
block.transportEvent.senderId === userId ||
|
|
block.transportEvent.receiverId === userId
|
|
)
|
|
.map(block => block.transportEvent);
|
|
|
|
let totalCarbonKg = 0;
|
|
let totalFoodMiles = 0;
|
|
let totalWeight = 0;
|
|
|
|
const breakdownByMethod: EnvironmentalImpact['breakdownByMethod'] = {} as any;
|
|
const breakdownByEventType: EnvironmentalImpact['breakdownByEventType'] = {} as any;
|
|
|
|
for (const event of userEvents) {
|
|
totalCarbonKg += event.carbonFootprintKg;
|
|
totalFoodMiles += event.distanceKm;
|
|
totalWeight += this.estimateWeight(event);
|
|
|
|
// Method breakdown
|
|
if (!breakdownByMethod[event.transportMethod]) {
|
|
breakdownByMethod[event.transportMethod] = { distance: 0, carbon: 0 };
|
|
}
|
|
breakdownByMethod[event.transportMethod].distance += event.distanceKm;
|
|
breakdownByMethod[event.transportMethod].carbon += event.carbonFootprintKg;
|
|
|
|
// Event type breakdown
|
|
if (!breakdownByEventType[event.eventType]) {
|
|
breakdownByEventType[event.eventType] = { count: 0, carbon: 0 };
|
|
}
|
|
breakdownByEventType[event.eventType].count++;
|
|
breakdownByEventType[event.eventType].carbon += event.carbonFootprintKg;
|
|
}
|
|
|
|
// Conventional comparison (assume 1500 miles avg, 2.5 kg CO2/lb)
|
|
const conventionalMiles = totalWeight * 1500;
|
|
const conventionalCarbon = totalWeight * 2.5;
|
|
|
|
return {
|
|
totalCarbonKg,
|
|
totalFoodMiles,
|
|
carbonPerKgProduce: totalWeight > 0 ? totalCarbonKg / totalWeight : 0,
|
|
milesPerKgProduce: totalWeight > 0 ? totalFoodMiles / totalWeight : 0,
|
|
breakdownByMethod,
|
|
breakdownByEventType,
|
|
comparisonToConventional: {
|
|
carbonSaved: Math.max(0, conventionalCarbon - totalCarbonKg),
|
|
milesSaved: Math.max(0, conventionalMiles - totalFoodMiles),
|
|
percentageReduction: conventionalCarbon > 0
|
|
? Math.round((1 - totalCarbonKg / conventionalCarbon) * 100)
|
|
: 0
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate QR code data for a plant or batch
|
|
*/
|
|
generateQRData(plantId?: string, batchId?: string): TransportQRData {
|
|
const events = plantId
|
|
? this.plantEvents.get(plantId)
|
|
: batchId
|
|
? this.batchEvents.get(batchId)
|
|
: [];
|
|
|
|
const lastEvent = events && events.length > 0
|
|
? events[events.length - 1]
|
|
: null;
|
|
|
|
const lineageHash = crypto.createHash('sha256')
|
|
.update(JSON.stringify(events))
|
|
.digest('hex')
|
|
.substring(0, 16);
|
|
|
|
return {
|
|
plantId,
|
|
batchId,
|
|
blockchainAddress: this.getLatestBlock().hash.substring(0, 42),
|
|
quickLookupUrl: `https://localgreenchain.org/track/${plantId || batchId}`,
|
|
lineageHash,
|
|
currentCustodian: lastEvent?.receiverId || 'unknown',
|
|
lastEventType: lastEvent?.eventType || 'seed_acquisition',
|
|
lastEventTimestamp: lastEvent?.timestamp || new Date().toISOString(),
|
|
verificationCode: crypto.randomBytes(4).toString('hex').toUpperCase()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Verify chain integrity
|
|
*/
|
|
isChainValid(): boolean {
|
|
for (let i = 1; i < this.chain.length; i++) {
|
|
const currentBlock = this.chain[i];
|
|
const previousBlock = this.chain[i - 1];
|
|
|
|
// Verify hash
|
|
const expectedHash = this.calculateHash(
|
|
currentBlock.index,
|
|
currentBlock.timestamp,
|
|
currentBlock.transportEvent,
|
|
currentBlock.previousHash,
|
|
currentBlock.nonce
|
|
);
|
|
|
|
if (currentBlock.hash !== expectedHash) {
|
|
return false;
|
|
}
|
|
|
|
// Verify chain link
|
|
if (currentBlock.previousHash !== previousBlock.hash) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Export to JSON
|
|
*/
|
|
toJSON(): object {
|
|
return {
|
|
difficulty: this.difficulty,
|
|
chain: this.chain
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Import from JSON
|
|
*/
|
|
static fromJSON(data: any): TransportChain {
|
|
const chain = new TransportChain(data.difficulty);
|
|
chain.chain = data.chain;
|
|
|
|
// Rebuild indexes
|
|
for (const block of chain.chain) {
|
|
chain.indexEvent(block.transportEvent, block);
|
|
}
|
|
|
|
return chain;
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let transportChainInstance: TransportChain | null = null;
|
|
|
|
export function getTransportChain(): TransportChain {
|
|
if (!transportChainInstance) {
|
|
transportChainInstance = new TransportChain();
|
|
}
|
|
return transportChainInstance;
|
|
}
|
|
|
|
export function setTransportChain(chain: TransportChain): void {
|
|
transportChainInstance = chain;
|
|
}
|