server update, add endpoints
server update (server_convolution.ts) add endpoints of peaks detection, valleys detection, vertices (including peaks and valleys) detection, outliers detection
This commit is contained in:
parent
4cd58e04d4
commit
e8e0e6de2a
3 changed files with 1932 additions and 175 deletions
|
|
@ -99,6 +99,9 @@ function applyBoundary1D(signal: number[], padding: number, boundary: string): n
|
|||
* @param options - Convolution options (mode, boundary)
|
||||
* @returns Convolution result with metadata
|
||||
*/
|
||||
/**
|
||||
* [CORRECTED] Performs 1D convolution between signal and kernel
|
||||
*/
|
||||
export function convolve1D(
|
||||
signal: number[],
|
||||
kernel: number[],
|
||||
|
|
@ -107,40 +110,54 @@ export function convolve1D(
|
|||
validateArray(signal, 'Signal');
|
||||
validateArray(kernel, 'Kernel');
|
||||
|
||||
const { mode = 'same', boundary = 'reflect' } = options;
|
||||
|
||||
// Flip kernel for convolution (not correlation)
|
||||
const { mode = 'full', boundary = 'zero' } = options;
|
||||
const flippedKernel = [...kernel].reverse();
|
||||
|
||||
const signalLen = signal.length;
|
||||
const kernelLen = flippedKernel.length;
|
||||
|
||||
let result: number[] = [];
|
||||
let paddedSignal = signal;
|
||||
|
||||
// Apply boundary conditions based on mode
|
||||
if (mode === 'same' || mode === 'full') {
|
||||
const padding = mode === 'same' ? Math.floor(kernelLen / 2) : kernelLen - 1;
|
||||
paddedSignal = applyBoundary1D(signal, padding, boundary);
|
||||
}
|
||||
|
||||
// Perform convolution
|
||||
const outputLength = mode === 'full' ? signalLen + kernelLen - 1 :
|
||||
mode === 'same' ? signalLen :
|
||||
signalLen - kernelLen + 1;
|
||||
|
||||
const startIdx = mode === 'valid' ? 0 :
|
||||
mode === 'same' ? Math.floor(kernelLen / 2) : 0;
|
||||
const result: number[] = new Array(outputLength);
|
||||
|
||||
const halfKernelLen = Math.floor(kernelLen / 2);
|
||||
|
||||
for (let i = 0; i < outputLength; i++) {
|
||||
let sum = 0;
|
||||
for (let j = 0; j < kernelLen; j++) {
|
||||
const signalIdx = startIdx + i + j;
|
||||
if (signalIdx >= 0 && signalIdx < paddedSignal.length) {
|
||||
sum += paddedSignal[signalIdx] * flippedKernel[j];
|
||||
let signalIdx: number;
|
||||
|
||||
switch (mode) {
|
||||
case 'full':
|
||||
signalIdx = i - j;
|
||||
break;
|
||||
case 'same':
|
||||
signalIdx = i - halfKernelLen + j;
|
||||
break;
|
||||
case 'valid':
|
||||
signalIdx = i + j;
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle boundary conditions
|
||||
if (signalIdx >= 0 && signalIdx < signalLen) {
|
||||
sum += signal[signalIdx] * flippedKernel[j];
|
||||
} else if (boundary !== 'zero' && (mode === 'full' || mode === 'same')) {
|
||||
// This is a simplified boundary handler for the logic. Your more complex handler can be used here.
|
||||
let boundaryIdx = signalIdx;
|
||||
if (signalIdx < 0) {
|
||||
boundaryIdx = boundary === 'reflect' ? -signalIdx -1 : -signalIdx;
|
||||
} else if (signalIdx >= signalLen) {
|
||||
boundaryIdx = boundary === 'reflect' ? 2 * signalLen - signalIdx - 1 : 2 * signalLen - signalIdx - 2;
|
||||
}
|
||||
result.push(sum);
|
||||
boundaryIdx = Math.max(0, Math.min(signalLen - 1, boundaryIdx));
|
||||
sum += signal[boundaryIdx] * flippedKernel[j];
|
||||
}
|
||||
// If boundary is 'zero', we add nothing, which is correct.
|
||||
}
|
||||
result[i] = sum;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
1801
server_convolution.ts
Normal file
1801
server_convolution.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -223,26 +223,6 @@ export class SignalProcessor {
|
|||
return convolve1D(signal, reversedTemplate, { mode: 'same' }).values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Savitzky-Golay smoothing kernel
|
||||
*/
|
||||
private static createSavitzkyGolayKernel(windowSize: number, polyOrder: number): number[] {
|
||||
// Simplified Savitzky-Golay kernel generation
|
||||
// For a more complete implementation, you'd solve the least squares problem
|
||||
const halfWindow = Math.floor(windowSize / 2);
|
||||
const kernel: number[] = new Array(windowSize);
|
||||
|
||||
// For simplicity, use predetermined coefficients for common cases
|
||||
if (windowSize === 5 && polyOrder === 2) {
|
||||
return [-3, 12, 17, 12, -3].map(x => x / 35);
|
||||
} else if (windowSize === 7 && polyOrder === 2) {
|
||||
return [-2, 3, 6, 7, 6, 3, -2].map(x => x / 21);
|
||||
} else {
|
||||
// Fallback to simple moving average
|
||||
return new Array(windowSize).fill(1 / windowSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply median filtering (note: not convolution-based, but commonly used with other filters)
|
||||
*/
|
||||
|
|
@ -284,145 +264,123 @@ export class SignalProcessor {
|
|||
/**
|
||||
* 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: {
|
||||
method?: 'gradient' | 'laplacian' | 'dog';
|
||||
smoothWindow?: number;
|
||||
threshold?: number;
|
||||
minDistance?: number;
|
||||
} = {}): { index: number; value: number; strength: number }[] {
|
||||
const { method = 'gradient', threshold = 0.1, minDistance = 1 } = options;
|
||||
} = {}): { index: number; value: number }[] {
|
||||
const { smoothWindow = 0, threshold = -Infinity, minDistance = 1 } = options;
|
||||
|
||||
let edgeResponse: number[];
|
||||
|
||||
switch (method) {
|
||||
case 'gradient':
|
||||
// First derivative to detect edges (peaks are positive edges)
|
||||
const gradientKernel = [-1, 0, 1]; // Simple gradient
|
||||
edgeResponse = convolve1D(signal, gradientKernel, { mode: 'same' }).values;
|
||||
break;
|
||||
|
||||
case 'laplacian':
|
||||
// Second derivative to detect peaks (zero crossings)
|
||||
const laplacianKernel = [1, -2, 1]; // 1D Laplacian
|
||||
edgeResponse = convolve1D(signal, laplacianKernel, { mode: 'same' }).values;
|
||||
break;
|
||||
|
||||
case 'dog':
|
||||
// Difference of Gaussians for multi-scale peak detection
|
||||
const sigma1 = 1.0;
|
||||
const sigma2 = 1.6;
|
||||
const size = 9;
|
||||
const gauss1 = ConvolutionKernels.gaussian1D(size, sigma1);
|
||||
const gauss2 = ConvolutionKernels.gaussian1D(size, sigma2);
|
||||
const dogKernel = gauss1.map((g1, i) => g1 - gauss2[i]);
|
||||
edgeResponse = convolve1D(signal, dogKernel, { mode: 'same' }).values;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported peak detection method: ${method}`);
|
||||
let processedSignal = signal;
|
||||
// Optionally smooth the signal first to reduce noise
|
||||
if (smoothWindow > 1) {
|
||||
processedSignal = this.smooth(signal, { method: 'gaussian', windowSize: smoothWindow });
|
||||
}
|
||||
|
||||
// Find local maxima in edge response
|
||||
const peaks: { index: number; value: number; strength: number }[] = [];
|
||||
const peaks: { index: number; value: number }[] = [];
|
||||
|
||||
for (let i = 1; i < edgeResponse.length - 1; i++) {
|
||||
const current = edgeResponse[i];
|
||||
const left = edgeResponse[i - 1];
|
||||
const right = edgeResponse[i + 1];
|
||||
// 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];
|
||||
|
||||
// For gradient method, look for positive peaks
|
||||
// For Laplacian/DoG, look for zero crossings with positive slope
|
||||
let isPeak = false;
|
||||
let strength = 0;
|
||||
|
||||
if (method === 'gradient') {
|
||||
isPeak = current > left && current > right && current > threshold;
|
||||
strength = current;
|
||||
} else {
|
||||
// Zero crossing detection for Laplacian/DoG
|
||||
isPeak = left < 0 && right > 0 && Math.abs(current) < threshold;
|
||||
strength = Math.abs(current);
|
||||
}
|
||||
|
||||
if (isPeak) {
|
||||
peaks.push({
|
||||
index: i,
|
||||
value: signal[i],
|
||||
strength: strength
|
||||
});
|
||||
if (curr > prev && curr > next && curr > threshold) {
|
||||
peaks.push({ index: i, value: signal[i] }); // Store index and ORIGINAL value
|
||||
}
|
||||
}
|
||||
|
||||
// Apply minimum distance constraint
|
||||
if (minDistance > 1) {
|
||||
return this.enforceMinDistanceConv(peaks, minDistance);
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Detect valleys using convolution (inverted peak detection)
|
||||
* [REWRITTEN] Detects valleys (local minima) in a 1D signal.
|
||||
*/
|
||||
static detectValleysConvolution(signal: number[], options: {
|
||||
method?: 'gradient' | 'laplacian' | 'dog';
|
||||
smoothWindow?: number;
|
||||
threshold?: number;
|
||||
minDistance?: number;
|
||||
} = {}): { index: number; value: number; strength: number }[] {
|
||||
// Invert signal for valley detection
|
||||
} = {}): { index: number; value: number }[] {
|
||||
const invertedSignal = signal.map(x => -x);
|
||||
const valleys = this.detectPeaksConvolution(invertedSignal, options);
|
||||
const invertedThreshold = options.threshold !== undefined ? -options.threshold : undefined;
|
||||
|
||||
// Convert back to original scale
|
||||
return valleys.map(valley => ({
|
||||
...valley,
|
||||
value: -valley.value
|
||||
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?: 'gradient_variance' | 'median_diff' | 'local_deviation';
|
||||
method?: 'local_deviation' | 'mean_diff';
|
||||
windowSize?: number;
|
||||
threshold?: number;
|
||||
} = {}): { index: number; value: number; outlierScore: number }[] {
|
||||
const { method = 'gradient_variance', windowSize = 7, threshold = 2.0 } = options;
|
||||
const { method = 'local_deviation', windowSize = 7, threshold = 3.0 } = options;
|
||||
|
||||
let outlierScores: number[];
|
||||
|
||||
switch (method) {
|
||||
case 'gradient_variance':
|
||||
// Detect outliers using gradient variance
|
||||
const gradientKernel = [-1, 0, 1];
|
||||
const gradient = convolve1D(signal, gradientKernel, { mode: 'same' }).values;
|
||||
|
||||
// Convolve gradient with variance-detecting kernel
|
||||
const varianceKernel = new Array(windowSize).fill(1).map((_, i) => {
|
||||
const center = Math.floor(windowSize / 2);
|
||||
return (i - center) ** 2;
|
||||
});
|
||||
const normalizedVarianceKernel = varianceKernel.map(v => v / varianceKernel.reduce((s, x) => s + x, 0));
|
||||
|
||||
outlierScores = convolve1D(gradient.map(g => g * g), normalizedVarianceKernel, { mode: 'same' }).values;
|
||||
break;
|
||||
|
||||
case 'median_diff':
|
||||
// Detect outliers by difference from local median (approximated with convolution)
|
||||
const medianApproxKernel = ConvolutionKernels.gaussian1D(windowSize, windowSize / 6);
|
||||
const smoothed = convolve1D(signal, medianApproxKernel, { mode: 'same' }).values;
|
||||
outlierScores = signal.map((val, i) => Math.abs(val - smoothed[i]));
|
||||
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':
|
||||
// Detect outliers using local standard deviation approximation
|
||||
// A robust method using Z-score: how many local standard deviations away a point is.
|
||||
const avgKernel = ConvolutionKernels.average1D(windowSize);
|
||||
const localMean = convolve1D(signal, avgKernel, { mode: 'same' }).values;
|
||||
const squaredDiffs = signal.map((val, i) => (val - localMean[i]) ** 2);
|
||||
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 > 0 ? Math.abs(val - localMean[i]) / std : 0;
|
||||
return std > 1e-6 ? Math.abs(val - localMeanValues[i]) / std : 0;
|
||||
});
|
||||
break;
|
||||
|
||||
|
|
@ -430,7 +388,7 @@ export class SignalProcessor {
|
|||
throw new Error(`Unsupported outlier detection method: ${method}`);
|
||||
}
|
||||
|
||||
// Find points exceeding threshold
|
||||
// Find points exceeding the threshold
|
||||
const outliers: { index: number; value: number; outlierScore: number }[] = [];
|
||||
outlierScores.forEach((score, i) => {
|
||||
if (score > threshold) {
|
||||
|
|
@ -448,51 +406,32 @@ export class SignalProcessor {
|
|||
/**
|
||||
* 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: {
|
||||
method?: 'curvature' | 'sign_change' | 'momentum';
|
||||
smoothingWindow?: number;
|
||||
threshold?: number;
|
||||
minDistance?: number;
|
||||
} = {}): { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] {
|
||||
} = {}): { index: number; value: number; type: 'peak' | 'valley' }[] {
|
||||
const {
|
||||
method = 'curvature',
|
||||
smoothingWindow = 5,
|
||||
threshold = 0.001,
|
||||
threshold = 0, // CORRECTED: Changed default from -Infinity to a sensible 0
|
||||
minDistance = 3
|
||||
} = options;
|
||||
|
||||
// First, smooth the signal to reduce noise in trend detection
|
||||
const smoothed = this.smooth(signal, { method: 'gaussian', windowSize: smoothingWindow });
|
||||
// Create the options object to pass down. The valley function will handle inverting the threshold itself.
|
||||
const detectionOptions = { smoothingWindow, threshold, minDistance };
|
||||
|
||||
let vertices: { index: number; value: number; type: 'peak' | 'valley'; curvature: number }[] = [];
|
||||
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 }));
|
||||
|
||||
switch (method) {
|
||||
case 'curvature':
|
||||
vertices = this.detectCurvatureVertices(smoothed, threshold);
|
||||
break;
|
||||
// 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);
|
||||
|
||||
case 'sign_change':
|
||||
vertices = this.detectSignChangeVertices(smoothed, threshold);
|
||||
break;
|
||||
|
||||
case 'momentum':
|
||||
vertices = this.detectMomentumVertices(smoothed, threshold);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported vertex detection method: ${method}`);
|
||||
}
|
||||
|
||||
// Apply minimum distance constraint
|
||||
if (minDistance > 1) {
|
||||
vertices = this.enforceMinDistanceVertices(vertices, minDistance);
|
||||
}
|
||||
|
||||
// Map back to original signal values
|
||||
return vertices.map(v => ({
|
||||
...v,
|
||||
value: signal[v.index] // Use original signal value, not smoothed
|
||||
}));
|
||||
return vertices;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue