diff --git a/api-documentation.html b/api-documentation.html new file mode 100644 index 0000000..6da21b0 --- /dev/null +++ b/api-documentation.html @@ -0,0 +1,46 @@ + + + + + + + API Documentation + + + + +
+ + + + + \ No newline at end of file diff --git a/services/convolution.ts b/convolution.ts similarity index 100% rename from services/convolution.ts rename to convolution.ts diff --git a/services/kmeans.ts b/kmeans.ts similarity index 97% rename from services/kmeans.ts rename to kmeans.ts index a0ae502..12b85e2 100644 --- a/services/kmeans.ts +++ b/kmeans.ts @@ -1,144 +1,144 @@ -export type Point = number[]; - -export interface Cluster { - centroid: Point; - points: Point[]; -} - -export interface KMeansOptions { - batchSize?: number; - maxIterations?: number; - tolerance?: number; -} - -export interface KMeansResult { - clusters: Cluster[]; - iterations: number; - converged: boolean; -} - -export class KMeans { - private readonly k: number; - private readonly batchSize: number; - private readonly maxIterations: number; - private readonly tolerance: number; - private readonly data: Point[]; - private centroids: Point[] = []; - - constructor(data: Point[], k: number, options: KMeansOptions = {}) { - this.data = data; - this.k = k; - this.batchSize = options.batchSize ?? 32; - this.maxIterations = options.maxIterations ?? 100; - this.tolerance = options.tolerance ?? 0.0001; - } - - private static euclideanDistance(p1: Point, p2: Point): number { - return Math.sqrt(p1.reduce((sum, val, i) => sum + (val - p2[i]) ** 2, 0)); - } - - private initializeCentroids(): void { - const dataCopy = [...this.data]; - for (let i = 0; i < this.k; i++) { - const randomIndex = Math.floor(Math.random() * dataCopy.length); - this.centroids.push([...dataCopy[randomIndex]]); - dataCopy.splice(randomIndex, 1); - } - } - - /** - * Creates a random sample of the data. - */ - private createMiniBatch(): Point[] { - const miniBatch: Point[] = []; - const dataCopy = [...this.data]; - for (let i = 0; i < this.batchSize && dataCopy.length > 0; i++) { - const randomIndex = Math.floor(Math.random() * dataCopy.length); - miniBatch.push(dataCopy[randomIndex]); - dataCopy.splice(randomIndex, 1); - } - return miniBatch; - } - - /** - * Assigns all points in the full dataset to the final centroids. - */ - private assignFinalClusters(): Cluster[] { - const clusters: Cluster[] = this.centroids.map(c => ({ centroid: c, points: [] })); - - for (const point of this.data) { - let minDistance = Infinity; - let closestClusterIndex = -1; - for (let i = 0; i < this.centroids.length; i++) { - const distance = KMeans.euclideanDistance(point, this.centroids[i]); - if (distance < minDistance) { - minDistance = distance; - closestClusterIndex = i; - } - } - if (closestClusterIndex !== -1) { - clusters[closestClusterIndex].points.push(point); - } - } - return clusters; - } - - public run(): KMeansResult { - this.initializeCentroids(); - - const clusterPointCounts = new Array(this.k).fill(0); - let converged = false; - let iterations = 0; - - for (let i = 0; i < this.maxIterations; i++) { - iterations = i + 1; - const miniBatch = this.createMiniBatch(); - const previousCentroids = this.centroids.map(c => [...c]); - - // Assign points in the batch and update centroids gradually - for (const point of miniBatch) { - let minDistance = Infinity; - let closestClusterIndex = -1; - - for (let j = 0; j < this.k; j++) { - const distance = KMeans.euclideanDistance(point, this.centroids[j]); - if (distance < minDistance) { - minDistance = distance; - closestClusterIndex = j; - } - } - - if (closestClusterIndex !== -1) { - clusterPointCounts[closestClusterIndex]++; - const learningRate = 1 / clusterPointCounts[closestClusterIndex]; - const centroidToUpdate = this.centroids[closestClusterIndex]; - - // Move the centroid slightly towards the new point - for (let dim = 0; dim < centroidToUpdate.length; dim++) { - centroidToUpdate[dim] = (1 - learningRate) * centroidToUpdate[dim] + learningRate * point[dim]; - } - } - } - - // Check for convergence - let totalMovement = 0; - for(let j = 0; j < this.k; j++) { - totalMovement += KMeans.euclideanDistance(previousCentroids[j], this.centroids[j]); - } - - if (totalMovement < this.tolerance) { - converged = true; - break; - } - } - - // After training, assign all points to the final centroids - const finalClusters = this.assignFinalClusters(); - - return { - clusters: finalClusters, - iterations, - converged - }; - } +export type Point = number[]; + +export interface Cluster { + centroid: Point; + points: Point[]; +} + +export interface KMeansOptions { + batchSize?: number; + maxIterations?: number; + tolerance?: number; +} + +export interface KMeansResult { + clusters: Cluster[]; + iterations: number; + converged: boolean; +} + +export class KMeans { + private readonly k: number; + private readonly batchSize: number; + private readonly maxIterations: number; + private readonly tolerance: number; + private readonly data: Point[]; + private centroids: Point[] = []; + + constructor(data: Point[], k: number, options: KMeansOptions = {}) { + this.data = data; + this.k = k; + this.batchSize = options.batchSize ?? 32; + this.maxIterations = options.maxIterations ?? 100; + this.tolerance = options.tolerance ?? 0.0001; + } + + private static euclideanDistance(p1: Point, p2: Point): number { + return Math.sqrt(p1.reduce((sum, val, i) => sum + (val - p2[i]) ** 2, 0)); + } + + private initializeCentroids(): void { + const dataCopy = [...this.data]; + for (let i = 0; i < this.k; i++) { + const randomIndex = Math.floor(Math.random() * dataCopy.length); + this.centroids.push([...dataCopy[randomIndex]]); + dataCopy.splice(randomIndex, 1); + } + } + + /** + * Creates a random sample of the data. + */ + private createMiniBatch(): Point[] { + const miniBatch: Point[] = []; + const dataCopy = [...this.data]; + for (let i = 0; i < this.batchSize && dataCopy.length > 0; i++) { + const randomIndex = Math.floor(Math.random() * dataCopy.length); + miniBatch.push(dataCopy[randomIndex]); + dataCopy.splice(randomIndex, 1); + } + return miniBatch; + } + + /** + * Assigns all points in the full dataset to the final centroids. + */ + private assignFinalClusters(): Cluster[] { + const clusters: Cluster[] = this.centroids.map(c => ({ centroid: c, points: [] })); + + for (const point of this.data) { + let minDistance = Infinity; + let closestClusterIndex = -1; + for (let i = 0; i < this.centroids.length; i++) { + const distance = KMeans.euclideanDistance(point, this.centroids[i]); + if (distance < minDistance) { + minDistance = distance; + closestClusterIndex = i; + } + } + if (closestClusterIndex !== -1) { + clusters[closestClusterIndex].points.push(point); + } + } + return clusters; + } + + public run(): KMeansResult { + this.initializeCentroids(); + + const clusterPointCounts = new Array(this.k).fill(0); + let converged = false; + let iterations = 0; + + for (let i = 0; i < this.maxIterations; i++) { + iterations = i + 1; + const miniBatch = this.createMiniBatch(); + const previousCentroids = this.centroids.map(c => [...c]); + + // Assign points in the batch and update centroids gradually + for (const point of miniBatch) { + let minDistance = Infinity; + let closestClusterIndex = -1; + + for (let j = 0; j < this.k; j++) { + const distance = KMeans.euclideanDistance(point, this.centroids[j]); + if (distance < minDistance) { + minDistance = distance; + closestClusterIndex = j; + } + } + + if (closestClusterIndex !== -1) { + clusterPointCounts[closestClusterIndex]++; + const learningRate = 1 / clusterPointCounts[closestClusterIndex]; + const centroidToUpdate = this.centroids[closestClusterIndex]; + + // Move the centroid slightly towards the new point + for (let dim = 0; dim < centroidToUpdate.length; dim++) { + centroidToUpdate[dim] = (1 - learningRate) * centroidToUpdate[dim] + learningRate * point[dim]; + } + } + } + + // Check for convergence + let totalMovement = 0; + for(let j = 0; j < this.k; j++) { + totalMovement += KMeans.euclideanDistance(previousCentroids[j], this.centroids[j]); + } + + if (totalMovement < this.tolerance) { + converged = true; + break; + } + } + + // After training, assign all points to the final centroids + const finalClusters = this.assignFinalClusters(); + + return { + clusters: finalClusters, + iterations, + converged + }; + } } \ No newline at end of file diff --git a/package.json b/package.json deleted file mode 100644 index 09be592..0000000 --- a/package.json +++ /dev/null @@ -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" - } -} diff --git a/services/prediction.ts b/prediction.ts similarity index 94% rename from services/prediction.ts rename to prediction.ts index 799c5db..eb46525 100644 --- a/services/prediction.ts +++ b/prediction.ts @@ -1,101 +1,101 @@ -import * as math from 'mathjs'; - -// The structure for the returned regression model -export interface LinearRegressionModel { - slope: number; - intercept: number; - predict: (x: number) => number; -} - -// The structure for the full forecast output -export interface ForecastResult { - forecast: number[]; - predictionIntervals: { - upperBound: number[]; - lowerBound: number[]; - }; - modelParameters: { - slope: number; - intercept: number; - }; -} - -/** - * Calculates the linear regression model from a time series. - * @param yValues The historical data points (e.g., sales per month). - * @returns {LinearRegressionModel} An object containing the model's parameters and a predict function. - */ -export function calculateLinearRegression(yValues: number[]): LinearRegressionModel { - if (yValues.length < 2) { - throw new Error('At least two data points are required for linear regression.'); - } - - const xValues = Array.from({ length: yValues.length }, (_, i) => i); - - const meanX = Number(math.mean(xValues)); - const meanY = Number(math.mean(yValues)); - const stdDevX = Number(math.std(xValues, 'uncorrected')); - const stdDevY = Number(math.std(yValues, 'uncorrected')); - - // Ensure stdDevX is not zero to avoid division by zero - if (stdDevX === 0) { - // This happens if all xValues are the same, which is impossible in this time series context, - // but it's good practice to handle. A vertical line has an infinite slope. - // For simplicity, we can return a model with zero slope. - return { slope: 0, intercept: meanY, predict: (x: number) => meanY }; - } - - // 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 slope = correlation * (stdDevY / stdDevX); - const intercept = meanY - slope * meanX; - - const predict = (x: number): number => slope * x + intercept; - - return { slope, intercept, predict }; -} - -/** - * Generates a forecast for a specified number of future periods. - * @param model The calculated linear regression model. - * @param historicalDataLength The number of historical data points. - * @param forecastPeriods The number of future periods to predict. - * @returns {number[]} An array of forecasted values. - */ -export function generateForecast(model: LinearRegressionModel, historicalDataLength: number, forecastPeriods: number): number[] { - const forecast: number[] = []; - const startPeriod = historicalDataLength; - - for (let i = 0; i < forecastPeriods; i++) { - const futureX = startPeriod + i; - forecast.push(model.predict(futureX)); - } - return forecast; -} - -/** - * Calculates prediction intervals to show the range of uncertainty. - * @param yValues The original historical data. - * @param model The calculated linear regression model. - * @param forecast The array of forecasted values. - * @returns An object with upperBound and lowerBound arrays. - */ -export function calculatePredictionIntervals(yValues: number[], model: LinearRegressionModel, forecast: number[]) { - const n = yValues.length; - const residualsSquaredSum = yValues.reduce((sum, y, i) => { - const predictedY = model.predict(i); - return sum + (y - predictedY) ** 2; - }, 0); - const stdError = Math.sqrt(residualsSquaredSum / (n - 2)); - - const zScore = 1.96; // For a 95% confidence level - const marginOfError = zScore * stdError; - - const upperBound = forecast.map(val => val + marginOfError); - const lowerBound = forecast.map(val => val - marginOfError); - - return { upperBound, lowerBound }; +import * as math from 'mathjs'; + +// The structure for the returned regression model +export interface LinearRegressionModel { + slope: number; + intercept: number; + predict: (x: number) => number; +} + +// The structure for the full forecast output +export interface ForecastResult { + forecast: number[]; + predictionIntervals: { + upperBound: number[]; + lowerBound: number[]; + }; + modelParameters: { + slope: number; + intercept: number; + }; +} + +/** + * Calculates the linear regression model from a time series. + * @param yValues The historical data points (e.g., sales per month). + * @returns {LinearRegressionModel} An object containing the model's parameters and a predict function. + */ +export function calculateLinearRegression(yValues: number[]): LinearRegressionModel { + if (yValues.length < 2) { + throw new Error('At least two data points are required for linear regression.'); + } + + const xValues = Array.from({ length: yValues.length }, (_, i) => i); + + const meanX = Number(math.mean(xValues)); + const meanY = Number(math.mean(yValues)); + const stdDevX = Number(math.std(xValues, 'uncorrected')); + const stdDevY = Number(math.std(yValues, 'uncorrected')); + + // Ensure stdDevX is not zero to avoid division by zero + if (stdDevX === 0) { + // This happens if all xValues are the same, which is impossible in this time series context, + // but it's good practice to handle. A vertical line has an infinite slope. + // For simplicity, we can return a model with zero slope. + return { slope: 0, intercept: meanY, predict: (x: number) => meanY }; + } + + // 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 - 1) * stdDevX * stdDevY); + + const slope = correlation * (stdDevY / stdDevX); + const intercept = meanY - slope * meanX; + + const predict = (x: number): number => slope * x + intercept; + + return { slope, intercept, predict }; +} + +/** + * Generates a forecast for a specified number of future periods. + * @param model The calculated linear regression model. + * @param historicalDataLength The number of historical data points. + * @param forecastPeriods The number of future periods to predict. + * @returns {number[]} An array of forecasted values. + */ +export function generateForecast(model: LinearRegressionModel, historicalDataLength: number, forecastPeriods: number): number[] { + const forecast: number[] = []; + const startPeriod = historicalDataLength; + + for (let i = 0; i < forecastPeriods; i++) { + const futureX = startPeriod + i; + forecast.push(model.predict(futureX)); + } + return forecast; +} + +/** + * Calculates prediction intervals to show the range of uncertainty. + * @param yValues The original historical data. + * @param model The calculated linear regression model. + * @param forecast The array of forecasted values. + * @returns An object with upperBound and lowerBound arrays. + */ +export function calculatePredictionIntervals(yValues: number[], model: LinearRegressionModel, forecast: number[]) { + const n = yValues.length; + const residualsSquaredSum = yValues.reduce((sum, y, i) => { + const predictedY = model.predict(i); + return sum + (y - predictedY) ** 2; + }, 0); + const stdError = Math.sqrt(residualsSquaredSum / (n - 2)); + + const zScore = 1.96; // For a 95% confidence level + const marginOfError = zScore * stdError; + + const upperBound = forecast.map(val => val + marginOfError); + const lowerBound = forecast.map(val => val - marginOfError); + + return { upperBound, lowerBound }; } \ No newline at end of file diff --git a/server.ts b/server.ts index 1c071f0..4991752 100644 --- a/server.ts +++ b/server.ts @@ -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 { + 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); } 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); } 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); } 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); } 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); } 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); } 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); }); 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; \ No newline at end of file diff --git a/services/analysis_pipelines.ts b/services/analysis_pipelines.ts deleted file mode 100644 index 54b277f..0000000 --- a/services/analysis_pipelines.ts +++ /dev/null @@ -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 }; - } -} diff --git a/services/analytics_engine.ts b/services/analytics_engine.ts deleted file mode 100644 index 88979d3..0000000 --- a/services/analytics_engine.ts +++ /dev/null @@ -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(); - diff --git a/services/pivot_table.ts b/services/pivot_table.ts deleted file mode 100644 index 515c5e0..0000000 --- a/services/pivot_table.ts +++ /dev/null @@ -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[], - options: PivotOptions -): Record> { - const { index, columns, values, aggFunc = arr => arr.reduce((a, b) => a + b, 0) } = options; - const cellMap: Record> = {}; - - 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> = {}; - Object.entries(cellMap).forEach(([rowKey, cols]) => { - result[rowKey] = {}; - Object.entries(cols).forEach(([colKey, valuesArr]) => { - result[rowKey][colKey] = aggFunc(valuesArr); - }); - }); - - return result; -} \ No newline at end of file diff --git a/services/retail_metrics.ts b/services/retail_metrics.ts deleted file mode 100644 index 08b0b78..0000000 --- a/services/retail_metrics.ts +++ /dev/null @@ -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'); - } \ No newline at end of file diff --git a/services/rolling_window.ts b/services/rolling_window.ts deleted file mode 100644 index 2e11e1e..0000000 --- a/services/rolling_window.ts +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/services/timeseries.ts b/services/timeseries.ts deleted file mode 100644 index 3ceac50..0000000 --- a/services/timeseries.ts +++ /dev/null @@ -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, - }; - } -} - diff --git a/services/signal_processing_convolution.ts b/signal_processing_convolution.ts similarity index 100% rename from services/signal_processing_convolution.ts rename to signal_processing_convolution.ts diff --git a/tests/analyticsEngine.test.ts b/tests/analyticsEngine.test.ts deleted file mode 100644 index b8391f9..0000000 --- a/tests/analyticsEngine.test.ts +++ /dev/null @@ -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); - }); -}); \ No newline at end of file diff --git a/services/time-helper.ts b/time-helper.ts similarity index 91% rename from services/time-helper.ts rename to time-helper.ts index 06faa9f..b7acecc 100644 --- a/services/time-helper.ts +++ b/time-helper.ts @@ -1,22 +1,24 @@ -import { getISOWeek, getISODay, subYears, setISOWeek, setISODay, isValid } from 'date-fns'; - -export const getWeekNumber = (dateString: string): number => { - const date = new Date(dateString); - if (!isValid(date)) { - throw new Error('Invalid date string provided.'); - } - return getISOWeek(date); -}; - -export const getSameWeekDayLastYear = (dateString: string): string => { - const baseDate = new Date(dateString); - if (!isValid(baseDate)) { - throw new Error('Invalid date string provided.'); - } - const originalWeek = getISOWeek(baseDate); - const originalDayOfWeek = getISODay(baseDate); - const lastYearDate = subYears(baseDate, 1); - const dateWithWeekSet = setISOWeek(lastYearDate, originalWeek); - const finalDate = setISODay(dateWithWeekSet, originalDayOfWeek); - return finalDate.toISOString().split('T')[0]; // Return as YYYY-MM-DD +// time-helpers.ts - Date and time utility functions + +import { getISOWeek, getISODay, subYears, setISOWeek, setISODay, isValid } from 'date-fns'; + +export const getWeekNumber = (dateString: string): number => { + const date = new Date(dateString); + if (!isValid(date)) { + throw new Error('Invalid date string provided.'); + } + return getISOWeek(date); +}; + +export const getSameWeekDayLastYear = (dateString: string): string => { + const baseDate = new Date(dateString); + if (!isValid(baseDate)) { + throw new Error('Invalid date string provided.'); + } + const originalWeek = getISOWeek(baseDate); + const originalDayOfWeek = getISODay(baseDate); + const lastYearDate = subYears(baseDate, 1); + const dateWithWeekSet = setISOWeek(lastYearDate, originalWeek); + const finalDate = setISODay(dateWithWeekSet, originalDayOfWeek); + return finalDate.toISOString().split('T')[0]; // Return as YYYY-MM-DD }; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 0d6c2f4..0000000 --- a/tsconfig.json +++ /dev/null @@ -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"] -} \ No newline at end of file diff --git a/types/index.ts b/types/index.ts deleted file mode 100644 index 8cf56d2..0000000 --- a/types/index.ts +++ /dev/null @@ -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 { - success: boolean; - data?: T; - error?: string; -} \ No newline at end of file