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
411 lines
11 KiB
TypeScript
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',
|
|
};
|
|
}
|