localgreenchain/lib/analytics/trends.ts
Claude 816c3b3f2e
Implement Agent 7: Advanced Analytics Dashboard
Add comprehensive analytics system with:
- Analytics data layer (aggregator, metrics, trends, cache)
- 6 API endpoints (overview, plants, transport, farms, sustainability, export)
- 6 chart components (LineChart, BarChart, PieChart, AreaChart, Gauge, Heatmap)
- 5 dashboard widgets (KPICard, TrendIndicator, DataTable, DateRangePicker, FilterPanel)
- 5 dashboard pages (overview, plants, transport, farms, sustainability)
- Export functionality (CSV, JSON)

Dependencies added: recharts, d3, date-fns

Also includes minor fixes:
- Fix EnvironmentalForm spread type error
- Fix AgentOrchestrator Map iteration issues
- Fix next.config.js image domains undefined error
- Add downlevelIteration to tsconfig
2025-11-23 04:02:07 +00:00

411 lines
11 KiB
TypeScript

/**
* Trend Analysis for Analytics
* Provides trend detection, forecasting, and pattern analysis
*/
import { TimeSeriesDataPoint, TrendDirection, TrendData, TimeRange } from './types';
import { mean, standardDeviation, percentageChange, movingAverage } from './metrics';
import { format, parseISO, differenceInDays } from 'date-fns';
/**
* Analyze trend from time series data
*/
export function analyzeTrend(data: TimeSeriesDataPoint[]): TrendData {
if (data.length < 2) {
return {
metric: 'Unknown',
currentValue: data[0]?.value || 0,
previousValue: 0,
change: 0,
changePercent: 0,
direction: 'stable',
period: 'N/A',
};
}
const midpoint = Math.floor(data.length / 2);
const firstHalf = data.slice(0, midpoint);
const secondHalf = data.slice(midpoint);
const firstAvg = mean(firstHalf.map(d => d.value));
const secondAvg = mean(secondHalf.map(d => d.value));
const change = secondAvg - firstAvg;
const changePercent = percentageChange(secondAvg, firstAvg);
let direction: TrendDirection = 'stable';
if (changePercent > 5) direction = 'up';
else if (changePercent < -5) direction = 'down';
return {
metric: '',
currentValue: secondAvg,
previousValue: firstAvg,
change,
changePercent: Math.round(changePercent * 10) / 10,
direction,
period: `${data[0].timestamp} - ${data[data.length - 1].timestamp}`,
};
}
/**
* Calculate linear regression for forecasting
*/
export function linearRegression(data: TimeSeriesDataPoint[]): {
slope: number;
intercept: number;
rSquared: number;
} {
const n = data.length;
if (n < 2) return { slope: 0, intercept: 0, rSquared: 0 };
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
data.forEach((point, i) => {
const x = i;
const y = point.value;
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
sumY2 += y * y;
});
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
const intercept = (sumY - slope * sumX) / n;
// Calculate R-squared
const yMean = sumY / n;
let ssRes = 0, ssTot = 0;
data.forEach((point, i) => {
const predicted = slope * i + intercept;
ssRes += Math.pow(point.value - predicted, 2);
ssTot += Math.pow(point.value - yMean, 2);
});
const rSquared = ssTot === 0 ? 1 : 1 - ssRes / ssTot;
return {
slope: Math.round(slope * 1000) / 1000,
intercept: Math.round(intercept * 1000) / 1000,
rSquared: Math.round(rSquared * 1000) / 1000,
};
}
/**
* Forecast future values using linear regression
*/
export function forecast(
data: TimeSeriesDataPoint[],
periodsAhead: number
): TimeSeriesDataPoint[] {
const regression = linearRegression(data);
const predictions: TimeSeriesDataPoint[] = [];
const lastIndex = data.length - 1;
for (let i = 1; i <= periodsAhead; i++) {
const predictedValue = regression.slope * (lastIndex + i) + regression.intercept;
predictions.push({
timestamp: `+${i}`,
value: Math.max(0, Math.round(predictedValue * 100) / 100),
label: `Forecast ${i}`,
});
}
return predictions;
}
/**
* Detect seasonality in data
*/
export function detectSeasonality(data: TimeSeriesDataPoint[]): {
hasSeasonality: boolean;
period: number;
strength: number;
} {
if (data.length < 14) {
return { hasSeasonality: false, period: 0, strength: 0 };
}
// Try common periods: 7 days (weekly), 30 days (monthly)
const periods = [7, 14, 30];
let bestPeriod = 0;
let bestCorrelation = 0;
for (const period of periods) {
if (data.length < period * 2) continue;
let correlation = 0;
let count = 0;
for (let i = period; i < data.length; i++) {
correlation += Math.abs(data[i].value - data[i - period].value);
count++;
}
const avgCorr = count > 0 ? 1 - correlation / (count * mean(data.map(d => d.value))) : 0;
if (avgCorr > bestCorrelation) {
bestCorrelation = avgCorr;
bestPeriod = period;
}
}
return {
hasSeasonality: bestCorrelation > 0.5,
period: bestPeriod,
strength: Math.round(bestCorrelation * 100) / 100,
};
}
/**
* Identify peaks and valleys in data
*/
export function findPeaksAndValleys(
data: TimeSeriesDataPoint[],
sensitivity: number = 1.5
): {
peaks: TimeSeriesDataPoint[];
valleys: TimeSeriesDataPoint[];
} {
const peaks: TimeSeriesDataPoint[] = [];
const valleys: TimeSeriesDataPoint[] = [];
if (data.length < 3) return { peaks, valleys };
const avg = mean(data.map(d => d.value));
const std = standardDeviation(data.map(d => d.value));
const threshold = std * sensitivity;
for (let i = 1; i < data.length - 1; i++) {
const prev = data[i - 1].value;
const curr = data[i].value;
const next = data[i + 1].value;
// Peak detection
if (curr > prev && curr > next && curr > avg + threshold) {
peaks.push(data[i]);
}
// Valley detection
if (curr < prev && curr < next && curr < avg - threshold) {
valleys.push(data[i]);
}
}
return { peaks, valleys };
}
/**
* Calculate trend momentum
*/
export function calculateMomentum(data: TimeSeriesDataPoint[], lookback: number = 5): number {
if (data.length < lookback) return 0;
const recent = data.slice(-lookback);
const older = data.slice(-lookback * 2, -lookback);
if (older.length === 0) return 0;
const recentAvg = mean(recent.map(d => d.value));
const olderAvg = mean(older.map(d => d.value));
return Math.round(((recentAvg - olderAvg) / olderAvg) * 100 * 10) / 10;
}
/**
* Smooth data using exponential smoothing
*/
export function exponentialSmoothing(
data: TimeSeriesDataPoint[],
alpha: number = 0.3
): TimeSeriesDataPoint[] {
if (data.length === 0) return [];
const smoothed: TimeSeriesDataPoint[] = [data[0]];
for (let i = 1; i < data.length; i++) {
const smoothedValue = alpha * data[i].value + (1 - alpha) * smoothed[i - 1].value;
smoothed.push({
...data[i],
value: Math.round(smoothedValue * 100) / 100,
});
}
return smoothed;
}
/**
* Generate trend summary text
*/
export function generateTrendSummary(data: TimeSeriesDataPoint[], metricName: string): string {
if (data.length < 2) return `Insufficient data for ${metricName} analysis.`;
const trend = analyzeTrend(data);
const regression = linearRegression(data);
const { peaks, valleys } = findPeaksAndValleys(data);
const momentum = calculateMomentum(data);
let summary = '';
// Overall direction
if (trend.direction === 'up') {
summary += `${metricName} is trending upward with a ${Math.abs(trend.changePercent).toFixed(1)}% increase. `;
} else if (trend.direction === 'down') {
summary += `${metricName} is trending downward with a ${Math.abs(trend.changePercent).toFixed(1)}% decrease. `;
} else {
summary += `${metricName} remains relatively stable. `;
}
// Trend strength
if (regression.rSquared > 0.8) {
summary += 'The trend is strong and consistent. ';
} else if (regression.rSquared > 0.5) {
summary += 'The trend is moderate with some variability. ';
} else {
summary += 'Data shows high variability. ';
}
// Momentum
if (momentum > 10) {
summary += 'Recent acceleration detected. ';
} else if (momentum < -10) {
summary += 'Recent deceleration detected. ';
}
// Notable events
if (peaks.length > 0) {
summary += `${peaks.length} notable peak(s) observed. `;
}
if (valleys.length > 0) {
summary += `${valleys.length} notable dip(s) observed. `;
}
return summary.trim();
}
/**
* Compare two time series for similarity
*/
export function compareTimeSeries(
series1: TimeSeriesDataPoint[],
series2: TimeSeriesDataPoint[]
): {
correlation: number;
leadLag: number;
divergence: number;
} {
// Ensure same length
const minLength = Math.min(series1.length, series2.length);
const s1 = series1.slice(0, minLength).map(d => d.value);
const s2 = series2.slice(0, minLength).map(d => d.value);
// Calculate correlation
const mean1 = mean(s1);
const mean2 = mean(s2);
let numerator = 0, denom1 = 0, denom2 = 0;
for (let i = 0; i < minLength; i++) {
const d1 = s1[i] - mean1;
const d2 = s2[i] - mean2;
numerator += d1 * d2;
denom1 += d1 * d1;
denom2 += d2 * d2;
}
const correlation = denom1 * denom2 === 0 ? 0 : numerator / Math.sqrt(denom1 * denom2);
// Calculate divergence (normalized difference)
const divergence = mean(s1.map((v, i) => Math.abs(v - s2[i]))) / ((mean1 + mean2) / 2);
return {
correlation: Math.round(correlation * 1000) / 1000,
leadLag: 0, // Simplified - full cross-correlation would require more computation
divergence: Math.round(divergence * 1000) / 1000,
};
}
/**
* Get trend confidence level
*/
export function getTrendConfidence(data: TimeSeriesDataPoint[]): {
level: 'high' | 'medium' | 'low';
score: number;
factors: string[];
} {
const factors: string[] = [];
let score = 50;
// Data quantity factor
if (data.length >= 30) {
score += 15;
factors.push('Sufficient data points');
} else if (data.length >= 14) {
score += 10;
factors.push('Moderate data points');
} else {
score -= 10;
factors.push('Limited data points');
}
// Consistency factor
const std = standardDeviation(data.map(d => d.value));
const avg = mean(data.map(d => d.value));
const cv = avg !== 0 ? std / avg : 0;
if (cv < 0.2) {
score += 15;
factors.push('Low variability');
} else if (cv < 0.5) {
score += 5;
factors.push('Moderate variability');
} else {
score -= 10;
factors.push('High variability');
}
// Trend strength
const regression = linearRegression(data);
if (regression.rSquared > 0.7) {
score += 20;
factors.push('Strong trend fit');
} else if (regression.rSquared > 0.4) {
score += 10;
factors.push('Moderate trend fit');
} else {
score -= 5;
factors.push('Weak trend fit');
}
const level = score >= 70 ? 'high' : score >= 50 ? 'medium' : 'low';
return {
level,
score: Math.min(100, Math.max(0, score)),
factors,
};
}
/**
* Calculate year-over-year comparison
*/
export function yearOverYearComparison(
currentPeriod: TimeSeriesDataPoint[],
previousPeriod: TimeSeriesDataPoint[]
): {
currentTotal: number;
previousTotal: number;
change: number;
changePercent: number;
trend: TrendDirection;
} {
const currentTotal = currentPeriod.reduce((sum, d) => sum + d.value, 0);
const previousTotal = previousPeriod.reduce((sum, d) => sum + d.value, 0);
const change = currentTotal - previousTotal;
const changePercent = percentageChange(currentTotal, previousTotal);
return {
currentTotal: Math.round(currentTotal * 100) / 100,
previousTotal: Math.round(previousTotal * 100) / 100,
change: Math.round(change * 100) / 100,
changePercent: Math.round(changePercent * 10) / 10,
trend: changePercent > 5 ? 'up' : changePercent < -5 ? 'down' : 'stable',
};
}