bowling-analysis-system
Version:
A comprehensive system for analyzing bowling techniques using video processing and metrics calculation
434 lines (368 loc) • 11.9 kB
JavaScript
/**
* @module core/utils/PatternDetection
* @description Utilities for detecting patterns in time series data
*/
const { getConfig } = require('./PatternDetectionConfig');
/**
* Find patterns in time series data
* @param {Array} data - Time series data array
* @param {Object} [options] - Detection options
* @param {string} [options.type='default'] - Type of pattern detection
* @returns {Object} Detected patterns (peaks, valleys, inflections)
*/
function findPatterns(data, options = {}) {
if (!Array.isArray(data) || data.length === 0) {
return { peaks: [], valleys: [], inflections: [] };
}
// Get configuration based on detection type
const config = getConfig(options.type || 'default', options);
// Filter out null/undefined values
const cleanData = data.map((value, index) => ({
value: value === null || value === undefined ? NaN : value,
index
})).filter(item => !isNaN(item.value));
if (cleanData.length < 3) {
return { peaks: [], valleys: [], inflections: [] };
}
// Apply smoothing if needed
const smoothedData = config.smoothingFactor > 0
? smoothData(cleanData, config.smoothingFactor)
: cleanData;
// Detect peaks, valleys, and inflections
const peaks = findPeaks(smoothedData, config);
const valleys = findValleys(smoothedData, config);
const inflections = config.detectInflections
? findInflectionPoints(smoothedData, config)
: [];
return {
peaks,
valleys,
inflections
};
}
/**
* Find peaks in time series data
* @private
* @param {Array} data - Cleaned time series data
* @param {Object} config - Detection configuration
* @returns {Array} Detected peaks
*/
function findPeaks(data, config) {
const peaks = [];
const windowSize = config.windowSize;
// Calculate data range for prominence calculation
const values = data.map(item => item.value);
const dataMin = Math.min(...values);
const dataMax = Math.max(...values);
const dataRange = dataMax - dataMin;
// Minimum prominence threshold
const minProminence = dataRange * config.minPeakProminence;
for (let i = windowSize; i < data.length - windowSize; i++) {
const currentValue = data[i].value;
let isPeak = true;
// Check if current point is higher than all points in window
for (let j = i - windowSize; j <= i + windowSize; j++) {
if (j !== i && data[j].value >= currentValue) {
isPeak = false;
break;
}
}
if (isPeak) {
// Calculate prominence (height above highest saddle)
let leftMin = currentValue;
let rightMin = currentValue;
// Find minimum to the left
for (let j = i - 1; j >= 0; j--) {
if (data[j].value > data[j+1].value) {
// Found a rising edge, stop here
leftMin = data[j+1].value;
break;
}
if (j === 0) {
leftMin = data[0].value;
}
}
// Find minimum to the right
for (let j = i + 1; j < data.length; j++) {
if (data[j].value > data[j-1].value) {
// Found a rising edge, stop here
rightMin = data[j-1].value;
break;
}
if (j === data.length - 1) {
rightMin = data[data.length - 1].value;
}
}
// Prominence is height above highest saddle
const saddleHeight = Math.max(leftMin, rightMin);
const prominence = currentValue - saddleHeight;
// Only add peaks with sufficient prominence
if (prominence >= minProminence) {
peaks.push({
index: data[i].index,
value: currentValue,
prominence
});
}
}
}
// Filter peaks by minimum distance
return filterByDistance(peaks, config.minDistance);
}
/**
* Find valleys in time series data
* @private
* @param {Array} data - Cleaned time series data
* @param {Object} config - Detection configuration
* @returns {Array} Detected valleys
*/
function findValleys(data, config) {
const valleys = [];
const windowSize = config.windowSize;
// Calculate data range for prominence calculation
const values = data.map(item => item.value);
const dataMin = Math.min(...values);
const dataMax = Math.max(...values);
const dataRange = dataMax - dataMin;
// Minimum prominence threshold
const minProminence = dataRange * config.minValleyProminence;
for (let i = windowSize; i < data.length - windowSize; i++) {
const currentValue = data[i].value;
let isValley = true;
// Check if current point is lower than all points in window
for (let j = i - windowSize; j <= i + windowSize; j++) {
if (j !== i && data[j].value <= currentValue) {
isValley = false;
break;
}
}
if (isValley) {
// Calculate prominence (depth below lowest saddle)
let leftMax = currentValue;
let rightMax = currentValue;
// Find maximum to the left
for (let j = i - 1; j >= 0; j--) {
if (data[j].value < data[j+1].value) {
// Found a falling edge, stop here
leftMax = data[j+1].value;
break;
}
if (j === 0) {
leftMax = data[0].value;
}
}
// Find maximum to the right
for (let j = i + 1; j < data.length; j++) {
if (data[j].value < data[j-1].value) {
// Found a falling edge, stop here
rightMax = data[j-1].value;
break;
}
if (j === data.length - 1) {
rightMax = data[data.length - 1].value;
}
}
// Prominence is depth below lowest saddle
const saddleHeight = Math.min(leftMax, rightMax);
const prominence = saddleHeight - currentValue;
// Only add valleys with sufficient prominence
if (prominence >= minProminence) {
valleys.push({
index: data[i].index,
value: currentValue,
prominence
});
}
}
}
// Filter valleys by minimum distance
return filterByDistance(valleys, config.minDistance);
}
/**
* Find inflection points in time series data
* @private
* @param {Array} data - Cleaned time series data
* @param {Object} config - Detection configuration
* @returns {Array} Detected inflection points
*/
function findInflectionPoints(data, config) {
const inflections = [];
// Calculate derivatives
const derivatives = [];
for (let i = 1; i < data.length; i++) {
derivatives.push({
index: data[i].index,
value: data[i].value - data[i-1].value
});
}
if (derivatives.length < 3) {
return inflections;
}
// Calculate data range for threshold calculation
const values = data.map(item => item.value);
const dataMin = Math.min(...values);
const dataMax = Math.max(...values);
const dataRange = dataMax - dataMin;
// Threshold for significant inflection
const threshold = dataRange * config.inflectionThreshold;
// Find sign changes in derivative (inflection points)
for (let i = 1; i < derivatives.length; i++) {
const prevDerivative = derivatives[i-1].value;
const currDerivative = derivatives[i].value;
// Check for sign change
if ((prevDerivative > 0 && currDerivative < 0) ||
(prevDerivative < 0 && currDerivative > 0)) {
// Calculate significance of inflection
const significance = Math.abs(prevDerivative - currDerivative);
// Only add significant inflections
if (significance >= threshold) {
inflections.push({
index: derivatives[i].index,
value: data[i].value,
significance
});
}
}
}
// Filter inflections by minimum distance
return filterByDistance(inflections, config.minDistance);
}
/**
* Filter features by minimum distance
* @private
* @param {Array} features - Array of detected features
* @param {number} minDistance - Minimum distance between features
* @returns {Array} Filtered features
*/
function filterByDistance(features, minDistance) {
if (features.length <= 1 || minDistance <= 1) {
return features;
}
// Sort features by prominence/significance (descending)
const sortedFeatures = [...features].sort((a, b) => {
const aValue = a.prominence || a.significance || 0;
const bValue = b.prominence || b.significance || 0;
return bValue - aValue;
});
const result = [];
const usedIndices = new Set();
// Keep most prominent features that are sufficiently far apart
for (const feature of sortedFeatures) {
let tooClose = false;
for (const usedIndex of usedIndices) {
if (Math.abs(feature.index - usedIndex) < minDistance) {
tooClose = true;
break;
}
}
if (!tooClose) {
result.push(feature);
usedIndices.add(feature.index);
}
}
// Sort result by index (ascending)
return result.sort((a, b) => a.index - b.index);
}
/**
* Apply smoothing to time series data
* @private
* @param {Array} data - Cleaned time series data
* @param {number} factor - Smoothing factor (0-1)
* @returns {Array} Smoothed data
*/
function smoothData(data, factor) {
if (factor <= 0 || data.length < 3) {
return data;
}
const smoothed = [data[0]];
for (let i = 1; i < data.length - 1; i++) {
const prev = data[i-1].value;
const curr = data[i].value;
const next = data[i+1].value;
// Apply weighted moving average
const smoothedValue = (prev * factor + curr * (1 - 2 * factor) + next * factor);
smoothed.push({
index: data[i].index,
value: smoothedValue
});
}
smoothed.push(data[data.length - 1]);
return smoothed;
}
/**
* Calculate statistics for a pattern
* @param {Array} pattern - Array of values
* @returns {Object} Statistical properties of the pattern
*/
function calculatePatternStatistics(pattern) {
if (!Array.isArray(pattern) || pattern.length === 0) {
return {
mean: 0,
standardDeviation: 0,
dispersion: 0,
range: 0,
min: 0,
max: 0
};
}
// Filter out any non-numeric values
const cleanPattern = pattern.filter(value =>
value !== null && value !== undefined && !isNaN(value)
);
if (cleanPattern.length === 0) {
return {
mean: 0,
standardDeviation: 0,
dispersion: 0,
range: 0,
min: 0,
max: 0
};
}
// Calculate mean
const mean = cleanPattern.reduce((sum, value) => sum + value, 0) / cleanPattern.length;
// Calculate standard deviation
const squaredDiffs = cleanPattern.map(value => Math.pow(value - mean, 2));
const variance = squaredDiffs.reduce((sum, value) => sum + value, 0) / cleanPattern.length;
const standardDeviation = Math.sqrt(variance);
// Calculate range
const min = Math.min(...cleanPattern);
const max = Math.max(...cleanPattern);
const range = max - min;
// Use raw standard deviation as dispersion measure instead of coefficient of variation
const dispersion = standardDeviation;
return {
mean,
standardDeviation,
dispersion,
range,
min,
max
};
}
/**
* Find the closest index in an array to a target value
* @param {Array} indices - Array of indices
* @param {number} targetIndex - Target index to find closest to
* @returns {number} Index of the closest value
*/
function findClosestIndex(indices, targetIndex) {
if (!Array.isArray(indices) || indices.length === 0) {
return -1;
}
let closestIndex = 0;
let minDistance = Math.abs(indices[0] - targetIndex);
for (let i = 1; i < indices.length; i++) {
const distance = Math.abs(indices[i] - targetIndex);
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
module.exports = {
findPatterns,
calculatePatternStatistics,
findClosestIndex
};