UNPKG

bowling-analysis-system

Version:

A comprehensive system for analyzing bowling techniques using video processing and metrics calculation

434 lines (368 loc) 11.9 kB
/** * @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 };