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:
raymond 2025-09-10 04:09:20 +00:00
parent 4cd58e04d4
commit e8e0e6de2a
3 changed files with 1932 additions and 175 deletions

View file

@ -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;
}
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.push(sum);
result[i] = sum;
}
return {

1801
server_convolution.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -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] });
}
return peaks;
// [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;
}
/**