Compare commits

..

No commits in common. "main" and "convolution" have entirely different histories.

17 changed files with 649 additions and 1463 deletions

46
api-documentation.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,35 +0,0 @@
{
"name": "analytics-api",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"express": "^4.21.2",
"lodash": "^4.17.21",
"mathjs": "^14.6.0",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.20",
"@types/node": "^24.3.0",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"concurrently": "^9.2.1",
"jest": "^30.1.3",
"swagger-jsdoc": "^6.2.8",
"ts-jest": "^29.4.4",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
}
}

View file

@ -48,7 +48,7 @@ export function calculateLinearRegression(yValues: number[]): LinearRegressionMo
// Cast the result of math.sum to a Number
const correlationNumerator = Number(math.sum(xValues.map((x, i) => (x - meanX) * (yValues[i] - meanY))));
const correlation = correlationNumerator / ((xValues.length) * stdDevX * stdDevY);
const correlation = correlationNumerator / ((xValues.length - 1) * stdDevX * stdDevY);
const slope = correlation * (stdDevY / stdDevX);
const intercept = meanY - slope * meanX;

613
server.ts
View file

@ -6,51 +6,350 @@
import express from 'express';
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
import cors from 'cors';
import * as math from 'mathjs';
import * as _ from 'lodash';
// Assuming these files exist in the same directory
// These imports assume the files exist in the same directory
// import { KMeans, KMeansOptions } from './kmeans';
// import { getWeekNumber, getSameWeekDayLastYear } from './time-helper';
// import { calculateLinearRegression, generateForecast, calculatePredictionIntervals, ForecastResult } from './prediction';
import { SignalProcessor, SmoothingOptions, EdgeDetectionOptions } from './services/signal_processing_convolution';
import { TimeSeriesAnalyzer, ARIMAOptions } from './services/timeseries';
import { AnalysisPipelines } from './services/analysis_pipelines';
import { convolve1D, convolve2D, ConvolutionKernels } from './services/convolution';
import { DataSeries, DataMatrix, Condition, ApiResponse } from './types/index';
import { handleError, validateSeries, validateMatrix } from './services/analytics_engine';
import { ForecastResult } from './services/prediction';
import { analytics } from './services/analytics_engine';
import { purchaseRate, liftValue, costRatio, grossMarginRate, averageSpendPerCustomer, purchaseIndex } from './services/retail_metrics';
import { RollingWindow } from './services/rolling_window';
import { pivotTable, PivotOptions } from './services/pivot_table';
import { SignalProcessor, SmoothingOptions, EdgeDetectionOptions } from './signal_processing_convolution';
import { convolve1D, ConvolutionKernels } from './convolution'; // Direct import for new functions
interface KMeansOptions {}
class KMeans {
constructor(p: any, n: any, o: any) {}
run = () => ({ clusters: [] })
}
const getWeekNumber = (d: string) => 1;
const getSameWeekDayLastYear = (d: string) => new Date().toISOString();
interface ForecastResult {}
const calculateLinearRegression = (v: any) => ({slope: 1, intercept: 0});
const generateForecast = (m: any, l: any, p: any) => [];
const calculatePredictionIntervals = (v: any, m: any, f: any) => [];
// Initialize Express app
const app = express();
app.use(express.json());
app.use(cors()); // <-- 2. ENABLE CORS FOR ALL ROUTES
const PORT = process.env.PORT || 3000;
const swaggerOptions = {
swaggerDefinition: {
openapi: '3.0.0',
info: {
title: 'My Express API',
version: '1.0.0',
description: 'API documentation for my awesome Express app',
swaggerDefinition: {
openapi: '3.0.0',
info: {
title: 'My Express API',
version: '1.0.0',
description: 'API documentation for my awesome Express app',
},
servers: [
{
url: `http://localhost:${PORT}`,
},
],
},
servers: [
{
url: `http://localhost:${PORT}`,
},
],
},
apis: ["./server.ts"], // Pointing to the correct, renamed file
apis: ["./server.ts"], // Pointing to this file for Swagger docs
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// ========================================
// TYPE DEFINITIONS
// ========================================
interface DataSeries {
values: number[];
labels?: string[];
}
interface DataMatrix {
data: number[][];
columns?: string[];
rows?: string[];
}
interface Condition {
field: string;
operator: '>' | '<' | '=' | '>=' | '<=' | '!=';
value: number | string;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
// ========================================
// HELPER FUNCTIONS
// ========================================
const handleError = (error: unknown): string => {
return error instanceof Error ? error.message : 'Unknown error';
};
const validateSeries = (series: DataSeries): void => {
if (!series || !Array.isArray(series.values) || series.values.length === 0) {
throw new Error('Series must contain at least one value');
}
};
const validateMatrix = (matrix: DataMatrix): void => {
if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) {
throw new Error('Matrix must contain at least one row');
}
};
/**
* A helper class to provide a fluent API for rolling window calculations.
*/
class RollingWindow {
private windows: number[][];
constructor(windows: number[][]) {
this.windows = windows;
}
mean(): number[] {
return this.windows.map(window => Number(math.mean(window)));
}
sum(): number[] {
return this.windows.map(window => _.sum(window));
}
min(): number[] {
return this.windows.map(window => Math.min(...window));
}
max(): number[] {
return this.windows.map(window => Math.max(...window));
}
toArray(): number[][] {
return this.windows;
}
}
// ========================================
// ANALYTICS ENGINE (Simplified)
// ========================================
class AnalyticsEngine {
private applyConditions(series: DataSeries, conditions: Condition[] = []): number[] {
if (conditions.length === 0) return series.values;
return series.values; // TODO: Implement filtering
}
// Basic statistical functions
unique(series: DataSeries): number[] {
validateSeries(series);
return _.uniq(series.values);
}
mean(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Number(math.mean(filteredValues));
}
count(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return filteredValues.length;
}
variance(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Number(math.variance(filteredValues));
}
standardDeviation(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Number(math.std(filteredValues));
}
percentile(series: DataSeries, percent: number, ascending: boolean = true, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
const sorted = ascending ? _.sortBy(filteredValues) : _.sortBy(filteredValues).reverse();
const index = (percent / 100) * (sorted.length - 1);
const lower = Math.floor(index);
const upper = Math.ceil(index);
const weight = index % 1;
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
}
median(series: DataSeries, conditions: Condition[] = []): number {
return this.percentile(series, 50, true, conditions);
}
mode(series: DataSeries, conditions: Condition[] = []): number[] {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
const frequency = _.countBy(filteredValues);
const maxFreq = Math.max(...Object.values(frequency));
return Object.keys(frequency)
.filter(key => frequency[key] === maxFreq)
.map(Number);
}
max(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Math.max(...filteredValues);
}
min(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Math.min(...filteredValues);
}
correlation(series1: DataSeries, series2: DataSeries): number {
validateSeries(series1);
validateSeries(series2);
if (series1.values.length !== series2.values.length) {
throw new Error('Series must have same length for correlation');
}
const x = series1.values;
const y = series2.values;
const n = x.length;
const sumX = _.sum(x);
const sumY = _.sum(y);
const sumXY = _.sum(x.map((xi, i) => xi * y[i]));
const sumX2 = _.sum(x.map(xi => xi * xi));
const sumY2 = _.sum(y.map(yi => yi * yi));
const numerator = n * sumXY - sumX * sumY;
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
return numerator / denominator;
}
// Rolling window functions
rolling(series: DataSeries, windowSize: number): RollingWindow {
validateSeries(series);
if (windowSize <= 0) {
throw new Error('Window size must be a positive number.');
}
if (series.values.length < windowSize) {
return new RollingWindow([]);
}
const windows: number[][] = [];
for (let i = 0; i <= series.values.length - windowSize; i++) {
const window = series.values.slice(i, i + windowSize);
windows.push(window);
}
return new RollingWindow(windows);
}
movingAverage(series: DataSeries, windowSize: number): number[] {
return this.rolling(series, windowSize).mean();
}
// K-means wrapper (uses imported KMeans class)
kmeans(matrix: DataMatrix, nClusters: number, options: KMeansOptions = {}): { clusters: number[][][], centroids: number[][] } {
validateMatrix(matrix);
const points: number[][] = matrix.data;
// Use the new MiniBatchKMeans class
const kmeans = new KMeans(points, nClusters, options);
const result = kmeans.run();
const centroids = result.clusters.map(c => (c as any).centroid);
const clusters = result.clusters.map(c => (c as any).points);
return { clusters, centroids };
}
// Time helper wrapper functions
getWeekNumber(dateString: string): number {
return getWeekNumber(dateString);
}
getSameWeekDayLastYear(dateString: string): string {
return getSameWeekDayLastYear(dateString);
}
// Retail functions
purchaseRate(productPurchases: number, totalTransactions: number): number {
if (totalTransactions === 0) throw new Error('Total transactions cannot be zero');
return (productPurchases / totalTransactions) * 100;
}
liftValue(jointPurchaseRate: number, productAPurchaseRate: number, productBPurchaseRate: number): number {
const expectedJointRate = productAPurchaseRate * productBPurchaseRate;
if (expectedJointRate === 0) throw new Error('Expected joint rate cannot be zero');
return jointPurchaseRate / expectedJointRate;
}
costRatio(cost: number, salePrice: number): number {
if (salePrice === 0) throw new Error('Sale price cannot be zero');
return cost / salePrice;
}
grossMarginRate(salePrice: number, cost: number): number {
if (salePrice === 0) throw new Error('Sale price cannot be zero');
return (salePrice - cost) / salePrice;
}
averageSpendPerCustomer(totalRevenue: number, numberOfCustomers: number): number {
if (numberOfCustomers === 0) {
throw new Error('Number of customers cannot be zero');
}
return totalRevenue / numberOfCustomers;
}
purchaseIndex(totalItemsSold: number, numberOfCustomers: number): number {
if (numberOfCustomers === 0) {
throw new Error('Number of customers cannot be zero');
}
return (totalItemsSold / numberOfCustomers) * 1000;
}
// ========================================
// Prediction functions
// ========================================
timeSeriesForecast(series: DataSeries, forecastPeriods: number): ForecastResult {
validateSeries(series);
const model = calculateLinearRegression(series.values);
const forecast = generateForecast(model, series.values.length, forecastPeriods);
const predictionIntervals = calculatePredictionIntervals(series.values, model, forecast);
return {
forecast,
predictionIntervals,
modelParameters: {
slope: model.slope,
intercept: model.intercept,
},
};
}
}
// Initialize analytics engine
const analytics = new AnalyticsEngine();
// ========================================
// API ROUTES
// ========================================
@ -479,45 +778,6 @@ app.post('/api/correlation', (req, res) => {
}
});
/**
* @swagger
* /api/pivot-table:
* post:
* summary: Generate a pivot table from records
* description: Returns a pivot table based on the provided data and options
* tags: [Data Transformation]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: array
* items:
* type: object
* description: Array of records to pivot
* options:
* $ref: '#/components/schemas/PivotOptions'
* responses:
* '200':
* description: Pivot table generated successfully
* '400':
* description: Invalid input data
*/
app.post('/api/pivot-table', (req, res) => {
try {
const { data, options } = req.body;
// You can pass analytics.mean, analytics.count, etc. as options.aggFunc if needed
const result = pivotTable(data, options);
res.status(200).json({ success: true, data: result });
} catch (error) {
const errorMessage = handleError(error);
res.status(400).json({ success: false, error: errorMessage });
}
});
/**
* @swagger
* /api/series/moving-average:
@ -594,159 +854,6 @@ app.post('/api/series/rolling', (req, res) => {
}
});
/**
* @swagger
* /api/series/auto-arima-find:
* post:
* summary: (EXPERIMENTAL) Automatically find best SARIMA parameters
* description: Performs a grid search to find the best SARIMA parameters based on AIC. NOTE - This is a simplified estimation and may not find the true optimal model. For best results, use the identification tools and the 'manual-forecast' endpoint.
* tags: [Series Operations]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* series:
* $ref: '#/components/schemas/DataSeries'
* seasonalPeriod:
* type: integer
* description: The seasonal period of the data (e.g., 7 for weekly).
* example: 7
* responses:
* '200':
* description: The best model found and the search log.
* '400':
* description: Invalid input data.
*/
app.post('/api/series/auto-arima-find', (req, res) => {
try {
const { series, seasonalPeriod } = req.body;
validateSeries(series);
const result = AnalysisPipelines.findBestArimaParameters(series.values, seasonalPeriod);
res.status(200).json({ success: true, data: result });
} catch (error) {
const errorMessage = handleError(error);
res.status(400).json({ success: false, error: errorMessage });
}
});
/**
* @swagger
* /api/series/manual-forecast:
* post:
* summary: Generate a forecast with manually specified SARIMA parameters
* description: This is the primary forecasting tool. It allows an expert user (who has analyzed ACF/PACF plots) to apply a specific SARIMA model to a time series and generate a forecast.
* tags: [Series Operations]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* series:
* $ref: '#/components/schemas/DataSeries'
* options:
* $ref: '#/components/schemas/ARIMAOptions'
* forecastSteps:
* type: integer
* description: The number of future time steps to predict.
* example: 7
* responses:
* '200':
* description: The forecast results.
* '400':
* description: Invalid input data
*/
app.post('/api/series/manual-forecast', (req, res) => {
try {
const { series, options, forecastSteps } = req.body;
validateSeries(series);
const result = TimeSeriesAnalyzer.arimaForecast(series.values, options, forecastSteps);
res.status(200).json({ success: true, data: result });
} catch (error) {
const errorMessage = handleError(error);
res.status(400).json({ success: false, error: errorMessage });
}
});
/**
* @swagger
* /api/series/identify-correlations:
* post:
* summary: Calculate ACF and PACF for a time series
* description: Returns the Autocorrelation and Partial Autocorrelation function values, which are essential for identifying SARIMA model parameters.
* tags: [Series Operations]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* series:
* $ref: '#/components/schemas/DataSeries'
* maxLag:
* type: integer
* description: The maximum number of lags to calculate.
* example: 40
* responses:
* '200':
* description: The calculated ACF and PACF values.
* '400':
* description: Invalid input data.
*/
app.post('/api/series/identify-correlations', (req, res) => {
try {
const { series, maxLag } = req.body;
validateSeries(series);
const acf = TimeSeriesAnalyzer.calculateACF(series.values, maxLag);
const pacf = TimeSeriesAnalyzer.calculatePACF(series.values, maxLag);
res.status(200).json({ success: true, data: { acf, pacf } });
} catch (error) {
res.status(400).json({ success: false, error: handleError(error) });
}
});
/**
* @swagger
* /api/series/decompose-stl:
* post:
* summary: Decompose a time series into components
* description: Applies Seasonal-Trend-Loess (STL) decomposition to separate the series into trend, seasonal, and residual components.
* tags: [Series Operations]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* series:
* $ref: '#/components/schemas/DataSeries'
* period:
* type: integer
* description: The seasonal period of the data (e.g., 7 for weekly).
* example: 7
* responses:
* '200':
* description: The decomposed components of the time series.
* '400':
* description: Invalid input data.
*/
app.post('/api/series/decompose-stl', (req, res) => {
try {
const { series, period } = req.body;
validateSeries(series);
const result = TimeSeriesAnalyzer.stlDecomposition(series.values, period);
res.status(200).json({ success: true, data: result });
} catch (error) {
res.status(400).json({ success: false, error: handleError(error) });
}
});
/**
* @swagger
* /api/ml/kmeans:
@ -889,7 +996,7 @@ app.post('/api/time/same-day-last-year', (req, res) => {
*/
app.post('/api/retail/purchase-rate', (req, res) => {
try {
const result = purchaseRate(req.body.productPurchases, req.body.totalTransactions);
const result = analytics.purchaseRate(req.body.productPurchases, req.body.totalTransactions);
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
} catch (error) {
const errorMessage = handleError(error);
@ -931,7 +1038,7 @@ app.post('/api/retail/purchase-rate', (req, res) => {
*/
app.post('/api/retail/lift-value', (req, res) => {
try {
const result = liftValue(req.body.jointPurchaseRate, req.body.productAPurchaseRate, req.body.productBPurchaseRate);
const result = analytics.liftValue(req.body.jointPurchaseRate, req.body.productAPurchaseRate, req.body.productBPurchaseRate);
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
} catch (error) {
const errorMessage = handleError(error);
@ -969,7 +1076,7 @@ app.post('/api/retail/lift-value', (req, res) => {
*/
app.post('/api/retail/cost-ratio', (req, res) => {
try {
const result = costRatio(req.body.cost, req.body.salePrice);
const result = analytics.costRatio(req.body.cost, req.body.salePrice);
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
} catch (error) {
const errorMessage = handleError(error);
@ -1007,7 +1114,7 @@ app.post('/api/retail/cost-ratio', (req, res) => {
*/
app.post('/api/retail/gross-margin', (req, res) => {
try {
const result = grossMarginRate(req.body.salePrice, req.body.cost);
const result = analytics.grossMarginRate(req.body.salePrice, req.body.cost);
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
} catch (error) {
const errorMessage = handleError(error);
@ -1046,7 +1153,7 @@ app.post('/api/retail/gross-margin', (req, res) => {
app.post('/api/retail/average-spend', (req, res) => {
try {
const { totalRevenue, numberOfCustomers } = req.body;
const result = averageSpendPerCustomer(totalRevenue, numberOfCustomers);
const result = analytics.averageSpendPerCustomer(totalRevenue, numberOfCustomers);
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
} catch (error) {
const errorMessage = handleError(error);
@ -1085,7 +1192,7 @@ app.post('/api/retail/average-spend', (req, res) => {
app.post('/api/retail/purchase-index', (req, res) => {
try {
const { totalItemsSold, numberOfCustomers } = req.body;
const result = purchaseIndex(totalItemsSold, numberOfCustomers);
const result = analytics.purchaseIndex(totalItemsSold, numberOfCustomers);
res.status(200).json({ success: true, data: result } as ApiResponse<number>);
} catch (error) {
const errorMessage = handleError(error);
@ -1541,53 +1648,6 @@ app.get('/api/kernels/:name', (req, res) => {
* type: number
* default: 0.1
* description: The sensitivity threshold for detecting an edge. Values below this will be set to 0.
* ARIMAOptions:
* type: object
* properties:
* p:
* type: integer
* description: Non-seasonal AutoRegressive (AR) order.
* d:
* type: integer
* description: Non-seasonal Differencing (I) order.
* q:
* type: integer
* description: Non-seasonal Moving Average (MA) order.
* P:
* type: integer
* description: Seasonal AR order.
* D:
* type: integer
* description: Seasonal Differencing order.
* Q:
* type: integer
* description: Seasonal MA order.
* s:
* type: integer
* description: The seasonal period length (e.g., 7 for weekly).
* PivotOptions:
* type: object
* required:
* - index
* - columns
* - values
* properties:
* index:
* type: array
* items:
* type: string
* description: Keys to use as row labels
* columns:
* type: array
* items:
* type: string
* description: Keys to use as column labels
* values:
* type: string
* description: Key to aggregate
* aggFunc:
* type: string
* description: Aggregation function name (e.g., "sum", "mean", "count")
* ApiResponse:
* type: object
* properties:
@ -1720,7 +1780,7 @@ app.get('/api/docs/export/html', (req, res) => {
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({ success: false, error: 'Internal server error' });
res.status(500).json({ success: false, error: 'Internal server error' } as ApiResponse<any>);
});
app.use('*', (req, res) => {
@ -1732,8 +1792,9 @@ app.use('*', (req, res) => {
// ========================================
app.listen(PORT, () => {
console.log(`Analytics API server running on port ${PORT}`);
console.log(`API Documentation: http://localhost:${PORT}/api-docs`);
console.log(`Analytics API server running on port ${PORT}`);
console.log(`Health check: http://localhost:${PORT}/api/health`);
console.log(`API Documentation: http://localhost:${PORT}/api-docs`);
});
export default app;

View file

@ -1,133 +0,0 @@
// analysis_pipelines.ts - High-level workflows for common analysis tasks.
import { SignalProcessor } from './signal_processing_convolution';
import { TimeSeriesAnalyzer, STLDecomposition } from './timeseries';
/**
* The comprehensive result of a denoise and detrend operation.
*/
export interface DenoiseAndDetrendResult {
original: number[];
smoothed: number[];
decomposition: STLDecomposition;
}
/**
* The result of an automatic SARIMA parameter search.
*/
export interface AutoArimaResult {
bestModel: {
p: number;
d: number;
q: number;
P: number;
D: number;
Q: number;
s: number;
aic: number;
};
searchLog: { p: number; d: number; q: number; P: number; D: number; Q: number; s: number; aic: number }[];
}
/**
* A class containing high-level analysis pipelines that combine
* functions from various processing libraries.
*/
export class AnalysisPipelines {
/**
* A full pipeline to take a raw signal, smooth it to remove noise,
* and then decompose it into trend, seasonal, and residual components.
* @param series The original time series data.
* @param period The seasonal period for STL decomposition.
* @param smoothWindow The window size for the initial smoothing (denoising) pass.
* @returns An object containing the original, smoothed, and decomposed series.
*/
static denoiseAndDetrend(series: number[], period: number, smoothWindow: number = 5): DenoiseAndDetrendResult {
// Ensure window is odd for symmetry
if (smoothWindow > 1 && smoothWindow % 2 === 0) {
smoothWindow++;
}
const smoothed = SignalProcessor.smooth(series, {
method: 'gaussian',
windowSize: smoothWindow
});
const decomposition = TimeSeriesAnalyzer.stlDecomposition(smoothed, period);
return {
original: series,
smoothed: smoothed,
decomposition: decomposition,
};
}
/**
* [FINAL CORRECTED VERSION] Performs a full grid search to find the optimal SARIMA parameters.
* This version now correctly includes 's' in the final result object.
* @param series The original time series data.
* @param seasonalPeriod The seasonal period of the data (e.g., 7 for weekly, 12 for monthly).
* @returns An object containing the best model parameters and a log of the search.
*/
static findBestArimaParameters(
series: number[],
seasonalPeriod: number,
maxD: number = 1,
maxP: number = 2,
maxQ: number = 2,
maxSeasonalD: number = 1,
maxSeasonalP: number = 2,
maxSeasonalQ: number = 2
): AutoArimaResult {
const searchLog: any[] = [];
let bestModel: any = { aic: Infinity };
const calculateAIC = (residuals: number[], numParams: number): number => {
const n = residuals.length;
if (n === 0) return Infinity;
const sse = residuals.reduce((sum, r) => sum + r * r, 0);
if (sse < 1e-9) return -Infinity; // Perfect fit
const logLikelihood = -n / 2 * (Math.log(2 * Math.PI) + Math.log(sse / n)) - n / 2;
return 2 * numParams - 2 * logLikelihood;
};
// Grid search over all parameter combinations
for (let d = 0; d <= maxD; d++) {
for (let p = 0; p <= maxP; p++) {
for (let q = 0; q <= maxQ; q++) {
for (let D = 0; D <= maxSeasonalD; D++) {
for (let P = 0; P <= maxSeasonalP; P++) {
for (let Q = 0; Q <= maxSeasonalQ; Q++) {
// Skip trivial models where nothing is done
if (p === 0 && d === 0 && q === 0 && P === 0 && D === 0 && Q === 0) continue;
const options = { p, d, q, P, D, Q, s: seasonalPeriod };
try {
const { residuals } = TimeSeriesAnalyzer.arimaForecast(series, options, 0);
const numParams = p + q + P + Q;
const aic = calculateAIC(residuals, numParams);
// Construct the full model info object, ensuring 's' is included
const modelInfo = { p, d, q, P, D, Q, s: seasonalPeriod, aic };
searchLog.push(modelInfo);
if (modelInfo.aic < bestModel.aic) {
bestModel = modelInfo;
}
} catch (error) {
// Skip invalid parameter combinations that cause errors
}
} } } } } }
if (bestModel.aic === Infinity) {
throw new Error("Could not find a suitable SARIMA model. The data may be too short or complex.");
}
// Sort the log by AIC for easier reading
searchLog.sort((a, b) => a.aic - b.aic);
return { bestModel, searchLog };
}
}

View file

@ -1,208 +0,0 @@
import * as math from 'mathjs';
import * as _ from 'lodash';
import { DataSeries, DataMatrix, Condition, ApiResponse } from '../types/index';
import { RollingWindow } from './rolling_window';
import { KMeans, KMeansOptions } from './kmeans';
import { getWeekNumber, getSameWeekDayLastYear } from './time-helper';
import { calculateLinearRegression, generateForecast, calculatePredictionIntervals, ForecastResult } from './prediction';
export const handleError = (error: unknown): string => {
return error instanceof Error ? error.message : 'Unknown error';
};
export const validateSeries = (series: DataSeries): void => {
if (!series || !Array.isArray(series.values) || series.values.length === 0) {
throw new Error('Series must contain at least one value');
}
};
export const validateMatrix = (matrix: DataMatrix): void => {
if (!matrix || !Array.isArray(matrix.data) || matrix.data.length === 0) {
throw new Error('Matrix must contain at least one row');
}
};
export class AnalyticsEngine {
private applyConditions(series: DataSeries, conditions: Condition[] = []): number[] {
if (conditions.length === 0) return series.values;
return series.values; // TODO: Implement filtering
}
// Basic statistical functions
unique(series: DataSeries): number[] {
validateSeries(series);
return _.uniq(series.values);
}
mean(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Number(math.mean(filteredValues));
}
count(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return filteredValues.length;
}
distinctCount(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
const uniqueValues = _.uniq(filteredValues);
return uniqueValues.length;
}
variance(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Number(math.variance(filteredValues));
}
standardDeviation(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Number(math.std(filteredValues));
}
percentile(series: DataSeries, percent: number, ascending: boolean = true, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
const sorted = ascending ? _.sortBy(filteredValues) : _.sortBy(filteredValues).reverse();
const index = (percent / 100) * (sorted.length - 1);
const lower = Math.floor(index);
const upper = Math.ceil(index);
const weight = index % 1;
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
}
median(series: DataSeries, conditions: Condition[] = []): number {
return this.percentile(series, 50, true, conditions);
}
mode(series: DataSeries, conditions: Condition[] = []): number[] {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
const frequency = _.countBy(filteredValues);
const maxFreq = Math.max(...Object.values(frequency));
return Object.keys(frequency)
.filter(key => frequency[key] === maxFreq)
.map(Number);
}
max(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Math.max(...filteredValues);
}
min(series: DataSeries, conditions: Condition[] = []): number {
validateSeries(series);
const filteredValues = this.applyConditions(series, conditions);
if (filteredValues.length === 0) throw new Error('No data points match conditions');
return Math.min(...filteredValues);
}
correlation(series1: DataSeries, series2: DataSeries): number {
validateSeries(series1);
validateSeries(series2);
if (series1.values.length !== series2.values.length) {
throw new Error('Series must have same length for correlation');
}
const x = series1.values;
const y = series2.values;
const n = x.length;
const sumX = _.sum(x);
const sumY = _.sum(y);
const sumXY = _.sum(x.map((xi, i) => xi * y[i]));
const sumX2 = _.sum(x.map(xi => xi * xi));
const sumY2 = _.sum(y.map(yi => yi * yi));
const numerator = n * sumXY - sumX * sumY;
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
return numerator / denominator;
}
// Rolling window functions
rolling(series: DataSeries, windowSize: number): RollingWindow {
validateSeries(series);
if (windowSize <= 0) {
throw new Error('Window size must be a positive number.');
}
if (series.values.length < windowSize) {
return new RollingWindow([]);
}
const windows: number[][] = [];
for (let i = 0; i <= series.values.length - windowSize; i++) {
const window = series.values.slice(i, i + windowSize);
windows.push(window);
}
return new RollingWindow(windows);
}
movingAverage(series: DataSeries, windowSize: number): number[] {
return this.rolling(series, windowSize).mean();
}
// K-means wrapper (uses imported KMeans class)
kmeans(matrix: DataMatrix, nClusters: number, options: KMeansOptions = {}): { clusters: number[][][], centroids: number[][] } {
validateMatrix(matrix);
const points: number[][] = matrix.data;
// Use the new MiniBatchKMeans class
const kmeans = new KMeans(points, nClusters, options);
const result = kmeans.run();
const centroids = result.clusters.map(c => (c as any).centroid);
const clusters = result.clusters.map(c => (c as any).points);
return { clusters, centroids };
}
// Time helper wrapper functions
getWeekNumber(dateString: string): number {
return getWeekNumber(dateString);
}
getSameWeekDayLastYear(dateString: string): string {
return getSameWeekDayLastYear(dateString);
}
// ========================================
// Prediction functions
// ========================================
timeSeriesForecast(series: DataSeries, forecastPeriods: number): ForecastResult {
validateSeries(series);
const model = calculateLinearRegression(series.values);
const forecast = generateForecast(model, series.values.length, forecastPeriods);
const predictionIntervals = calculatePredictionIntervals(series.values, model, forecast);
return {
forecast,
predictionIntervals,
modelParameters: {
slope: model.slope,
intercept: model.intercept,
},
};
}
}
export const analytics = new AnalyticsEngine();

View file

@ -1,36 +0,0 @@
import { analytics } from './analytics_engine'; // Import your analytics engine
export interface PivotOptions {
index: string[];
columns: string[];
values: string;
aggFunc?: (items: number[]) => number; // Aggregation function (e.g., analytics.mean)
}
export function pivotTable(
data: Record<string, any>[],
options: PivotOptions
): Record<string, Record<string, number>> {
const { index, columns, values, aggFunc = arr => arr.reduce((a, b) => a + b, 0) } = options;
const cellMap: Record<string, Record<string, number[]>> = {};
data.forEach(row => {
const rowKey = index.map(k => row[k]).join('|');
const colKey = columns.map(k => row[k]).join('|');
if (!cellMap[rowKey]) cellMap[rowKey] = {};
if (!cellMap[rowKey][colKey]) cellMap[rowKey][colKey] = [];
cellMap[rowKey][colKey].push(row[values]);
});
// Apply aggregation function to each cell
const result: Record<string, Record<string, number>> = {};
Object.entries(cellMap).forEach(([rowKey, cols]) => {
result[rowKey] = {};
Object.entries(cols).forEach(([colKey, valuesArr]) => {
result[rowKey][colKey] = aggFunc(valuesArr);
});
});
return result;
}

View file

@ -1,77 +0,0 @@
export function purchaseIndex(totalItemsSold: number, numberOfCustomers: number): number {
if (numberOfCustomers === 0) {
throw new Error('Number of customers cannot be zero');
}
return (totalItemsSold / numberOfCustomers) * 1000;
}
export function purchaseRate(productPurchases: number, totalTransactions: number): number;
export function purchaseRate(productPurchases: number[], totalTransactions: number[]): number[];
export function purchaseRate(productPurchases: number | number[], totalTransactions: number | number[]): number | number[] {
if (Array.isArray(productPurchases) && Array.isArray(totalTransactions)) {
if (productPurchases.length !== totalTransactions.length) throw new Error('Arrays must have the same length');
return productPurchases.map((pp, i) => purchaseRate(pp, totalTransactions[i]));
}
if (typeof productPurchases === 'number' && typeof totalTransactions === 'number') {
if (totalTransactions === 0) throw new Error('Total transactions cannot be zero');
return (productPurchases / totalTransactions) * 100;
}
throw new Error('Input types must match');
}
export function liftValue(jointPurchaseRate: number, productAPurchaseRate: number, productBPurchaseRate: number): number;
export function liftValue(jointPurchaseRate: number[], productAPurchaseRate: number[], productBPurchaseRate: number[]): number[];
export function liftValue(jointPurchaseRate: number | number[], productAPurchaseRate: number | number[], productBPurchaseRate: number | number[]): number | number[] {
if (Array.isArray(jointPurchaseRate) && Array.isArray(productAPurchaseRate) && Array.isArray(productBPurchaseRate)) {
if (jointPurchaseRate.length !== productAPurchaseRate.length || jointPurchaseRate.length !== productBPurchaseRate.length) throw new Error('Arrays must have the same length');
return jointPurchaseRate.map((jpr, i) => liftValue(jpr, productAPurchaseRate[i], productBPurchaseRate[i]));
}
if (typeof jointPurchaseRate === 'number' && typeof productAPurchaseRate === 'number' && typeof productBPurchaseRate === 'number') {
const expectedJointRate = productAPurchaseRate * productBPurchaseRate;
if (expectedJointRate === 0) throw new Error('Expected joint rate cannot be zero');
return jointPurchaseRate / expectedJointRate;
}
throw new Error('Input types must match');
}
export function costRatio(cost: number, salePrice: number): number;
export function costRatio(cost: number[], salePrice: number[]): number[];
export function costRatio(cost: number | number[], salePrice: number | number[]): number | number[] {
if (Array.isArray(cost) && Array.isArray(salePrice)) {
if (cost.length !== salePrice.length) throw new Error('Arrays must have the same length');
return cost.map((c, i) => costRatio(c, salePrice[i]));
}
if (typeof cost === 'number' && typeof salePrice === 'number') {
if (salePrice === 0) throw new Error('Sale price cannot be zero');
return cost / salePrice;
}
throw new Error('Input types must match');
}
export function grossMarginRate(salePrice: number, cost: number): number;
export function grossMarginRate(salePrice: number[], cost: number[]): number[];
export function grossMarginRate(salePrice: number | number[], cost: number | number[]): number | number[] {
if (Array.isArray(salePrice) && Array.isArray(cost)) {
if (salePrice.length !== cost.length) throw new Error('Arrays must have the same length');
return salePrice.map((sp, i) => grossMarginRate(sp, cost[i]));
}
if (typeof salePrice === 'number' && typeof cost === 'number') {
if (salePrice === 0) throw new Error('Sale price cannot be zero');
return (salePrice - cost) / salePrice;
}
throw new Error('Input types must match');
}
export function averageSpendPerCustomer(totalRevenue: number, numberOfCustomers: number): number;
export function averageSpendPerCustomer(totalRevenue: number[], numberOfCustomers: number[]): number[];
export function averageSpendPerCustomer(totalRevenue: number | number[], numberOfCustomers: number | number[]): number | number[] {
if (Array.isArray(totalRevenue) && Array.isArray(numberOfCustomers)) {
if (totalRevenue.length !== numberOfCustomers.length) throw new Error('Arrays must have the same length');
return totalRevenue.map((tr, i) => averageSpendPerCustomer(tr, numberOfCustomers[i]));
}
if (typeof totalRevenue === 'number' && typeof numberOfCustomers === 'number') {
if (numberOfCustomers === 0) throw new Error('Number of customers cannot be zero');
return totalRevenue / numberOfCustomers;
}
throw new Error('Input types must match');
}

View file

@ -1,30 +0,0 @@
import * as math from 'mathjs';
import * as _ from 'lodash';
export class RollingWindow {
private windows: number[][];
constructor(windows: number[][]) {
this.windows = windows;
}
mean(): number[] {
return this.windows.map(window => Number(math.mean(window)));
}
sum(): number[] {
return this.windows.map(window => _.sum(window));
}
min(): number[] {
return this.windows.map(window => Math.min(...window));
}
max(): number[] {
return this.windows.map(window => Math.max(...window));
}
toArray(): number[][] {
return this.windows;
}
}

View file

@ -1,346 +0,0 @@
// timeseries.ts - A library for time series analysis, focusing on ARIMA.
// ========================================
// TYPE DEFINITIONS
// ========================================
/**
* Defines the parameters for an ARIMA model.
* (p, d, q) are the non-seasonal components.
* (P, D, Q, s) are the optional seasonal components for SARIMA.
*/
export interface ARIMAOptions {
p: number; // AutoRegressive (AR) order
d: number; // Differencing (I) order
q: number; // Moving Average (MA) order
P?: number; // Seasonal AR order
D?: number; // Seasonal Differencing order
Q?: number; // Seasonal MA order
s?: number; // Seasonal period length
}
/**
* The result object from an ARIMA forecast.
*/
export interface ARIMAForecastResult {
forecast: number[]; // The predicted future values
residuals: number[]; // The errors of the model fit on the original data
model: ARIMAOptions; // The model parameters used
}
/**
* The result object from an STL decomposition.
*/
export interface STLDecomposition {
seasonal: number[]; // The seasonal component of the series
trend: number[]; // The trend component of the series
residual: number[]; // The remainder/residual component
original: number[]; // The original series, for comparison
}
/**
* A class for performing time series analysis, including identification and forecasting.
*/
export class TimeSeriesAnalyzer {
// ========================================
// 1. IDENTIFICATION METHODS
// ========================================
/**
* Calculates the difference of a time series.
* This is the 'I' (Integrated) part of ARIMA, used to make a series stationary.
* @param series The input data series.
* @param lag The lag to difference by (usually 1).
* @returns A new, differenced time series.
*/
static difference(series: number[], lag: number = 1): number[] {
if (lag < 1 || !Number.isInteger(lag)) {
throw new Error('Lag must be a positive integer.');
}
if (series.length <= lag) {
return [];
}
const differenced: number[] = [];
for (let i = lag; i < series.length; i++) {
differenced.push(series[i] - series[i - lag]);
}
return differenced;
}
/**
* Helper function to calculate the autocovariance of a series at a given lag.
*/
private static autocovariance(series: number[], lag: number): number {
const n = series.length;
if (lag >= n) return 0;
const mean = series.reduce((a, b) => a + b) / n;
let sum = 0;
for (let i = lag; i < n; i++) {
sum += (series[i] - mean) * (series[i - lag] - mean);
}
return sum / n;
}
/**
* Calculates the Autocorrelation Function (ACF) for a time series.
* ACF helps in determining the 'q' parameter for an ARIMA model.
* @param series The input data series.
* @param maxLag The maximum number of lags to calculate.
* @returns An array of correlation values from lag 1 to maxLag.
*/
static calculateACF(series: number[], maxLag: number): number[] {
if (series.length < 2) return [];
const variance = this.autocovariance(series, 0);
if (variance === 0) {
return new Array(maxLag).fill(1);
}
const acf: number[] = [];
for (let lag = 1; lag <= maxLag; lag++) {
acf.push(this.autocovariance(series, lag) / variance);
}
return acf;
}
/**
* Calculates the Partial Autocorrelation Function (PACF) for a time series.
* This now uses the Durbin-Levinson algorithm for an accurate calculation.
* PACF helps in determining the 'p' parameter for an ARIMA model.
* @param series The input data series.
* @param maxLag The maximum number of lags to calculate.
* @returns An array of partial correlation values from lag 1 to maxLag.
*/
static calculatePACF(series: number[], maxLag: number): number[] {
const acf = this.calculateACF(series, maxLag);
const pacf: number[] = [];
if (acf.length === 0) return [];
pacf.push(acf[0]); // PACF at lag 1 is the same as ACF at lag 1
for (let k = 2; k <= maxLag; k++) {
let numerator = acf[k - 1];
let denominator = 1;
const phi = new Array(k + 1).fill(0).map(() => new Array(k + 1).fill(0));
for(let i=1; i<=k; i++) {
phi[i][i] = acf[i-1];
}
for (let j = 1; j < k; j++) {
const factor = pacf[j - 1];
numerator -= factor * acf[k - j - 1];
denominator -= factor * acf[j - 1];
}
if (Math.abs(denominator) < 1e-9) { // Avoid division by zero
pacf.push(0);
continue;
}
const pacf_k = numerator / denominator;
pacf.push(pacf_k);
}
return pacf;
}
/**
* Decomposes a time series using the robust Classical Additive method.
* This version correctly isolates trend, seasonal, and residual components.
* @param series The input data series.
* @param period The seasonal period (e.g., 7 for daily data with a weekly cycle).
* @returns An object containing the seasonal, trend, and residual series.
*/
static stlDecomposition(series: number[], period: number): STLDecomposition {
if (series.length < 2 * period) {
throw new Error("Series must be at least twice the length of the seasonal period.");
}
// Helper for a centered moving average
const movingAverage = (data: number[], window: number) => {
const result = [];
const halfWindow = Math.floor(window / 2);
for (let i = 0; i < data.length; i++) {
const start = Math.max(0, i - halfWindow);
const end = Math.min(data.length, i + halfWindow + 1);
let sum = 0;
for (let j = start; j < end; j++) {
sum += data[j];
}
result.push(sum / (end - start));
}
return result;
};
// Step 1: Calculate the trend using a centered moving average.
// If period is even, we use a 2x-MA to center it correctly.
let trend: number[];
if (period % 2 === 0) {
const intermediate = movingAverage(series, period);
trend = movingAverage(intermediate, 2);
} else {
trend = movingAverage(series, period);
}
// Step 2: Detrend the series
const detrended = series.map((val, i) => val - trend[i]);
// Step 3: Calculate the seasonal component by averaging the detrended values for each period
const seasonalAverages = new Array(period).fill(0);
const seasonalCounts = new Array(period).fill(0);
for (let i = 0; i < series.length; i++) {
if (!isNaN(detrended[i])) {
const seasonIndex = i % period;
seasonalAverages[seasonIndex] += detrended[i];
seasonalCounts[seasonIndex]++;
}
}
for (let i = 0; i < period; i++) {
seasonalAverages[i] /= seasonalCounts[i];
}
// Center the seasonal component to have a mean of zero
const seasonalMean = seasonalAverages.reduce((a, b) => a + b, 0) / period;
const centeredSeasonalAverages = seasonalAverages.map(avg => avg - seasonalMean);
const seasonal = new Array(series.length).fill(0);
for (let i = 0; i < series.length; i++) {
seasonal[i] = centeredSeasonalAverages[i % period];
}
// Step 4: Calculate the residual component
const residual = detrended.map((val, i) => val - seasonal[i]);
return {
original: series,
seasonal,
trend,
residual,
};
}
// ========================================
// 2. FORECASTING METHODS
// ========================================
/**
* [UPGRADED] Generates a forecast using a simplified SARIMA model.
* This implementation now handles both non-seasonal (p,d,q) and seasonal (P,D,Q,s) components.
* @param series The input time series data.
* @param options The SARIMA parameters.
* @param forecastSteps The number of future steps to predict.
* @returns An object containing the forecast and model residuals.
*/
static arimaForecast(series: number[], options: ARIMAOptions, forecastSteps: number): ARIMAForecastResult {
const { p, d, q, P = 0, D = 0, Q = 0, s = 0 } = options;
if (series.length < p + d + (P + D) * s + q + Q * s) {
throw new Error("Data series is too short for the specified SARIMA order.");
}
const originalSeries = [...series];
let differencedSeries = [...series];
const diffLog: { lag: number, values: number[] }[] = [];
// Step 1: Apply seasonal differencing 'D' times
for (let i = 0; i < D; i++) {
diffLog.push({ lag: s, values: differencedSeries.slice(-s) });
differencedSeries = this.difference(differencedSeries, s);
}
// Step 2: Apply non-seasonal differencing 'd' times
for (let i = 0; i < d; i++) {
diffLog.push({ lag: 1, values: differencedSeries.slice(-1) });
differencedSeries = this.difference(differencedSeries, 1);
}
const n = differencedSeries.length;
// Simplified coefficients
const arCoeffs = p > 0 ? new Array(p).fill(1 / p) : [];
const maCoeffs = q > 0 ? new Array(q).fill(1 / q) : [];
const sarCoeffs = P > 0 ? new Array(P).fill(1 / P) : [];
const smaCoeffs = Q > 0 ? new Array(Q).fill(1 / Q) : [];
const residuals: number[] = new Array(n).fill(0);
const fitted: number[] = new Array(n).fill(0);
// Step 3: Fit the model
const startIdx = Math.max(p, q, P * s, Q * s);
for (let t = startIdx; t < n; t++) {
// Non-seasonal AR
let arVal = 0;
for (let i = 0; i < p; i++) arVal += arCoeffs[i] * differencedSeries[t - 1 - i];
// Non-seasonal MA
let maVal = 0;
for (let i = 0; i < q; i++) maVal += maCoeffs[i] * residuals[t - 1 - i];
// Seasonal AR
let sarVal = 0;
for (let i = 0; i < P; i++) sarVal += sarCoeffs[i] * differencedSeries[t - s * (i + 1)];
// Seasonal MA
let smaVal = 0;
for (let i = 0; i < Q; i++) smaVal += smaCoeffs[i] * residuals[t - s * (i + 1)];
fitted[t] = arVal + maVal + sarVal + smaVal;
residuals[t] = differencedSeries[t] - fitted[t];
}
// Step 4: Generate the forecast
const forecastDifferenced: number[] = [];
const extendedSeries = [...differencedSeries];
const extendedResiduals = [...residuals];
for (let f = 0; f < forecastSteps; f++) {
const t = n + f;
let nextForecast = 0;
// AR
for (let i = 0; i < p; i++) nextForecast += arCoeffs[i] * extendedSeries[t - 1 - i];
// MA (future residuals are 0)
for (let i = 0; i < q; i++) nextForecast += maCoeffs[i] * extendedResiduals[t - 1 - i];
// SAR
for (let i = 0; i < P; i++) nextForecast += sarCoeffs[i] * extendedSeries[t - s * (i + 1)];
// SMA
for (let i = 0; i < Q; i++) nextForecast += smaCoeffs[i] * extendedResiduals[t - s * (i + 1)];
forecastDifferenced.push(nextForecast);
extendedSeries.push(nextForecast);
extendedResiduals.push(0);
}
// Step 5: Invert the differencing
let forecast = [...forecastDifferenced];
for (let i = diffLog.length - 1; i >= 0; i--) {
const { lag, values } = diffLog[i];
const inverted = [];
const fullHistory = [...originalSeries, ...forecast]; // Need a temporary full history for inversion
// A simpler inversion method for forecasting
let history = [...series];
for (const forecastVal of forecast) {
const lastSeasonalVal = history[history.length - lag];
const invertedVal = forecastVal + lastSeasonalVal;
inverted.push(invertedVal);
history.push(invertedVal);
}
forecast = inverted;
}
return {
forecast,
residuals,
model: options,
};
}
}

View file

@ -1,21 +0,0 @@
import { analytics } from '../services/analytics_engine';
describe('AnalyticsEngine', () => {
test('mean returns correct average', () => {
const series = { values: [1, 2, 3, 4, 5] };
const result = analytics.mean(series);
expect(result).toBe(3);
});
test('max returns correct maximum', () => {
const series = { values: [1, 2, 3, 4, 5] };
const result = analytics.max(series);
expect(result).toBe(5);
});
test('min returns correct minimum', () => {
const series = { values: [1, 2, 3, 4, 5] };
const result = analytics.min(series);
expect(result).toBe(1);
});
});

View file

@ -1,3 +1,5 @@
// time-helpers.ts - Date and time utility functions
import { getISOWeek, getISODay, subYears, setISOWeek, setISODay, isValid } from 'date-fns';
export const getWeekNumber = (dateString: string): number => {

View file

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./"
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,22 +0,0 @@
export interface DataSeries {
values: number[];
labels?: string[];
}
export interface DataMatrix {
data: number[][];
columns?: string[];
rows?: string[];
}
export interface Condition {
field: string;
operator: '>' | '<' | '=' | '>=' | '<=' | '!=';
value: number | string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}