diff --git a/api-documentation.html b/api-documentation.html index 475d326..6da21b0 100644 --- a/api-documentation.html +++ b/api-documentation.html @@ -28,7 +28,7 @@ - - - -`; - - res.setHeader('Content-Disposition', 'attachment; filename="api-documentation.html"'); - res.setHeader('Content-Type', 'text/html'); - res.send(htmlTemplate); -}); - - -// ======================================== -// ERROR HANDLING -// ======================================== - -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' } as ApiResponse); -}); - -app.use('*', (req, res) => { - res.status(404).json({ success: false, error: 'Endpoint not found' }); -}); - -// ======================================== -// SERVER STARTUP -// ======================================== - -app.listen(PORT, () => { - 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`); -}); - +// server.ts - Simplified main server file +// package.json dependencies needed: +// npm install express mathjs lodash date-fns swagger-jsdoc swagger-ui-express js-yaml +// npm install -D @types/express @types/node @types/lodash typescript ts-node + +import express from 'express'; +import swaggerJsdoc from 'swagger-jsdoc'; +import swaggerUi from 'swagger-ui-express'; +import * as math from 'mathjs'; +import * as _ from 'lodash'; + +// 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 './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) => []; + +const app = express(); +app.use(express.json()); +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', + }, + servers: [ + { + url: `http://localhost:${PORT}`, + }, + ], + }, + 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 +// ======================================== + +/** + * @swagger + * /api/health: + * get: + * summary: Health check endpoint + * description: Returns the health status of the API + * tags: [Health] + * responses: + * '200': + * description: API is healthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: OK + * timestamp: + * type: string + * format: date-time + */ +app.get('/api/health', (req, res) => { + res.status(200).json({ status: 'OK', timestamp: new Date().toISOString() }); +}); + +/** + * @swagger + * /api/unique: + * post: + * summary: Get unique values from a data series + * description: Returns an array of unique values from the provided data series + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * responses: + * '200': + * description: Unique values calculated successfully + * '400': + * description: Invalid input data + */ +app.post('/api/unique', (req, res) => { + try { + const result = analytics.unique(req.body.series); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/mean: + * post: + * summary: Calculate mean of a data series + * description: Returns the arithmetic mean of the provided data series, optionally filtered by conditions + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * '200': + * description: Mean calculated successfully + * '400': + * description: Invalid input data + */ +app.post('/api/mean', (req, res) => { + try { + const result = analytics.mean(req.body.series, req.body.conditions); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/count: + * post: + * summary: Count data points in a series + * description: Returns the count of data points in the series, optionally filtered by conditions + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * '200': + * description: Count calculated successfully + * '400': + * description: Invalid input data + */ +app.post('/api/count', (req, res) => { + try { + const result = analytics.count(req.body.series, req.body.conditions); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/variance: + * post: + * summary: Calculate variance of a data series + * description: Returns the variance of the provided data series + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * '200': + * description: Variance calculated successfully + * '400': + * description: Invalid input data + */ +app.post('/api/variance', (req, res) => { + try { + const result = analytics.variance(req.body.series, req.body.conditions); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/std: + * post: + * summary: Calculate standard deviation of a data series + * description: Returns the standard deviation of the provided data series + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * '200': + * description: Standard deviation calculated successfully + * '400': + * description: Invalid input data + */ +app.post('/api/std', (req, res) => { + try { + const result = analytics.standardDeviation(req.body.series, req.body.conditions); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/percentile: + * post: + * summary: Calculate percentile of a data series + * description: Returns the specified percentile of the provided data series + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * percent: + * type: number + * description: Percentile to calculate (0-100) + * example: 95 + * ascending: + * type: boolean + * description: Sort order + * default: true + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * '200': + * description: Percentile calculated successfully + * '400': + * description: Invalid input data + */ +app.post('/api/percentile', (req, res) => { + try { + const result = analytics.percentile(req.body.series, req.body.percent, req.body.ascending, req.body.conditions); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/median: + * post: + * summary: Calculate median of a data series + * description: Returns the median (50th percentile) of the provided data series + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * '200': + * description: Median calculated successfully + * '400': + * description: Invalid input data + */ +app.post('/api/median', (req, res) => { + try { + const result = analytics.median(req.body.series, req.body.conditions); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/mode: + * post: + * summary: Calculate mode of a data series + * description: Returns the mode (most frequent values) of the provided data series + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * '200': + * description: Mode calculated successfully + * '400': + * description: Invalid input data + */ +app.post('/api/mode', (req, res) => { + try { + const result = analytics.mode(req.body.series, req.body.conditions); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/max: + * post: + * summary: Find maximum value in a data series + * description: Returns the maximum value from the provided data series + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * '200': + * description: Maximum value found successfully + * '400': + * description: Invalid input data + */ +app.post('/api/max', (req, res) => { + try { + const result = analytics.max(req.body.series, req.body.conditions); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/min: + * post: + * summary: Find minimum value in a data series + * description: Returns the minimum value from the provided data series + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * conditions: + * type: array + * items: + * $ref: '#/components/schemas/Condition' + * responses: + * '200': + * description: Minimum value found successfully + * '400': + * description: Invalid input data + */ +app.post('/api/min', (req, res) => { + try { + const result = analytics.min(req.body.series, req.body.conditions); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/correlation: + * post: + * summary: Calculate correlation between two data series + * description: Returns the Pearson correlation coefficient between two data series + * tags: [Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series1: + * $ref: '#/components/schemas/DataSeries' + * series2: + * $ref: '#/components/schemas/DataSeries' + * responses: + * '200': + * description: Correlation calculated successfully + * '400': + * description: Invalid input data or series have different lengths + */ +app.post('/api/correlation', (req, res) => { + try { + const result = analytics.correlation(req.body.series1, req.body.series2); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/series/moving-average: + * post: + * summary: Calculate moving average of a data series + * description: Returns the moving average of the provided data series with specified window size + * tags: [Series Operations] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * windowSize: + * type: integer + * description: Size of the moving window + * minimum: 1 + * example: 5 + * responses: + * '200': + * description: Moving average calculated successfully + * '400': + * description: Invalid input data or window size + */ +app.post('/api/series/moving-average', (req, res) => { + try { + const { series, windowSize } = req.body; + const result = analytics.movingAverage(series, windowSize); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/series/rolling: + * post: + * summary: Get rolling windows of a data series + * description: Returns rolling windows of the provided data series with specified window size + * tags: [Series Operations] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * windowSize: + * type: integer + * description: Size of the rolling window + * minimum: 1 + * example: 3 + * responses: + * '200': + * description: Rolling windows calculated successfully + * '400': + * description: Invalid input data or window size + */ +app.post('/api/series/rolling', (req, res) => { + try { + const { series, windowSize } = req.body; + const result = analytics.rolling(series, windowSize).toArray(); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/ml/kmeans: + * post: + * summary: Perform K-means clustering + * description: Performs K-means clustering on the provided data matrix + * tags: [Machine Learning] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * matrix: + * $ref: '#/components/schemas/DataMatrix' + * nClusters: + * type: integer + * description: Number of clusters + * minimum: 1 + * example: 3 + * options: + * type: object + * description: K-means options + * responses: + * '200': + * description: K-means clustering completed successfully + * '400': + * description: Invalid input data + */ +app.post('/api/ml/kmeans', (req, res) => { + try { + const result = analytics.kmeans(req.body.matrix, req.body.nClusters, req.body.options); + res.status(200).json({ success: true, data: result } as ApiResponse<{ clusters: number[][][], centroids: number[][] }>); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse<{ clusters: number[][][], centroids: number[][] }>); + } +}); + +/** + * @swagger + * /api/time/week-number: + * post: + * summary: Get week number from date + * description: Returns the ISO week number for the provided date string + * tags: [Time] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * date: + * type: string + * format: date + * description: Date string in ISO format + * example: "2024-03-15" + * responses: + * '200': + * description: Week number calculated successfully + * '400': + * description: Invalid date format + */ +app.post('/api/time/week-number', (req, res) => { + try { + const { date } = req.body; + const result = analytics.getWeekNumber(date); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/time/same-day-last-year: + * post: + * summary: Get same day of week from last year + * description: Returns the date string for the same day of the week from the previous year + * tags: [Time] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * date: + * type: string + * format: date + * description: Date string in ISO format + * example: "2024-03-15" + * responses: + * '200': + * description: Same day last year calculated successfully + * '400': + * description: Invalid date format + */ +app.post('/api/time/same-day-last-year', (req, res) => { + try { + const { date } = req.body; + const result = analytics.getSameWeekDayLastYear(date); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/retail/purchase-rate: + * post: + * summary: Calculate purchase rate + * description: Calculates the purchase rate as a percentage of product purchases over total transactions + * tags: [Retail] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * productPurchases: + * type: number + * description: Number of product purchases + * example: 150 + * totalTransactions: + * type: number + * description: Total number of transactions + * example: 1000 + * responses: + * '200': + * description: Purchase rate calculated successfully + * '400': + * description: Invalid input data or division by zero + */ +app.post('/api/retail/purchase-rate', (req, res) => { + try { + 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); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/retail/lift-value: + * post: + * summary: Calculate lift value + * description: Calculates the lift value for market basket analysis + * tags: [Retail] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * jointPurchaseRate: + * type: number + * description: Joint purchase rate of both products + * example: 0.05 + * productAPurchaseRate: + * type: number + * description: Purchase rate of product A + * example: 0.2 + * productBPurchaseRate: + * type: number + * description: Purchase rate of product B + * example: 0.3 + * responses: + * '200': + * description: Lift value calculated successfully + * '400': + * description: Invalid input data or division by zero + */ +app.post('/api/retail/lift-value', (req, res) => { + try { + 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); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/retail/cost-ratio: + * post: + * summary: Calculate cost ratio + * description: Calculates the cost ratio (cost divided by sale price) + * tags: [Retail] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * cost: + * type: number + * description: Cost of the product + * example: 50 + * salePrice: + * type: number + * description: Sale price of the product + * example: 100 + * responses: + * '200': + * description: Cost ratio calculated successfully + * '400': + * description: Invalid input data or division by zero + */ +app.post('/api/retail/cost-ratio', (req, res) => { + try { + 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); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/retail/gross-margin: + * post: + * summary: Calculate gross margin rate + * description: Calculates the gross margin rate as a percentage + * tags: [Retail] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * salePrice: + * type: number + * description: Sale price of the product + * example: 100 + * cost: + * type: number + * description: Cost of the product + * example: 60 + * responses: + * '200': + * description: Gross margin rate calculated successfully + * '400': + * description: Invalid input data or division by zero + */ +app.post('/api/retail/gross-margin', (req, res) => { + try { + 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); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/retail/average-spend: + * post: + * summary: Calculate average spend per customer + * description: Calculates the average amount spent per customer + * tags: [Retail] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * totalRevenue: + * type: number + * description: Total revenue + * example: 50000 + * numberOfCustomers: + * type: number + * description: Number of customers + * example: 500 + * responses: + * '200': + * description: Average spend calculated successfully + * '400': + * description: Invalid input data or division by zero + */ +app.post('/api/retail/average-spend', (req, res) => { + try { + const { totalRevenue, numberOfCustomers } = req.body; + const result = analytics.averageSpendPerCustomer(totalRevenue, numberOfCustomers); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/retail/purchase-index: + * post: + * summary: Calculate purchase index + * description: Calculates the purchase index (items per 1000 customers) + * tags: [Retail] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * totalItemsSold: + * type: number + * description: Total number of items sold + * example: 2500 + * numberOfCustomers: + * type: number + * description: Number of customers + * example: 1000 + * responses: + * '200': + * description: Purchase index calculated successfully + * '400': + * description: Invalid input data or division by zero + */ +app.post('/api/retail/purchase-index', (req, res) => { + try { + const { totalItemsSold, numberOfCustomers } = req.body; + const result = analytics.purchaseIndex(totalItemsSold, numberOfCustomers); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/predict/forecast: + * post: + * summary: Generate time series forecast + * description: Generates a forecast for time series data using linear regression + * tags: [Prediction] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * forecastPeriods: + * type: integer + * description: Number of periods to forecast + * minimum: 1 + * example: 10 + * responses: + * '200': + * description: Forecast generated successfully + * '400': + * description: Invalid input data + */ +app.post('/api/predict/forecast', (req, res) => { + try { + const { series, forecastPeriods } = req.body; + const result = analytics.timeSeriesForecast(series, forecastPeriods); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +// ======================================== +// NEW SIGNAL & IMAGE PROCESSING ROUTES +// ======================================== + +/** + * @swagger + * /api/series/smooth: + * post: + * summary: Smooth a 1D data series + * description: Applies a smoothing filter (Gaussian or Moving Average) to a 1D data series to reduce noise. + * tags: [Series Operations] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * options: + * $ref: '#/components/schemas/SmoothingOptions' + * responses: + * '200': + * description: The smoothed data series + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiResponse' + * '400': + * description: Invalid input data + */ +app.post('/api/series/smooth', (req, res) => { + try { + const { series, options } = req.body; + validateSeries(series); + const result = SignalProcessor.smooth(series.values, options); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/series/detect-peaks: + * post: + * summary: Detect peaks in a 1D data series + * description: Identifies local maxima (peaks) in a 1D data series. More robust and accurate logic. + * tags: [Series Operations] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * options: + * type: object + * properties: + * smoothWindow: + * type: integer + * description: Optional window size for Gaussian smoothing to reduce noise before peak detection. + * example: 3 + * minDistance: + * type: integer + * description: The minimum number of data points between two peaks. + * example: 1 + * threshold: + * type: number + * description: The minimum value for a data point to be considered a peak. + * example: 0.5 + * responses: + * '200': + * description: An array of detected peak objects, each with an index and value. + * '400': + * description: Invalid input data + */ +app.post('/api/series/detect-peaks', (req, res) => { + try { + const { series, options } = req.body; + validateSeries(series); + const result = SignalProcessor.detectPeaksConvolution(series.values, options); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/series/detect-valleys: + * post: + * summary: Detect valleys in a 1D data series + * description: Identifies local minima (valleys) in a 1D data series. More robust and accurate logic. + * tags: [Series Operations] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * options: + * type: object + * properties: + * smoothWindow: + * type: integer + * description: Optional window size for Gaussian smoothing to reduce noise before valley detection. + * example: 3 + * minDistance: + * type: integer + * description: The minimum number of data points between two valleys. + * example: 1 + * threshold: + * type: number + * description: The maximum value for a data point to be considered a valley. + * example: -0.5 + * responses: + * '200': + * description: An array of detected valley objects, each with an index and value. + * '400': + * description: Invalid input data + */ +app.post('/api/series/detect-valleys', (req, res) => { + try { + const { series, options } = req.body; + validateSeries(series); + const result = SignalProcessor.detectValleysConvolution(series.values, options); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/series/detect-outliers: + * post: + * summary: Detect outliers in a 1D data series + * description: Identifies outliers in a 1D data series using statistically sound methods. + * tags: [Series Operations] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * options: + * type: object + * properties: + * method: + * type: string + * enum: [local_deviation, mean_diff] + * default: local_deviation + * windowSize: + * type: integer + * default: 7 + * threshold: + * type: number + * description: "The sensitivity threshold. For 'local_deviation', this is the number of standard deviations (Z-score)." + * default: 3.0 + * responses: + * '200': + * description: An array of detected outlier objects. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiResponse' + * '400': + * description: Invalid input data + */ +app.post('/api/series/detect-outliers', (req, res) => { + try { + const { series, options } = req.body; + validateSeries(series); + const result = SignalProcessor.detectOutliersConvolution(series.values, options); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/series/detect-vertices: + * post: + * summary: Detect trend vertices (turning points) in a 1D series + * description: Identifies all significant peaks and valleys in a data series trend using a robust local maxima/minima search. + * tags: [Series Operations] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * series: + * $ref: '#/components/schemas/DataSeries' + * options: + * type: object + * properties: + * smoothingWindow: + * type: integer + * default: 5 + * description: Window size for an initial Gaussian smoothing pass to reduce noise. + * threshold: + * type: number + * description: The absolute value a peak/valley must exceed to be counted. + * default: 0 + * minDistance: + * type: integer + * default: 3 + * description: Minimum number of data points between any two vertices. + * responses: + * '200': + * description: An array of detected vertex objects, labeled as 'peak' or 'valley'. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiResponse' + * '400': + * description: Invalid input data + */ +app.post('/api/series/detect-vertices', (req, res) => { + try { + const { series, options } = req.body; + validateSeries(series); + const result = SignalProcessor.detectTrendVertices(series.values, options); + res.status(200).json({ success: true, data: result } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +/** + * @swagger + * /api/kernels/{name}: + * get: + * summary: Get a pre-defined convolution kernel + * description: Retrieves a standard 1D or 2D convolution kernel by its name. + * tags: [Kernels] + * parameters: + * - in: path + * name: name + * required: true + * schema: + * type: string + * enum: [sobel-x, sobel-y, laplacian, difference1d, average1d] + * description: The name of the kernel to retrieve. + * - in: query + * name: size + * schema: + * type: integer + * default: 3 + * description: The size of the kernel (for kernels like 'average1d'). + * responses: + * '200': + * description: The requested kernel as a 1D or 2D array. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ApiResponse' + * '400': + * description: Unknown kernel name or invalid options. + */ +app.get('/api/kernels/:name', (req, res) => { + try { + const kernelName = req.params.name; + const size = req.query.size ? parseInt(req.query.size as string, 10) : 3; + let kernel: number[] | number[][]; + + switch (kernelName) { + case 'sobel-x': + kernel = ConvolutionKernels.sobel('x'); + break; + case 'sobel-y': + kernel = ConvolutionKernels.sobel('y'); + break; + case 'laplacian': + kernel = ConvolutionKernels.laplacian(); + break; + case 'difference1d': + kernel = ConvolutionKernels.difference1D(); + break; + case 'average1d': + kernel = ConvolutionKernels.average1D(size); + break; + default: + throw new Error(`Unknown kernel name: ${kernelName}`); + } + res.status(200).json({ success: true, data: kernel } as ApiResponse); + } catch (error) { + const errorMessage = handleError(error); + res.status(400).json({ success: false, error: errorMessage } as ApiResponse); + } +}); + +// ======================================== +// SWAGGER COMPONENTS +// ======================================== + +/** + * @swagger + * components: + * schemas: + * DataSeries: + * type: object + * required: + * - values + * properties: + * values: + * type: array + * items: + * type: number + * description: Array of numerical values + * example: [1, 2, 3, 4, 5] + * labels: + * type: array + * items: + * type: string + * description: Optional labels for the values + * example: ["Jan", "Feb", "Mar", "Apr", "May"] + * DataMatrix: + * type: object + * required: + * - data + * properties: + * data: + * type: array + * items: + * type: array + * items: + * type: number + * description: 2D array of numerical values + * example: [[1, 2], [3, 4], [5, 6]] + * columns: + * type: array + * items: + * type: string + * description: Optional column names + * example: ["x", "y"] + * rows: + * type: array + * items: + * type: string + * description: Optional row names + * example: ["row1", "row2", "row3"] + * Condition: + * type: object + * required: + * - field + * - operator + * - value + * properties: + * field: + * type: string + * description: Field name to apply condition on + * example: "value" + * operator: + * type: string + * enum: [">", "<", "=", ">=", "<=", "!="] + * description: Comparison operator + * example: ">" + * value: + * oneOf: + * - type: number + * - type: string + * description: Value to compare against + * example: 10 + * SmoothingOptions: + * type: object + * properties: + * method: + * type: string + * enum: [gaussian, moving_average] + * default: gaussian + * description: The smoothing method to use. + * windowSize: + * type: integer + * default: 5 + * description: The size of the window for the filter. Must be an odd number for Gaussian. + * sigma: + * type: number + * default: 1.0 + * description: The standard deviation for the Gaussian filter. + * EdgeDetectionOptions: + * type: object + * properties: + * method: + * type: string + * enum: [sobel, laplacian] + * default: sobel + * description: The edge detection algorithm to use. + * threshold: + * type: number + * default: 0.1 + * description: The sensitivity threshold for detecting an edge. Values below this will be set to 0. + * ApiResponse: + * type: object + * properties: + * success: + * type: boolean + * description: Whether the request was successful + * data: + * description: Response data (varies by endpoint) + * error: + * type: string + * description: Error message if success is false + */ + +/** + * @swagger + * /api/docs/export/json: + * get: + * summary: Export API documentation as JSON + * description: Returns the complete OpenAPI specification in JSON format + * tags: [Documentation] + * responses: + * '200': + * description: OpenAPI specification in JSON format + * content: + * application/json: + * schema: + * type: object + */ +app.get('/api/docs/export/json', (req, res) => { + res.setHeader('Content-Disposition', 'attachment; filename="api-documentation.json"'); + res.setHeader('Content-Type', 'application/json'); + res.json(swaggerSpec); +}); + +/** + * @swagger + * /api/docs/export/yaml: + * get: + * summary: Export API documentation as YAML + * description: Returns the complete OpenAPI specification in YAML format + * tags: [Documentation] + * responses: + * '200': + * description: OpenAPI specification in YAML format + * content: + * text/yaml: + * schema: + * type: string + */ +app.get('/api/docs/export/yaml', (req, res) => { + const yaml = require('js-yaml'); + const yamlString = yaml.dump(swaggerSpec, { indent: 2 }); + + res.setHeader('Content-Disposition', 'attachment; filename="api-documentation.yaml"'); + res.setHeader('Content-Type', 'text/yaml'); + res.send(yamlString); +}); + +/** + * @swagger + * /api/docs/export/html: + * get: + * summary: Export API documentation as HTML + * description: Returns a standalone HTML file with the complete API documentation + * tags: [Documentation] + * responses: + * '200': + * description: Standalone HTML documentation + * content: + * text/html: + * schema: + * type: string + */ +app.get('/api/docs/export/html', (req, res) => { + const htmlTemplate = ` + + + + + + API Documentation + + + + +
+ + + + +`; + + res.setHeader('Content-Disposition', 'attachment; filename="api-documentation.html"'); + res.setHeader('Content-Type', 'text/html'); + res.send(htmlTemplate); +}); + +// ======================================== +// ERROR HANDLING +// ======================================== + +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' } as ApiResponse); +}); + +app.use('*', (req, res) => { + res.status(404).json({ success: false, error: 'Endpoint not found' }); +}); + +// ======================================== +// SERVER STARTUP +// ======================================== + +app.listen(PORT, () => { + 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/signal_processing_convolution.ts b/signal_processing_convolution.ts new file mode 100644 index 0000000..ec5b652 --- /dev/null +++ b/signal_processing_convolution.ts @@ -0,0 +1,671 @@ +// signal-processing.ts - Convolution-based signal processing functions +import { convolve1D, convolve2D, ConvolutionKernels, ConvolutionOptions } from './convolution'; + +export interface SmoothingOptions { + method?: 'gaussian' | 'moving_average'; + windowSize?: number; + sigma?: number; +} + +export interface EdgeDetectionOptions { + method?: 'sobel' | 'laplacian' | 'canny'; + threshold?: number; +} + +export interface FilterOptions { + type: 'lowpass' | 'highpass' | 'bandpass' | 'bandstop'; + cutoffLow?: number; + cutoffHigh?: number; + order?: number; +} + +export interface DerivativeOptions { + order?: 1 | 2; + method?: 'gradient' | 'laplacian'; +} + +/** + * Convolution-Based Signal Processing Library + * Functions that leverage convolution operations for signal processing + */ +export class SignalProcessor { + + /** + * Smooth a 1D signal using convolution-based methods + */ + static smooth(signal: number[], options: SmoothingOptions = {}): number[] { + const { method = 'gaussian', windowSize = 5, sigma = 1.0 } = options; + + if (signal.length === 0) { + throw new Error('Signal cannot be empty'); + } + + let kernel: number[]; + + switch (method) { + case 'gaussian': + kernel = ConvolutionKernels.gaussian1D(windowSize, sigma); + break; + + case 'moving_average': + kernel = ConvolutionKernels.average1D(windowSize); + break; + + default: + throw new Error(`Unsupported smoothing method: ${method}`); + } + + return convolve1D(signal, kernel, { mode: 'same' }).values; + } + + /** + * Detect edges in 2D image data using convolution-based methods + */ + static detectEdges2D(image: number[][], options: EdgeDetectionOptions = {}): number[][] { + const { method = 'sobel', threshold = 0.1 } = options; + + let kernelX: number[][]; + let kernelY: number[][]; + + switch (method) { + case 'sobel': + kernelX = ConvolutionKernels.sobel("x"); + kernelY = ConvolutionKernels.sobel("y"); + break; + + case 'laplacian': + const laplacianKernel = ConvolutionKernels.laplacian(); + return convolve2D(image, laplacianKernel, { mode: 'same' }).matrix.map(row => + row.map(val => Math.abs(val) > threshold ? Math.abs(val) : 0) + ); + + default: + throw new Error(`Unsupported edge detection method: ${method}`); + } + + // Apply both kernels and combine results + const edgesX = convolve2D(image, kernelX, { mode: 'same' }).matrix; + const edgesY = convolve2D(image, kernelY, { mode: 'same' }).matrix; + + // Calculate gradient magnitude + const result: number[][] = []; + for (let i = 0; i < edgesX.length; i++) { + result[i] = []; + for (let j = 0; j < edgesX[i].length; j++) { + const magnitude = Math.sqrt(edgesX[i][j] ** 2 + edgesY[i][j] ** 2); + result[i][j] = magnitude > threshold ? magnitude : 0; + } + } + + return result; + } + + /** + * Apply digital filters using convolution + */ + static filter(signal: number[], options: FilterOptions): number[] { + const { type, cutoffLow = 0.1, cutoffHigh = 0.5, order = 4 } = options; + + let kernel: number[]; + + switch (type) { + case 'lowpass': + // Low-pass filter using Gaussian kernel + kernel = ConvolutionKernels.gaussian1D(order * 4 + 1, order / 2); + return convolve1D(signal, kernel, { mode: 'same' }).values; + + case 'highpass': + // High-pass filter using difference of Gaussians + const lpKernel = ConvolutionKernels.gaussian1D(order * 4 + 1, order / 2); + const smoothed = convolve1D(signal, lpKernel, { mode: 'same' }).values; + return signal.map((val, i) => val - smoothed[i]); + + case 'bandpass': + // Band-pass as combination of high-pass and low-pass + const hp = this.filter(signal, { type: 'highpass', cutoffLow, order }); + return this.filter(hp, { type: 'lowpass', cutoffLow: cutoffHigh, order }); + + case 'bandstop': + // Band-stop as original minus band-pass + const bp = this.filter(signal, { type: 'bandpass', cutoffLow, cutoffHigh, order }); + return signal.map((val, i) => val - bp[i]); + + default: + throw new Error(`Unsupported filter type: ${type}`); + } + } + + /** + * Calculate derivatives using convolution with derivative kernels + */ + static derivative(signal: number[], options: DerivativeOptions = {}): number[] { + const { order = 1, method = 'gradient' } = options; + + let kernel: number[]; + + if (method === 'gradient') { + switch (order) { + case 1: + // First derivative using gradient kernel + kernel = [-0.5, 0, 0.5]; // Simple gradient + break; + case 2: + // Second derivative using Laplacian-like kernel + kernel = [1, -2, 1]; // Simple second derivative + break; + default: + throw new Error(`Unsupported derivative order: ${order}`); + } + } else if (method === 'laplacian' && order === 2) { + // 1D Laplacian + kernel = [1, -2, 1]; + } else { + throw new Error(`Unsupported derivative method: ${method}`); + } + + return convolve1D(signal, kernel, { mode: 'same' }).values; + } + + /** + * Blur 2D image using Gaussian convolution + */ + static blur2D(image: number[][], sigma: number = 1.0, kernelSize?: number): number[][] { + const size = kernelSize || Math.ceil(sigma * 6) | 1; // Ensure odd size + const kernel = ConvolutionKernels.gaussian(size, sigma); + + return convolve2D(image, kernel, { mode: 'same' }).matrix; + } + + /** + * Sharpen 2D image using unsharp masking (convolution-based) + */ + static sharpen2D(image: number[][], strength: number = 1.0): number[][] { + const sharpenKernel = [ + [0, -strength, 0], + [-strength, 1 + 4 * strength, -strength], + [0, -strength, 0] + ]; + + return convolve2D(image, sharpenKernel, { mode: 'same' }).matrix; + } + + /** + * Apply emboss effect using convolution + */ + static emboss2D(image: number[][], direction: 'ne' | 'nw' | 'se' | 'sw' = 'ne'): number[][] { + const embossKernels = { + ne: [[-2, -1, 0], [-1, 1, 1], [0, 1, 2]], + nw: [[0, -1, -2], [1, 1, -1], [2, 1, 0]], + se: [[0, 1, 2], [-1, 1, 1], [-2, -1, 0]], + sw: [[2, 1, 0], [1, 1, -1], [0, -1, -2]] + }; + + const kernel = embossKernels[direction]; + return convolve2D(image, kernel, { mode: 'same' }).matrix; + } + + /** + * Apply motion blur using directional convolution kernel + */ + static motionBlur(signal: number[], direction: number, length: number = 9): number[] { + // Create motion blur kernel + const kernel = new Array(length).fill(1 / length); + + return convolve1D(signal, kernel, { mode: 'same' }).values; + } + + /** + * Detect impulse response using convolution with known impulse + */ + static matchedFilter(signal: number[], template: number[]): number[] { + // Matched filter using cross-correlation (convolution with reversed template) + const reversedTemplate = [...template].reverse(); + return convolve1D(signal, reversedTemplate, { mode: 'same' }).values; + } + + /** + * Apply median filtering (note: not convolution-based, but commonly used with other filters) + */ + static medianFilter(signal: number[], windowSize: number = 3): number[] { + const result: number[] = []; + const halfWindow = Math.floor(windowSize / 2); + + for (let i = 0; i < signal.length; i++) { + const window: number[] = []; + + for (let j = Math.max(0, i - halfWindow); j <= Math.min(signal.length - 1, i + halfWindow); j++) { + window.push(signal[j]); + } + + window.sort((a, b) => a - b); + const median = window[Math.floor(window.length / 2)]; + result.push(median); + } + + return result; + } + + /** + * Cross-correlation using convolution + */ + static crossCorrelate(signal1: number[], signal2: number[]): number[] { + // Cross-correlation is convolution with one signal reversed + const reversedSignal2 = [...signal2].reverse(); + return convolve1D(signal1, reversedSignal2, { mode: 'full' }).values; + } + + /** + * Auto-correlation using convolution + */ + static autoCorrelate(signal: number[]): number[] { + return this.crossCorrelate(signal, signal); + } + + /** + * Detect peaks using convolution-based edge detection + */ + /** + * [REWRITTEN] Detects peaks (local maxima) in a 1D signal. + * This is a more robust method that directly finds local maxima. + */ + static detectPeaksConvolution(signal: number[], options: { + smoothWindow?: number; + threshold?: number; + minDistance?: number; + } = {}): { index: number; value: number }[] { + const { smoothWindow = 0, threshold = -Infinity, minDistance = 1 } = options; + + let processedSignal = signal; + // Optionally smooth the signal first to reduce noise + if (smoothWindow > 1) { + processedSignal = this.smooth(signal, { method: 'gaussian', windowSize: smoothWindow }); + } + + const peaks: { index: number; value: number }[] = []; + + // Find all points that are higher than their immediate neighbors + for (let i = 1; i < processedSignal.length - 1; i++) { + const prev = processedSignal[i - 1]; + const curr = processedSignal[i]; + const next = processedSignal[i + 1]; + + if (curr > prev && curr > next && curr > threshold) { + peaks.push({ index: i, value: signal[i] }); // Store index and ORIGINAL value + } + } + + // Check boundaries: Is the first or last point a peak? + if (processedSignal[0] > processedSignal[1] && processedSignal[0] > threshold) { + peaks.unshift({ index: 0, value: signal[0] }); + } + const last = processedSignal.length - 1; + if (processedSignal[last] > processedSignal[last - 1] && processedSignal[last] > threshold) { + peaks.push({ index: last, value: signal[last] }); + } + + // [CORRECTED LOGIC] Enforce minimum distance between peaks + if (minDistance < 2 || peaks.length <= 1) { + return peaks; + } + + // Sort peaks by value, highest first + peaks.sort((a, b) => b.value - a.value); + + const finalPeaks: { index: number; value: number }[] = []; + const removed = new Array(peaks.length).fill(false); + + for (let i = 0; i < peaks.length; i++) { + if (!removed[i]) { + finalPeaks.push(peaks[i]); + // Remove other peaks within the minimum distance + for (let j = i + 1; j < peaks.length; j++) { + if (!removed[j] && Math.abs(peaks[i].index - peaks[j].index) < minDistance) { + removed[j] = true; + } + } + } + } + + return finalPeaks.sort((a, b) => a.index - b.index); + } + + + /** + * [REWRITTEN] Detects valleys (local minima) in a 1D signal. + */ + static detectValleysConvolution(signal: number[], options: { + smoothWindow?: number; + threshold?: number; + minDistance?: number; + } = {}): { index: number; value: number }[] { + const invertedSignal = signal.map(x => -x); + const invertedThreshold = options.threshold !== undefined ? -options.threshold : undefined; + + const invertedPeaks = this.detectPeaksConvolution(invertedSignal, { ...options, threshold: invertedThreshold }); + + return invertedPeaks.map(peak => ({ + index: peak.index, + value: -peak.value, + })); + } + + /** + * Detect outliers using convolution-based methods + */ + /** + * [REWRITTEN] Detects outliers using more reliable and statistically sound methods. + */ + static detectOutliersConvolution(signal: number[], options: { + method?: 'local_deviation' | 'mean_diff'; + windowSize?: number; + threshold?: number; + } = {}): { index: number; value: number; outlierScore: number }[] { + const { method = 'local_deviation', windowSize = 7, threshold = 3.0 } = options; + + let outlierScores: number[]; + + switch (method) { + case 'mean_diff': + // Detects outliers by their difference from the local mean. + const meanKernel = ConvolutionKernels.average1D(windowSize); + const localMean = convolve1D(signal, meanKernel, { mode: 'same' }).values; + outlierScores = signal.map((val, i) => Math.abs(val - localMean[i])); + break; + + case 'local_deviation': + // A robust method using Z-score: how many local standard deviations away a point is. + const avgKernel = ConvolutionKernels.average1D(windowSize); + const localMeanValues = convolve1D(signal, avgKernel, { mode: 'same' }).values; + const squaredDiffs = signal.map((val, i) => (val - localMeanValues[i]) ** 2); + const localVar = convolve1D(squaredDiffs, avgKernel, { mode: 'same' }).values; + outlierScores = signal.map((val, i) => { + const std = Math.sqrt(localVar[i]); + return std > 1e-6 ? Math.abs(val - localMeanValues[i]) / std : 0; + }); + break; + + default: + throw new Error(`Unsupported outlier detection method: ${method}`); + } + + // Find points exceeding the threshold + const outliers: { index: number; value: number; outlierScore: number }[] = []; + outlierScores.forEach((score, i) => { + if (score > threshold) { + outliers.push({ + index: i, + value: signal[i], + outlierScore: score + }); + } + }); + + return outliers; + } + + /** + * Detect trend vertices (turning points) using convolution + */ + /** + * [CORRECTED] Detects trend vertices (turning points) by finding all peaks and valleys. + * This version fixes a bug that prevented valleys from being detected. + */ + static detectTrendVertices(signal: number[], options: { + smoothingWindow?: number; + threshold?: number; + minDistance?: number; + } = {}): { index: number; value: number; type: 'peak' | 'valley' }[] { + const { + smoothingWindow = 5, + threshold = 0, // CORRECTED: Changed default from -Infinity to a sensible 0 + minDistance = 3 + } = options; + + // Create the options object to pass down. The valley function will handle inverting the threshold itself. + const detectionOptions = { smoothingWindow, threshold, minDistance }; + + const peaks = this.detectPeaksConvolution(signal, detectionOptions).map(p => ({ ...p, type: 'peak' as const })); + const valleys = this.detectValleysConvolution(signal, detectionOptions).map(v => ({ ...v, type: 'valley' as const })); + + // Combine peaks and valleys and sort them by their index to get the sequence of trend changes + const vertices = [...peaks, ...valleys]; + vertices.sort((a, b) => a.index - b.index); + + return vertices; + } + + /** + * Detect vertices using curvature (second derivative) + */ + private static detectCurvatureVertices( + signal: number[], + threshold: number + ): { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] { + // Use second derivative kernel for curvature + const curvatureKernel = [1, -2, 1]; // Discrete Laplacian + const curvature = convolve1D(signal, curvatureKernel, { mode: 'same' }).values; + + const vertices: { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] = []; + + // Find zero crossings in curvature with sufficient magnitude + for (let i = 1; i < curvature.length - 1; i++) { + const prev = curvature[i - 1]; + const curr = curvature[i]; + const next = curvature[i + 1]; + + // Zero crossing detection + if ((prev > 0 && next < 0) || (prev < 0 && next > 0)) { + const curvatureMagnitude = Math.abs(curr); + + if (curvatureMagnitude > threshold) { + const type: 'peak' | 'valley' = prev > 0 ? 'peak' : 'valley'; + + vertices.push({ + index: i, + value: signal[i], + type, + curvature: curr + }); + } + } + } + + return vertices; + } + + /** + * Detect vertices using gradient sign changes + */ + private static detectSignChangeVertices( + signal: number[], + threshold: number + ): { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] { + // First derivative for gradient + const gradientKernel = [-0.5, 0, 0.5]; // Central difference + const gradient = convolve1D(signal, gradientKernel, { mode: 'same' }).values; + + // Second derivative for curvature + const curvatureKernel = [1, -2, 1]; + const curvature = convolve1D(signal, curvatureKernel, { mode: 'same' }).values; + + const vertices: { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] = []; + + // Find gradient sign changes + for (let i = 1; i < gradient.length - 1; i++) { + const prevGrad = gradient[i - 1]; + const nextGrad = gradient[i + 1]; + + // Check for sign change with sufficient gradient magnitude + if (Math.abs(prevGrad) > threshold && Math.abs(nextGrad) > threshold) { + if ((prevGrad > 0 && nextGrad < 0) || (prevGrad < 0 && nextGrad > 0)) { + const type: 'peak' | 'valley' = prevGrad > 0 ? 'peak' : 'valley'; + + vertices.push({ + index: i, + value: signal[i], + type, + curvature: curvature[i] + }); + } + } + } + + return vertices; + } + + /** + * Detect vertices using momentum changes + */ + private static detectMomentumVertices( + signal: number[], + threshold: number + ): { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] { + // Create momentum kernel (difference over larger window) + const momentumKernel = [-1, 0, 0, 0, 1]; // 4-point difference + const momentum = convolve1D(signal, momentumKernel, { mode: 'same' }).values; + + // Detect momentum reversals + const momentumGradient = convolve1D(momentum, [-0.5, 0, 0.5], { mode: 'same' }).values; + const curvature = convolve1D(signal, [1, -2, 1], { mode: 'same' }).values; + + const vertices: { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] = []; + + for (let i = 2; i < momentum.length - 2; i++) { + const prevMomentum = momentum[i - 1]; + const currMomentum = momentum[i]; + const nextMomentum = momentum[i + 1]; + + // Check for momentum reversal + if (Math.abs(momentumGradient[i]) > threshold) { + if ((prevMomentum > 0 && nextMomentum < 0) || (prevMomentum < 0 && nextMomentum > 0)) { + const type: 'peak' | 'valley' = prevMomentum > 0 ? 'peak' : 'valley'; + + vertices.push({ + index: i, + value: signal[i], + type, + curvature: curvature[i] + }); + } + } + } + + return vertices; + } + + /** + * Detect trend direction changes using convolution + */ + static detectTrendChanges(signal: number[], options: { + windowSize?: number; + threshold?: number; + minTrendLength?: number; + } = {}): { index: number; fromTrend: 'up' | 'down' | 'flat'; toTrend: 'up' | 'down' | 'flat'; strength: number }[] { + const { windowSize = 10, threshold = 0.01, minTrendLength = 5 } = options; + + // Calculate local trends using convolution with trend-detecting kernel + const trendKernel = new Array(windowSize).fill(0).map((_, i) => { + const center = windowSize / 2; + return (i - center) / (windowSize * windowSize / 12); // Linear trend kernel + }); + + const trends = convolve1D(signal, trendKernel, { mode: 'same' }).values; + + // Classify trends + const trendDirection = trends.map(t => { + if (t > threshold) return 'up'; + if (t < -threshold) return 'down'; + return 'flat'; + }); + + // Find trend changes + const changes: { index: number; fromTrend: 'up' | 'down' | 'flat'; toTrend: 'up' | 'down' | 'flat'; strength: number }[] = []; + + let currentTrend: 'up' | 'down' | 'flat' = trendDirection[0]; + let trendStart = 0; + + for (let i = 1; i < trendDirection.length; i++) { + if (trendDirection[i] !== currentTrend) { + const trendLength = i - trendStart; + + if (trendLength >= minTrendLength) { + changes.push({ + index: i, + fromTrend: currentTrend, + toTrend: trendDirection[i] as 'up' | 'down' | 'flat', + strength: Math.abs(trends[i] - trends[trendStart]) + }); + } + + currentTrend = trendDirection[i] as 'up' | 'down' | 'flat'; + trendStart = i; + } + } + + return changes; + } + + /** + * Enforce minimum distance between vertices + */ + private static enforceMinDistanceVertices( + vertices: { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[], + minDistance: number + ): { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] { + if (vertices.length <= 1) return vertices; + + // Sort by curvature magnitude (stronger vertices first) + const sorted = [...vertices].sort((a, b) => Math.abs(b.curvature) - Math.abs(a.curvature)); + const result: { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] = []; + + for (const vertex of sorted) { + let tooClose = false; + + for (const accepted of result) { + if (Math.abs(vertex.index - accepted.index) < minDistance) { + tooClose = true; + break; + } + } + + if (!tooClose) { + result.push(vertex); + } + } + + // Sort result by index + return result.sort((a, b) => a.index - b.index); + } + + /** + * Enforce minimum distance between detected features + */ + private static enforceMinDistanceConv( + features: { index: number; value: number; strength: number }[], + minDistance: number + ): { index: number; value: number; strength: number }[] { + if (features.length <= 1) return features; + + // Sort by strength (descending) + const sorted = [...features].sort((a, b) => b.strength - a.strength); + const result: { index: number; value: number; strength: number }[] = []; + + for (const feature of sorted) { + let tooClose = false; + + for (const accepted of result) { + if (Math.abs(feature.index - accepted.index) < minDistance) { + tooClose = true; + break; + } + } + + if (!tooClose) { + result.push(feature); + } + } + + // Sort result by index + return result.sort((a, b) => a.index - b.index); + } +} \ No newline at end of file