Agents created: 1. PlantLineageAgent - Monitors plant ancestry and lineage integrity 2. TransportTrackerAgent - Tracks transport events and carbon footprint 3. DemandForecastAgent - Predicts consumer demand and market trends 4. VerticalFarmAgent - Manages vertical farm operations and optimization 5. EnvironmentAnalysisAgent - Analyzes growing conditions and recommendations 6. MarketMatchingAgent - Connects grower supply with consumer demand 7. SustainabilityAgent - Monitors environmental impact and sustainability 8. NetworkDiscoveryAgent - Maps geographic distribution and network analysis 9. QualityAssuranceAgent - Verifies blockchain integrity and data quality 10. GrowerAdvisoryAgent - Provides personalized growing recommendations Also includes: - BaseAgent abstract class for common functionality - AgentOrchestrator for centralized agent management - Comprehensive type definitions - Full documentation in docs/AGENTS.md
610 lines
19 KiB
TypeScript
610 lines
19 KiB
TypeScript
/**
|
|
* NetworkDiscoveryAgent
|
|
* Analyzes geographic distribution and connections in the plant network
|
|
*
|
|
* Responsibilities:
|
|
* - Map plant distribution across regions
|
|
* - Identify network hotspots and clusters
|
|
* - Suggest grower/consumer connections
|
|
* - Track network growth patterns
|
|
* - Detect coverage gaps
|
|
*/
|
|
|
|
import { BaseAgent } from './BaseAgent';
|
|
import { AgentConfig, AgentTask, NetworkAnalysis } from './types';
|
|
import { getBlockchain } from '../blockchain/manager';
|
|
import { PlantBlock } from '../blockchain/types';
|
|
|
|
interface NetworkNode {
|
|
id: string;
|
|
type: 'grower' | 'consumer' | 'plant';
|
|
location: { latitude: number; longitude: number };
|
|
connections: string[];
|
|
activityScore: number;
|
|
species?: string[];
|
|
lastActive: string;
|
|
}
|
|
|
|
interface NetworkCluster {
|
|
id: string;
|
|
centroid: { latitude: number; longitude: number };
|
|
nodes: NetworkNode[];
|
|
radius: number;
|
|
density: number;
|
|
dominantSpecies: string[];
|
|
activityLevel: 'low' | 'medium' | 'high';
|
|
}
|
|
|
|
interface CoverageGap {
|
|
id: string;
|
|
location: { latitude: number; longitude: number };
|
|
nearestCluster: string;
|
|
distanceToNearest: number;
|
|
populationDensity: 'urban' | 'suburban' | 'rural';
|
|
potentialDemand: number;
|
|
recommendation: string;
|
|
}
|
|
|
|
interface ConnectionSuggestion {
|
|
id: string;
|
|
node1Id: string;
|
|
node2Id: string;
|
|
distance: number;
|
|
reason: string;
|
|
strength: number; // 0-100
|
|
mutualBenefits: string[];
|
|
}
|
|
|
|
interface NetworkGrowth {
|
|
date: string;
|
|
totalNodes: number;
|
|
totalConnections: number;
|
|
newNodesWeek: number;
|
|
newConnectionsWeek: number;
|
|
geographicExpansion: number; // km radius growth
|
|
}
|
|
|
|
interface RegionalStats {
|
|
region: string;
|
|
centerLat: number;
|
|
centerLon: number;
|
|
nodeCount: number;
|
|
plantCount: number;
|
|
uniqueSpecies: number;
|
|
avgActivityScore: number;
|
|
connections: number;
|
|
}
|
|
|
|
export class NetworkDiscoveryAgent extends BaseAgent {
|
|
private nodes: Map<string, NetworkNode> = new Map();
|
|
private clusters: NetworkCluster[] = [];
|
|
private coverageGaps: CoverageGap[] = [];
|
|
private connectionSuggestions: ConnectionSuggestion[] = [];
|
|
private growthHistory: NetworkGrowth[] = [];
|
|
private regionalStats: Map<string, RegionalStats> = new Map();
|
|
|
|
constructor() {
|
|
const config: AgentConfig = {
|
|
id: 'network-discovery-agent',
|
|
name: 'Network Discovery Agent',
|
|
description: 'Maps and analyzes the geographic plant network',
|
|
enabled: true,
|
|
intervalMs: 600000, // Run every 10 minutes
|
|
priority: 'medium',
|
|
maxRetries: 3,
|
|
timeoutMs: 60000
|
|
};
|
|
super(config);
|
|
}
|
|
|
|
/**
|
|
* Main execution cycle
|
|
*/
|
|
async runOnce(): Promise<AgentTask | null> {
|
|
const blockchain = getBlockchain();
|
|
const chain = blockchain.getChain();
|
|
const plants = chain.slice(1);
|
|
|
|
// Build network from plant data
|
|
this.buildNetworkNodes(plants);
|
|
|
|
// Identify clusters
|
|
this.identifyClusters();
|
|
|
|
// Find coverage gaps
|
|
this.findCoverageGaps();
|
|
|
|
// Generate connection suggestions
|
|
this.generateConnectionSuggestions();
|
|
|
|
// Update regional statistics
|
|
this.updateRegionalStats();
|
|
|
|
// Track growth
|
|
this.trackGrowth();
|
|
|
|
// Generate alerts for significant network events
|
|
this.checkNetworkAlerts();
|
|
|
|
return this.createTaskResult('network_discovery', 'completed', {
|
|
totalNodes: this.nodes.size,
|
|
clustersIdentified: this.clusters.length,
|
|
coverageGaps: this.coverageGaps.length,
|
|
connectionSuggestions: this.connectionSuggestions.length,
|
|
regions: this.regionalStats.size
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build network nodes from plant data
|
|
*/
|
|
private buildNetworkNodes(plants: PlantBlock[]): void {
|
|
// Group by owner to create nodes
|
|
const ownerPlants = new Map<string, PlantBlock[]>();
|
|
|
|
for (const block of plants) {
|
|
const ownerId = block.plant.owner?.id || 'unknown';
|
|
const ownerGroup = ownerPlants.get(ownerId) || [];
|
|
ownerGroup.push(block);
|
|
ownerPlants.set(ownerId, ownerGroup);
|
|
}
|
|
|
|
// Create nodes for each owner
|
|
for (const [ownerId, ownerBlocks] of ownerPlants) {
|
|
const latestBlock = ownerBlocks[ownerBlocks.length - 1];
|
|
const species = [...new Set(ownerBlocks.map(b => b.plant.commonName).filter(Boolean))];
|
|
|
|
// Calculate connections (plants from same lineage)
|
|
const connections: string[] = [];
|
|
for (const block of ownerBlocks) {
|
|
if (block.plant.parentPlantId) {
|
|
const parentOwner = plants.find(p => p.plant.id === block.plant.parentPlantId)?.plant.owner?.id;
|
|
if (parentOwner && parentOwner !== ownerId && !connections.includes(parentOwner)) {
|
|
connections.push(parentOwner);
|
|
}
|
|
}
|
|
for (const childId of block.plant.childPlants || []) {
|
|
const childOwner = plants.find(p => p.plant.id === childId)?.plant.owner?.id;
|
|
if (childOwner && childOwner !== ownerId && !connections.includes(childOwner)) {
|
|
connections.push(childOwner);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate activity score
|
|
const recentActivity = ownerBlocks.filter(b => {
|
|
const age = Date.now() - new Date(b.timestamp).getTime();
|
|
return age < 30 * 24 * 60 * 60 * 1000; // Last 30 days
|
|
}).length;
|
|
|
|
const node: NetworkNode = {
|
|
id: ownerId,
|
|
type: ownerBlocks.length > 5 ? 'grower' : 'consumer',
|
|
location: latestBlock.plant.location,
|
|
connections,
|
|
activityScore: Math.min(100, 20 + recentActivity * 10 + connections.length * 5),
|
|
species: species as string[],
|
|
lastActive: latestBlock.timestamp
|
|
};
|
|
|
|
this.nodes.set(ownerId, node);
|
|
}
|
|
|
|
// Also create plant nodes for visualization
|
|
for (const block of plants) {
|
|
const plantNode: NetworkNode = {
|
|
id: `plant-${block.plant.id}`,
|
|
type: 'plant',
|
|
location: block.plant.location,
|
|
connections: block.plant.childPlants?.map(c => `plant-${c}`) || [],
|
|
activityScore: block.plant.status === 'growing' ? 80 : 40,
|
|
species: [block.plant.commonName].filter(Boolean) as string[],
|
|
lastActive: block.timestamp
|
|
};
|
|
|
|
this.nodes.set(plantNode.id, plantNode);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Identify clusters using simplified DBSCAN-like algorithm
|
|
*/
|
|
private identifyClusters(): void {
|
|
const nodes = Array.from(this.nodes.values()).filter(n => n.type !== 'plant');
|
|
const clusterRadius = 50; // km
|
|
const minClusterSize = 2;
|
|
|
|
const visited = new Set<string>();
|
|
const clusters: NetworkCluster[] = [];
|
|
|
|
for (const node of nodes) {
|
|
if (visited.has(node.id)) continue;
|
|
|
|
// Find all nodes within radius
|
|
const neighborhood = nodes.filter(n =>
|
|
!visited.has(n.id) &&
|
|
this.calculateDistance(node.location, n.location) <= clusterRadius
|
|
);
|
|
|
|
if (neighborhood.length >= minClusterSize) {
|
|
// Create cluster
|
|
const clusterNodes: NetworkNode[] = [];
|
|
|
|
for (const neighbor of neighborhood) {
|
|
visited.add(neighbor.id);
|
|
clusterNodes.push(neighbor);
|
|
}
|
|
|
|
// Calculate centroid
|
|
const centroid = {
|
|
latitude: clusterNodes.reduce((sum, n) => sum + n.location.latitude, 0) / clusterNodes.length,
|
|
longitude: clusterNodes.reduce((sum, n) => sum + n.location.longitude, 0) / clusterNodes.length
|
|
};
|
|
|
|
// Calculate radius
|
|
const maxDist = Math.max(...clusterNodes.map(n =>
|
|
this.calculateDistance(centroid, n.location)
|
|
));
|
|
|
|
// Find dominant species
|
|
const speciesCounts = new Map<string, number>();
|
|
for (const n of clusterNodes) {
|
|
for (const species of n.species || []) {
|
|
speciesCounts.set(species, (speciesCounts.get(species) || 0) + 1);
|
|
}
|
|
}
|
|
const dominantSpecies = Array.from(speciesCounts.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 3)
|
|
.map(([species]) => species);
|
|
|
|
// Calculate activity level
|
|
const avgActivity = clusterNodes.reduce((sum, n) => sum + n.activityScore, 0) / clusterNodes.length;
|
|
const activityLevel = avgActivity > 70 ? 'high' : avgActivity > 40 ? 'medium' : 'low';
|
|
|
|
clusters.push({
|
|
id: `cluster-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
|
centroid,
|
|
nodes: clusterNodes,
|
|
radius: Math.round(maxDist * 10) / 10,
|
|
density: clusterNodes.length / (Math.PI * maxDist * maxDist),
|
|
dominantSpecies,
|
|
activityLevel
|
|
});
|
|
}
|
|
}
|
|
|
|
this.clusters = clusters;
|
|
}
|
|
|
|
/**
|
|
* Find coverage gaps in the network
|
|
*/
|
|
private findCoverageGaps(): void {
|
|
// Define key metropolitan areas that should have coverage
|
|
const keyAreas = [
|
|
{ name: 'Metro Center', lat: 40.7128, lon: -74.0060, pop: 'urban' },
|
|
{ name: 'Suburban North', lat: 40.85, lon: -73.95, pop: 'suburban' },
|
|
{ name: 'Suburban South', lat: 40.55, lon: -74.15, pop: 'suburban' },
|
|
{ name: 'Rural West', lat: 40.7, lon: -74.4, pop: 'rural' }
|
|
];
|
|
|
|
const gaps: CoverageGap[] = [];
|
|
|
|
for (const area of keyAreas) {
|
|
// Find nearest cluster
|
|
let nearestCluster: NetworkCluster | null = null;
|
|
let nearestDistance = Infinity;
|
|
|
|
for (const cluster of this.clusters) {
|
|
const dist = this.calculateDistance(
|
|
{ latitude: area.lat, longitude: area.lon },
|
|
cluster.centroid
|
|
);
|
|
if (dist < nearestDistance) {
|
|
nearestDistance = dist;
|
|
nearestCluster = cluster;
|
|
}
|
|
}
|
|
|
|
// If no cluster within 30km, it's a gap
|
|
if (nearestDistance > 30) {
|
|
const potentialDemand = area.pop === 'urban' ? 1000 :
|
|
area.pop === 'suburban' ? 500 : 100;
|
|
|
|
gaps.push({
|
|
id: `gap-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
|
location: { latitude: area.lat, longitude: area.lon },
|
|
nearestCluster: nearestCluster?.id || 'none',
|
|
distanceToNearest: Math.round(nearestDistance),
|
|
populationDensity: area.pop as 'urban' | 'suburban' | 'rural',
|
|
potentialDemand,
|
|
recommendation: `Recruit growers in ${area.name} area to serve ${potentialDemand}+ potential consumers`
|
|
});
|
|
}
|
|
}
|
|
|
|
this.coverageGaps = gaps;
|
|
}
|
|
|
|
/**
|
|
* Generate connection suggestions
|
|
*/
|
|
private generateConnectionSuggestions(): void {
|
|
const nodes = Array.from(this.nodes.values()).filter(n => n.type !== 'plant');
|
|
const suggestions: ConnectionSuggestion[] = [];
|
|
|
|
for (const node1 of nodes) {
|
|
for (const node2 of nodes) {
|
|
if (node1.id >= node2.id) continue; // Avoid duplicates
|
|
if (node1.connections.includes(node2.id)) continue; // Already connected
|
|
|
|
const distance = this.calculateDistance(node1.location, node2.location);
|
|
if (distance > 100) continue; // Too far
|
|
|
|
// Calculate connection strength
|
|
let strength = 50;
|
|
const benefits: string[] = [];
|
|
|
|
// Distance bonus
|
|
if (distance < 10) {
|
|
strength += 20;
|
|
benefits.push('Very close proximity');
|
|
} else if (distance < 25) {
|
|
strength += 10;
|
|
benefits.push('Local connection');
|
|
}
|
|
|
|
// Complementary types
|
|
if (node1.type !== node2.type) {
|
|
strength += 15;
|
|
benefits.push('Grower-consumer match');
|
|
}
|
|
|
|
// Shared species interest
|
|
const sharedSpecies = node1.species?.filter(s => node2.species?.includes(s)) || [];
|
|
if (sharedSpecies.length > 0) {
|
|
strength += sharedSpecies.length * 5;
|
|
benefits.push(`Shared interest: ${sharedSpecies.join(', ')}`);
|
|
}
|
|
|
|
// Activity match
|
|
if (Math.abs(node1.activityScore - node2.activityScore) < 20) {
|
|
strength += 10;
|
|
benefits.push('Similar activity levels');
|
|
}
|
|
|
|
if (strength >= 60) {
|
|
suggestions.push({
|
|
id: `suggestion-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
|
node1Id: node1.id,
|
|
node2Id: node2.id,
|
|
distance: Math.round(distance * 10) / 10,
|
|
reason: `${benefits[0] || 'Proximity match'}`,
|
|
strength: Math.min(100, strength),
|
|
mutualBenefits: benefits
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by strength and keep top suggestions
|
|
this.connectionSuggestions = suggestions
|
|
.sort((a, b) => b.strength - a.strength)
|
|
.slice(0, 50);
|
|
}
|
|
|
|
/**
|
|
* Update regional statistics
|
|
*/
|
|
private updateRegionalStats(): void {
|
|
// Define regions
|
|
const regions = [
|
|
{ name: 'Northeast', centerLat: 42, centerLon: -73, radius: 300 },
|
|
{ name: 'Southeast', centerLat: 33, centerLon: -84, radius: 400 },
|
|
{ name: 'Midwest', centerLat: 41, centerLon: -87, radius: 400 },
|
|
{ name: 'Southwest', centerLat: 33, centerLon: -112, radius: 400 },
|
|
{ name: 'West Coast', centerLat: 37, centerLon: -122, radius: 300 }
|
|
];
|
|
|
|
for (const region of regions) {
|
|
const regionNodes = Array.from(this.nodes.values()).filter(n => {
|
|
const dist = this.calculateDistance(
|
|
n.location,
|
|
{ latitude: region.centerLat, longitude: region.centerLon }
|
|
);
|
|
return dist <= region.radius;
|
|
});
|
|
|
|
const plantNodes = regionNodes.filter(n => n.type === 'plant');
|
|
const otherNodes = regionNodes.filter(n => n.type !== 'plant');
|
|
|
|
const allSpecies = new Set<string>();
|
|
regionNodes.forEach(n => n.species?.forEach(s => allSpecies.add(s)));
|
|
|
|
const stats: RegionalStats = {
|
|
region: region.name,
|
|
centerLat: region.centerLat,
|
|
centerLon: region.centerLon,
|
|
nodeCount: otherNodes.length,
|
|
plantCount: plantNodes.length,
|
|
uniqueSpecies: allSpecies.size,
|
|
avgActivityScore: otherNodes.length > 0
|
|
? Math.round(otherNodes.reduce((sum, n) => sum + n.activityScore, 0) / otherNodes.length)
|
|
: 0,
|
|
connections: otherNodes.reduce((sum, n) => sum + n.connections.length, 0)
|
|
};
|
|
|
|
this.regionalStats.set(region.name, stats);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Track network growth
|
|
*/
|
|
private trackGrowth(): void {
|
|
const currentNodes = this.nodes.size;
|
|
const currentConnections = Array.from(this.nodes.values())
|
|
.reduce((sum, n) => sum + n.connections.length, 0) / 2;
|
|
|
|
const lastGrowth = this.growthHistory[this.growthHistory.length - 1];
|
|
|
|
const growth: NetworkGrowth = {
|
|
date: new Date().toISOString(),
|
|
totalNodes: currentNodes,
|
|
totalConnections: Math.round(currentConnections),
|
|
newNodesWeek: lastGrowth ? currentNodes - lastGrowth.totalNodes : currentNodes,
|
|
newConnectionsWeek: lastGrowth ? Math.round(currentConnections) - lastGrowth.totalConnections : Math.round(currentConnections),
|
|
geographicExpansion: this.calculateGeographicExpansion()
|
|
};
|
|
|
|
this.growthHistory.push(growth);
|
|
|
|
// Keep last year of data
|
|
if (this.growthHistory.length > 52) {
|
|
this.growthHistory = this.growthHistory.slice(-52);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate geographic expansion
|
|
*/
|
|
private calculateGeographicExpansion(): number {
|
|
if (this.nodes.size === 0) return 0;
|
|
|
|
const nodes = Array.from(this.nodes.values());
|
|
let maxDistance = 0;
|
|
|
|
// Find maximum distance between any two nodes (simplified)
|
|
for (let i = 0; i < Math.min(nodes.length, 100); i++) {
|
|
for (let j = i + 1; j < Math.min(nodes.length, 100); j++) {
|
|
const dist = this.calculateDistance(nodes[i].location, nodes[j].location);
|
|
maxDistance = Math.max(maxDistance, dist);
|
|
}
|
|
}
|
|
|
|
return Math.round(maxDistance);
|
|
}
|
|
|
|
/**
|
|
* Check for network alerts
|
|
*/
|
|
private checkNetworkAlerts(): void {
|
|
// Alert for high-potential coverage gaps
|
|
for (const gap of this.coverageGaps) {
|
|
if (gap.populationDensity === 'urban' && gap.distanceToNearest > 50) {
|
|
this.createAlert('warning', 'Coverage Gap in Urban Area',
|
|
gap.recommendation,
|
|
{ relatedEntityId: gap.id, relatedEntityType: 'gap' }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Alert for network growth milestones
|
|
const nodeCount = this.nodes.size;
|
|
const milestones = [50, 100, 250, 500, 1000];
|
|
|
|
for (const milestone of milestones) {
|
|
if (nodeCount >= milestone * 0.95 && nodeCount <= milestone * 1.05) {
|
|
this.createAlert('info', 'Network Milestone',
|
|
`Network has reached approximately ${milestone} participants!`,
|
|
{ relatedEntityType: 'network' }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate Haversine distance
|
|
*/
|
|
private calculateDistance(
|
|
loc1: { latitude: number; longitude: number },
|
|
loc2: { latitude: number; longitude: number }
|
|
): number {
|
|
const R = 6371;
|
|
const dLat = (loc2.latitude - loc1.latitude) * Math.PI / 180;
|
|
const dLon = (loc2.longitude - loc1.longitude) * Math.PI / 180;
|
|
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
Math.cos(loc1.latitude * Math.PI / 180) * Math.cos(loc2.latitude * 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;
|
|
}
|
|
|
|
/**
|
|
* Get network analysis
|
|
*/
|
|
getNetworkAnalysis(): NetworkAnalysis {
|
|
return {
|
|
totalNodes: this.nodes.size,
|
|
totalConnections: Array.from(this.nodes.values())
|
|
.reduce((sum, n) => sum + n.connections.length, 0) / 2,
|
|
clusters: this.clusters.map(c => ({
|
|
centroid: { lat: c.centroid.latitude, lon: c.centroid.longitude },
|
|
nodeCount: c.nodes.length,
|
|
avgDistance: c.radius,
|
|
dominantSpecies: c.dominantSpecies
|
|
})),
|
|
hotspots: this.clusters
|
|
.filter(c => c.activityLevel === 'high')
|
|
.map(c => ({
|
|
location: { lat: c.centroid.latitude, lon: c.centroid.longitude },
|
|
intensity: c.nodes.length * c.density,
|
|
type: 'mixed' as const
|
|
})),
|
|
recommendations: this.coverageGaps.map(g => g.recommendation)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get clusters
|
|
*/
|
|
getClusters(): NetworkCluster[] {
|
|
return this.clusters;
|
|
}
|
|
|
|
/**
|
|
* Get coverage gaps
|
|
*/
|
|
getCoverageGaps(): CoverageGap[] {
|
|
return this.coverageGaps;
|
|
}
|
|
|
|
/**
|
|
* Get connection suggestions
|
|
*/
|
|
getConnectionSuggestions(): ConnectionSuggestion[] {
|
|
return this.connectionSuggestions;
|
|
}
|
|
|
|
/**
|
|
* Get growth history
|
|
*/
|
|
getGrowthHistory(): NetworkGrowth[] {
|
|
return this.growthHistory;
|
|
}
|
|
|
|
/**
|
|
* Get regional stats
|
|
*/
|
|
getRegionalStats(): RegionalStats[] {
|
|
return Array.from(this.regionalStats.values());
|
|
}
|
|
|
|
/**
|
|
* Get node by ID
|
|
*/
|
|
getNode(nodeId: string): NetworkNode | null {
|
|
return this.nodes.get(nodeId) || null;
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let networkAgentInstance: NetworkDiscoveryAgent | null = null;
|
|
|
|
export function getNetworkDiscoveryAgent(): NetworkDiscoveryAgent {
|
|
if (!networkAgentInstance) {
|
|
networkAgentInstance = new NetworkDiscoveryAgent();
|
|
}
|
|
return networkAgentInstance;
|
|
}
|