UNPKG

bowling-analysis-system

Version:

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

1,346 lines (1,116 loc) 91.9 kB
/** * @module bowling_analysis/processors/BiasCalculator * @description Unified implementation of bias calculation for event detection and analysis */ const path = require('path'); const fs = require('fs').promises; const { performance } = require('perf_hooks'); const { defaultLogger } = require('../../utils/logger'); const systemConfig = require('../../config/system-config'); const { getConfig: getDynamicConfig } = require('../../config/dynamic-config'); const { v4 } = require('uuid'); /** * Generate a unique identifier * @returns {string} UUID string * @private */ function generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * @class BiasCalculator * @description Unified implementation of bias calculation for event detection and analysis */ class BiasCalculator { /** * Create a new BiasCalculator * @param {Object} options - Configuration options */ constructor(options = {}) { this.options = { debug: false, dataDir: process.cwd(), biasFilePath: path.join(process.cwd(), 'bias.json'), generateBias: false, // Default to not generating bias ...options }; this.logger = options.logger || defaultLogger.child('BiasCalculator'); if (this.options.debug) { this.logger.setLevel('debug'); } this.metrics = null; this.biasData = null; this.timings = {}; // Initialize dynamic configuration this.dynamicConfig = getDynamicConfig({ logger: this.logger }); } /** * Execute the bias calculation * @param {Object} data - Input data including processed metrics * @param {Object} options - Processing options * @returns {Promise<Object>} Bias calculation results */ async execute(data, options = {}) { const startTime = performance.now(); try { const mergedOptions = { ...this.options, ...options }; this.logger.debug('Starting bias calculation'); // Validate input data if (!data || !data.metrics) { throw new Error('Invalid input data for bias calculation'); } this.metrics = data.metrics; // First, try to load existing bias data this.biasData = await this.loadBiasData(mergedOptions.biasFilePath); // If no bias data OR we are explicitly told to generate bias (setup mode) if (!this.biasData || mergedOptions.generateBias) { if (mergedOptions.generateBias) { this.logger.info('**GENERATING NEW BIAS DATA** as requested (setup mode)'); } else { this.logger.info('**NO EXISTING BIAS DATA FOUND**, generating new bias data'); } // Define moments file path const momentsFilePath = path.join(process.cwd(), 'moments.json'); // Generate bias from moments data (only in setup mode or first-time run) this.logger.debug('Generating bias data from moments.json'); this.biasData = await this.calculateBiasFromMoments(this.metrics, momentsFilePath); // Save generated bias data if (this.biasData) { await this.saveBiasData(this.biasData, mergedOptions.biasFilePath); this.logger.info(`**SAVED NEWLY GENERATED BIAS DATA** to ${mergedOptions.biasFilePath}`); } } else { this.logger.info(`**USING EXISTING BIAS DATA** from ${mergedOptions.biasFilePath}`); } if (!this.biasData) { throw new Error('Failed to load or generate bias data'); } // Debug log point - complete bias calculation this.logger.debug('Bias calculation completed, generating simulated moments'); // Generate simulated moments based on bias correlations and phase 1 metrics const simulatedMoments = this.generateSimulatedMoments(this.metrics, this.biasData); // Debug log point - simulated moments this.logger.debug(`Generated ${Object.keys(simulatedMoments || {}).length} simulated moment types`); // Detect events using bias data and the simulated moments this.logger.debug('Detecting events with bias and simulated moments'); const events = this.detectEventsWithBias(this.metrics, this.biasData, simulatedMoments); // Debug log point - events detected this.logger.debug(`Detected ${Object.keys(events || {}).length} events`); const result = { bias: this.biasData, events, simulatedMoments, metadata: { timings: { biasCalculation: performance.now() - startTime } } }; this.logger.debug(`Bias calculation completed in ${result.metadata.timings.biasCalculation.toFixed(2)}ms`); return result; } catch (error) { this.logger.error(`Bias calculation failed: ${error.message}`); this.logger.error(error.stack || 'No stack trace available'); throw error; } } /** * Load bias data from file * @param {string} biasFilePath - Path to bias file * @returns {Promise<Object>} Loaded bias data */ async loadBiasData(biasFilePath) { const startTime = performance.now(); try { this.logger.debug(`Loading bias data from ${biasFilePath}`); const data = await fs.readFile(biasFilePath, 'utf8'); this.biasData = JSON.parse(data); // Validate loaded bias data if (!this.biasData.momentCorrelations) { this.logger.warn('Invalid bias data: missing momentCorrelations'); this.biasData = null; } this.timings.loadBiasData = performance.now() - startTime; return this.biasData; } catch (error) { this.logger.warn(`Could not load bias data: ${error.message}`); this.timings.loadBiasData = performance.now() - startTime; this.biasData = null; return null; } } /** * Save bias data to file * @param {Object} biasData - Bias data * @param {string} filePath - Path to save file * @returns {Promise<boolean>} Success indicator */ async saveBiasData(biasData, filePath) { const startTime = performance.now(); try { // Ensure we have a valid path if (!filePath) { filePath = path.join(process.cwd(), 'bias.json'); } // Make sure it's an absolute path if (!path.isAbsolute(filePath)) { filePath = path.join(process.cwd(), filePath); } // Ensure the directory exists const directory = path.dirname(filePath); try { await fs.mkdir(directory, { recursive: true }); } catch (err) { if (err.code !== 'EEXIST') { throw err; } } this.logger.debug(`Saving bias data to ${filePath}`); await fs.writeFile(filePath, JSON.stringify(biasData, null, 2), 'utf8'); this.logger.info(`Bias data successfully saved to ${filePath}`); this.timings.saveBiasData = performance.now() - startTime; return true; } catch (error) { this.logger.error(`Failed to save bias data: ${error.message}`); this.logger.error(`Error details: ${error.stack || 'No stack trace'}`); this.timings.saveBiasData = performance.now() - startTime; return false; } } /** * Calculate bias using moments data * @param {Object} metrics - Metrics data * @param {string} momentsFilePath - Optional path to moments file * @returns {Promise<Object>} Bias data based on moments */ async calculateBiasFromMoments(metrics, momentsFilePath = null) { const startTime = performance.now(); this.metrics = metrics; try { // Initialize bias object const bias = { id: v4(), createdAt: new Date().toISOString(), momentCorrelations: {}, version: "2.0" }; // Load moments data const momentsData = await this.loadMomentsData(momentsFilePath); // Extract time series data const allMetrics = this.flattenMetricsToMap(metrics); // Log available metric categories for debugging const metricCategories = Object.keys(allMetrics).reduce((categories, path) => { const category = path.split('.')[0]; if (!categories.includes(category)) { categories.push(category); } return categories; }, []); this.logger.debug(`Available metric categories: ${metricCategories.join(', ')}`); // Check for essential metrics for key events this._ensureEssentialMetrics(allMetrics); // If no moments data available, create synthetic moments based on keypoints if (!momentsData || !momentsData.moments || Object.keys(momentsData.moments).length === 0) { this.logger.warn('No moments data available, creating synthetic moments from keypoint data'); // Create synthetic moments const syntheticMoments = this._createSyntheticMomentsFromKeypoints(metrics); if (syntheticMoments && Object.keys(syntheticMoments).length > 0) { // Use synthetic moments instead this.logger.info(`Created synthetic moments with ${Object.keys(syntheticMoments).length} types`); // Process the synthetic moments the same way we would process loaded moments for (const momentType of Object.keys(syntheticMoments)) { // Get the frame arrays const momentFrames = syntheticMoments[momentType]; if (!momentFrames || !Array.isArray(momentFrames) || momentFrames.length === 0) { continue; } // Calculate correlations for this moment type const correlations = this._calculateCorrelationsForFrames(allMetrics, momentType, momentFrames); // Store in bias if we have correlations if (correlations && correlations.length > 0) { bias.momentCorrelations[momentType] = correlations; this.logger.info(`Found ${correlations.length} correlations for synthetic ${momentType}`); } } const endTime = performance.now(); this.logger.info(`Bias calculation from synthetic moments completed in ${(endTime - startTime).toFixed(2)}ms`); return bias; } else { this.logger.warn('Failed to create synthetic moments, using direct metric analysis'); return this.calculateBias(metrics); } } const moments = momentsData.moments; // Process each moment type for (const momentType of Object.keys(moments)) { this.logger.debug(`Processing moment type: ${momentType}`); const momentFrames = moments[momentType]; // Skip if no frames if (!momentFrames || !Array.isArray(momentFrames) || momentFrames.length === 0) { this.logger.warn(`No frames defined for moment type: ${momentType}`); continue; } // Calculate correlations for this moment's frames const correlations = this._calculateCorrelationsForFrames(allMetrics, momentType, momentFrames); // Store in bias under momentCorrelations bias.momentCorrelations[momentType] = correlations; this.logger.info(`Found ${correlations.length} significant correlations for ${momentType}`); } const endTime = performance.now(); this.logger.info(`Bias calculation from moments completed in ${(endTime - startTime).toFixed(2)}ms`); return bias; } catch (error) { this.logger.error(`Error calculating bias from moments: ${error.message}`); this.logger.error(error.stack); // Throw error instead of using fallback throw new Error(`Failed to calculate bias from moments: ${error.message}`); } } /** * Ensure essential metrics are available for event detection * @param {Object} allMetrics - Flattened metrics map * @private */ _ensureEssentialMetrics(allMetrics) { // Check for wrist velocity metrics const hasLeftWristVelocity = Object.keys(allMetrics).some(path => path.includes('velocity') && path.includes('wrist') && path.includes('left') ); const hasRightWristVelocity = Object.keys(allMetrics).some(path => path.includes('velocity') && path.includes('wrist') && path.includes('right') ); if (!hasLeftWristVelocity || !hasRightWristVelocity) { this.logger.warn(`Missing wrist velocity metrics: left=${hasLeftWristVelocity}, right=${hasRightWristVelocity}`); // Look for position data to derive velocity const leftWristPosition = Object.keys(allMetrics).find(path => path.includes('position') && path.includes('wrist') && path.includes('left') ); const rightWristPosition = Object.keys(allMetrics).find(path => path.includes('position') && path.includes('wrist') && path.includes('right') ); if (leftWristPosition && allMetrics[leftWristPosition]) { this.logger.info(`Deriving left wrist velocity from position data`); allMetrics['velocity.left.wrist'] = this._deriveVelocityFromPosition(allMetrics[leftWristPosition]); } if (rightWristPosition && allMetrics[rightWristPosition]) { this.logger.info(`Deriving right wrist velocity from position data`); allMetrics['velocity.right.wrist'] = this._deriveVelocityFromPosition(allMetrics[rightWristPosition]); } } // Check for foot metrics const hasLeftFootMetrics = Object.keys(allMetrics).some(path => (path.includes('velocity') || path.includes('position')) && (path.includes('foot') || path.includes('ankle')) && path.includes('left') ); const hasRightFootMetrics = Object.keys(allMetrics).some(path => (path.includes('velocity') || path.includes('position')) && (path.includes('foot') || path.includes('ankle')) && path.includes('right') ); if (!hasLeftFootMetrics || !hasRightFootMetrics) { this.logger.warn(`Missing foot metrics: left=${hasLeftFootMetrics}, right=${hasRightFootMetrics}`); // Look for ankle or heel position data to substitute const leftAnklePosition = Object.keys(allMetrics).find(path => path.includes('position') && (path.includes('ankle') || path.includes('heel')) && path.includes('left') ); const rightAnklePosition = Object.keys(allMetrics).find(path => path.includes('position') && (path.includes('ankle') || path.includes('heel')) && path.includes('right') ); if (leftAnklePosition && allMetrics[leftAnklePosition]) { this.logger.info(`Using ${leftAnklePosition} as substitute for left foot metrics`); allMetrics['position.left.foot'] = allMetrics[leftAnklePosition]; allMetrics['velocity.left.foot'] = this._deriveVelocityFromPosition(allMetrics[leftAnklePosition]); } if (rightAnklePosition && allMetrics[rightAnklePosition]) { this.logger.info(`Using ${rightAnklePosition} as substitute for right foot metrics`); allMetrics['position.right.foot'] = allMetrics[rightAnklePosition]; allMetrics['velocity.right.foot'] = this._deriveVelocityFromPosition(allMetrics[rightAnklePosition]); } } } /** * Derive velocity from position data * @param {Array} positionData - Array of position values * @returns {Array} Derived velocity values * @private */ _deriveVelocityFromPosition(positionData) { if (!positionData || !Array.isArray(positionData) || positionData.length < 2) { return []; } const velocities = [0]; // First frame has no previous frame to calculate velocity for (let i = 1; i < positionData.length; i++) { // Skip if either position is null/undefined if (positionData[i] === null || positionData[i] === undefined || positionData[i-1] === null || positionData[i-1] === undefined) { velocities.push(0); continue; } // Calculate velocity as change in position const velocity = positionData[i] - positionData[i-1]; velocities.push(velocity); } return velocities; } /** * Calculate correlations for a set of moment frames * @param {Object} allMetrics - Flattened metrics map * @param {string} momentType - Type of moment * @param {Array<number>} frames - Array of frame indices * @returns {Array} Array of correlations * @private */ _calculateCorrelationsForFrames(allMetrics, momentType, frames) { try { const correlations = []; // Log the metrics being processed this.logger.debug(`Processing ${Object.keys(allMetrics).length} metrics for ${momentType} with ${frames.length} frames`); // Get priority categories for this event/moment type const priorityCategories = this._getPriorityCategoriesForEvent(momentType); // Process each metric with the frames for (const [metricPath, timeSeriesData] of Object.entries(allMetrics)) { // Skip invalid time series if (!timeSeriesData || !Array.isArray(timeSeriesData) || timeSeriesData.length === 0) { continue; } const category = metricPath.split('.')[0]; // Skip low-priority metrics to focus on most relevant ones if (!priorityCategories.includes(category) && // Unless it explicitly mentions parts relevant to this event type !this._isHighlyRelevantMetric(metricPath, momentType)) { continue; } // Calculate correlation for this metric with all moment frames const correlation = this._calculateMetricMomentCorrelation( timeSeriesData, frames, metricPath, momentType ); // Add if significant if (correlation && correlation.significance > 0.1) { correlations.push(correlation); } } // Sort correlations by significance correlations.sort((a, b) => b.significance - a.significance); // Get top correlations with diversity return this._ensureMetricTypeDiversity(correlations, 30); } catch (error) { this.logger.error(`Error calculating correlations for ${momentType}: ${error.message}`); return []; } } /** * Check if a metric is highly relevant to a specific event type * @param {string} metricPath - Metric path * @param {string} eventType - Event type * @returns {boolean} Whether the metric is highly relevant * @private */ _isHighlyRelevantMetric(metricPath, eventType) { const metricLower = metricPath.toLowerCase(); if (eventType === systemConfig.EVENT_NAMES.RELEASE_POINT) { return metricLower.includes('wrist') || metricLower.includes('hand') || metricLower.includes('arm') || metricLower.includes('elbow') || metricLower.includes('release'); } else if (eventType === systemConfig.EVENT_NAMES.FRONT_FOOT_LANDING) { return (metricLower.includes('foot') || metricLower.includes('ankle')) && (metricLower.includes('left') || metricLower.includes('front')); } else if (eventType === systemConfig.EVENT_NAMES.BACK_FOOT_LANDING) { return (metricLower.includes('foot') || metricLower.includes('ankle')) && (metricLower.includes('right') || metricLower.includes('back')); } return false; } /** * Create synthetic moments from keypoint data * @param {Object} metrics - Metrics data * @returns {Object} Synthetic moments data * @private */ _createSyntheticMomentsFromKeypoints(metrics) { try { const moments = {}; // Create moments for each required moment type const requiredMoments = [ 'ball_release', 'left_foot_plant', 'right_foot_plant' ]; for (const momentType of requiredMoments) { const frames = this._createSyntheticMomentsForMomentType(metrics, momentType); if (frames && frames.length > 0) { moments[momentType] = frames; } } return moments; } catch (error) { this.logger.error(`Error creating synthetic moments: ${error.message}`); // Create some default moments as a fallback const totalFrames = metrics?.frameCount || 100; return { 'ball_release': [ Math.floor(totalFrames * 0.8) - 1, Math.floor(totalFrames * 0.8), Math.floor(totalFrames * 0.8) + 1 ], 'left_foot_plant': [ Math.floor(totalFrames * 0.65) - 1, Math.floor(totalFrames * 0.65), Math.floor(totalFrames * 0.65) + 1 ], 'right_foot_plant': [ Math.floor(totalFrames * 0.5) - 1, Math.floor(totalFrames * 0.5), Math.floor(totalFrames * 0.5) + 1 ] }; } } /** * Create synthetic moments for a specific moment type * @param {Object} metrics - Metrics data * @param {string} momentType - Moment type * @returns {Array<number>} Array of frame indices * @private */ _createSyntheticMomentsForMomentType(metrics, momentType) { try { // Extract time series data const allMetrics = this.flattenMetricsToMap(metrics); // Get candidate frames based on moment type let candidateFrames = []; if (momentType === 'ball_release') { // For ball release, look for peaks in wrist velocity candidateFrames = this._findReleasePointFrames(allMetrics); } else if (momentType === 'left_foot_plant') { // For left foot plant, look for left foot planting candidateFrames = this._findFrontFootLandingFrames(allMetrics); } else if (momentType === 'right_foot_plant') { // For right foot plant, look for right foot planting candidateFrames = this._findBackFootLandingFrames(allMetrics); } if (candidateFrames.length === 0) { this.logger.warn(`No candidate frames found for ${momentType}, using frame estimates`); // Determine a frame based on typical bowling sequence const totalFrames = metrics.frameCount || (metrics.timeSeries && Object.values(metrics.timeSeries)[0] ? Object.values(Object.values(metrics.timeSeries)[0])[0].length : 100); if (momentType === 'ball_release') { // Generate a small window around the estimated frame const centerFrame = Math.floor(totalFrames * 0.8); candidateFrames = [centerFrame - 1, centerFrame, centerFrame + 1, centerFrame + 2]; } else if (momentType === 'left_foot_plant') { const centerFrame = Math.floor(totalFrames * 0.65); candidateFrames = [centerFrame - 2, centerFrame - 1, centerFrame, centerFrame + 1, centerFrame + 2]; } else if (momentType === 'right_foot_plant') { const centerFrame = Math.floor(totalFrames * 0.5); candidateFrames = [centerFrame - 2, centerFrame - 1, centerFrame, centerFrame + 1, centerFrame + 2]; } } return candidateFrames; } catch (error) { this.logger.error(`Error creating synthetic moments for ${momentType}: ${error.message}`); return []; } } /** * Find release point frames from metrics * @param {Object} allMetrics - Flattened metrics map * @returns {Array<number>} Array of candidate frames * @private */ _findReleasePointFrames(allMetrics) { this.logger.debug('Finding potential release point frames from metrics'); if (!allMetrics || !allMetrics.timeSeries) { this.logger.warn('No time series data available for finding release point frames'); return []; } // Initialize dynamic configuration with metrics data if (allMetrics && allMetrics.metadata) { this.dynamicConfig.initialize({ totalFrames: allMetrics.metadata.totalFrames || 0, validFrames: allMetrics.metadata.validFrames || 0 }); } const timeSeriesData = allMetrics.timeSeries; // Collect all potential frames const potentialFrames = []; // First, look for ball velocity peaks - strongest indicator if (timeSeriesData.velocity && timeSeriesData.velocity.ballVelocity) { const ballVelocities = timeSeriesData.velocity.ballVelocity; // Find peaks in ball velocity - good indicator of release const velocityThreshold = this.dynamicConfig.get('thresholds.velocity.defaultLowVelocity', 0.5); for (let i = 1; i < ballVelocities.length - 1; i++) { // Check for velocity increasing then decreasing (a peak) if (ballVelocities[i] > velocityThreshold && ballVelocities[i] > ballVelocities[i-1] && ballVelocities[i] >= ballVelocities[i+1]) { potentialFrames.push({ frame: i, confidence: 0.9, source: 'ball_velocity_peak' }); } } } // Second approach: look in the later part of the motion for arm extension if (timeSeriesData.angles && timeSeriesData.angles.elbowAngle && timeSeriesData.angles.elbowAngle.right) { const elbowAngles = timeSeriesData.angles.elbowAngle.right; // Get the frame for checking the second half of motion const totalFrames = elbowAngles.length; const startFrame = this.dynamicConfig.getFrameIndex('releasePoint'); // Only search if we have enough frames if (totalFrames > 20 && startFrame > 10) { // Look for sudden increases in elbow angle (arm extension) for (let i = startFrame; i < Math.min(totalFrames - 1, startFrame + 20); i++) { if (elbowAngles[i+1] - elbowAngles[i] > 5) { // Significant increase in one frame potentialFrames.push({ frame: i, confidence: 0.8, source: 'elbow_extension' }); } } } } // Third approach: try wrist acceleration if (timeSeriesData.acceleration && timeSeriesData.acceleration.wristAcceleration && timeSeriesData.acceleration.wristAcceleration.right) { const wristAccel = timeSeriesData.acceleration.wristAcceleration.right; // Get frame percentage for the second half of motion const totalFrames = wristAccel.length; const startFrame = this.dynamicConfig.getFrameIndex('releasePoint'); // Only search if we have enough frames if (totalFrames > 20 && startFrame > 10) { // Look for peaks in wrist acceleration for (let i = startFrame; i < Math.min(totalFrames - 1, startFrame + 20); i++) { if (i > 0 && wristAccel[i] > wristAccel[i-1] && wristAccel[i] > wristAccel[i+1]) { potentialFrames.push({ frame: i, confidence: 0.7, source: 'wrist_acceleration_peak' }); } } } } // Return all potential frames return potentialFrames.map(pf => pf.frame); } /** * Find front foot landing frames from metrics * @param {Object} allMetrics - Flattened metrics map * @returns {Array<number>} Array of candidate frames * @private */ _findFrontFootLandingFrames(allMetrics) { this.logger.debug('Finding potential front foot landing frames from metrics'); if (!allMetrics || !allMetrics.timeSeries) { this.logger.warn('No time series data available for finding front foot landing frames'); return []; } // Initialize dynamic configuration with metrics data if (allMetrics && allMetrics.metadata) { this.dynamicConfig.initialize({ totalFrames: allMetrics.metadata.totalFrames || 0, validFrames: allMetrics.metadata.validFrames || 0 }); } const timeSeriesData = allMetrics.timeSeries; // Collect all potential frames const potentialFrames = []; // First, look for foot velocity minima - strongest indicator if (timeSeriesData.velocity && (timeSeriesData.velocity.footVelocities || timeSeriesData.velocity.ankleVelocities)) { const footVelocities = (timeSeriesData.velocity.footVelocities && timeSeriesData.velocity.footVelocities.left) || (timeSeriesData.velocity.ankleVelocities && timeSeriesData.velocity.ankleVelocities.left); if (footVelocities) { // Get dynamic threshold for low velocity const velocityThreshold = this.dynamicConfig.getLowVelocityThreshold(footVelocities); // Calculate start frame based on percentage of total frames const totalFrames = footVelocities.length; const startFrame = this.dynamicConfig.getFrameIndex('frontFootLanding'); // Start searching from middle part of the motion const searchStart = Math.max(10, startFrame - 20); const searchEnd = Math.min(totalFrames - 1, startFrame + 20); // Look for low foot velocity in the expected region for (let i = searchStart; i < searchEnd; i++) { if (footVelocities[i] < velocityThreshold) { potentialFrames.push({ frame: i, confidence: 0.9, source: 'foot_velocity_minimum' }); } } } } // Return all potential frames return potentialFrames.map(pf => pf.frame); } /** * Find back foot landing frames from metrics * @param {Object} allMetrics - Flattened metrics map * @returns {Array<number>} Array of candidate frames * @private */ _findBackFootLandingFrames(allMetrics) { this.logger.debug('Finding potential back foot landing frames from metrics'); if (!allMetrics || !allMetrics.timeSeries) { this.logger.warn('No time series data available for finding back foot landing frames'); return []; } // Initialize dynamic configuration with metrics data if (allMetrics && allMetrics.metadata) { this.dynamicConfig.initialize({ totalFrames: allMetrics.metadata.totalFrames || 0, validFrames: allMetrics.metadata.validFrames || 0 }); } const timeSeriesData = allMetrics.timeSeries; // Collect all potential frames const potentialFrames = []; // First, look for foot velocity minima - strongest indicator if (timeSeriesData.velocity && (timeSeriesData.velocity.footVelocities || timeSeriesData.velocity.ankleVelocities)) { const footVelocities = (timeSeriesData.velocity.footVelocities && timeSeriesData.velocity.footVelocities.right) || (timeSeriesData.velocity.ankleVelocities && timeSeriesData.velocity.ankleVelocities.right); if (footVelocities) { // Get dynamic threshold for low velocity const velocityThreshold = this.dynamicConfig.getLowVelocityThreshold(footVelocities); // Calculate start frame based on percentage of total frames const totalFrames = footVelocities.length; const startFrame = this.dynamicConfig.getFrameIndex('backFootLanding'); // Start searching from first part of the motion const searchStart = Math.max(0, startFrame - 20); const searchEnd = Math.min(totalFrames - 1, startFrame + 20); // Look for low foot velocity in the expected region for (let i = searchStart; i < searchEnd; i++) { if (footVelocities[i] < velocityThreshold) { potentialFrames.push({ frame: i, confidence: 0.9, source: 'foot_velocity_minimum' }); } } } } // Return all potential frames return potentialFrames.map(pf => pf.frame); } /** * Calculate variance of data * @param {Array} data - Array of values * @returns {number} Variance * @private */ _calculateVariance(data) { // Filter out null/undefined values const validData = data.filter(v => v !== null && v !== undefined); if (validData.length < 2) { return 0; } // Calculate mean const mean = validData.reduce((sum, val) => sum + val, 0) / validData.length; // Calculate variance return validData.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / validData.length; } /** * Load moments data from file or create synthetic moments * @param {string} momentsFilePath - Path to moments file * @returns {Promise<Object>} Loaded moments data */ async loadMomentsData(momentsFilePath) { const startTime = performance.now(); try { // Check if file exists try { await fs.access(momentsFilePath); // File exists, load it this.logger.debug(`Loading moments data from ${momentsFilePath}`); const data = await fs.readFile(momentsFilePath, 'utf8'); let moments = JSON.parse(data); // Ensure all required moment types are present const requiredMomentTypes = ['ball_release', 'left_foot_plant', 'right_foot_plant']; let allTypesPresent = true; for (const type of requiredMomentTypes) { if (!moments[type] || !Array.isArray(moments[type])) { this.logger.warn(`Missing or invalid ${type} in moments data`); allTypesPresent = false; } } if (!allTypesPresent) { this.logger.warn('Moments data is missing required types, creating synthetic moments'); moments = this._createSyntheticMoments(); } else { this.logger.info('Successfully loaded moments data from file'); } this.timings.loadMomentsData = performance.now() - startTime; return moments; } catch (fileError) { // File doesn't exist or other error this.logger.warn(`Could not access moments file: ${fileError.message}`); } // If we get here, we need to create synthetic moments this.logger.debug('Creating synthetic moments'); const syntheticMoments = this._createSyntheticMoments(); this.timings.loadMomentsData = performance.now() - startTime; return syntheticMoments; } catch (error) { this.logger.error(`Error loading moments data: ${error.message}`); this.timings.loadMomentsData = performance.now() - startTime; // Create synthetic moments as fallback this.logger.debug('Falling back to synthetic moments due to error'); return this._createSyntheticMoments(); } } /** * Calculate bias from metrics data * @param {Object} metrics - Metrics data * @returns {Object} Bias data */ calculateBias(metrics) { const startTime = performance.now(); this.metrics = metrics; try { // Initialize bias object const bias = { id: v4(), createdAt: new Date().toISOString(), momentCorrelations: {}, version: "2.0" }; // Get event frames for known events from metrics const events = metrics.events?.events || {}; const eventTypes = Object.keys(systemConfig.EVENT_NAMES); const knownEventTypes = Object.keys(events); // For each supported event type for (const eventTypeKey of eventTypes) { const eventType = systemConfig.EVENT_NAMES[eventTypeKey]; // Skip unsupported event types if (!eventType) continue; // Get event frame or estimate it let eventFrame; if (knownEventTypes.includes(eventType) && events[eventType]?.frame !== undefined) { eventFrame = events[eventType].frame; this.logger.debug(`Using known frame ${eventFrame} for ${eventType}`); } else { // If we don't have the event, estimate its frame const knownEvents = Object.entries(events) .filter(([key, event]) => event && event.frame !== undefined) .map(([key, event]) => ({ type: key, frame: event.frame })); // Sort events by frame knownEvents.sort((a, b) => a.frame - b.frame); // Calculate median frame as a fallback reference const medianFrame = knownEvents.length > 0 ? knownEvents[Math.floor(knownEvents.length / 2)].frame : Math.floor(metrics.frameCount / 2); // Estimate the frame for this event type based on known events eventFrame = this._estimateEventFrame(eventType, knownEvents, medianFrame); this.logger.debug(`Estimated frame ${eventFrame} for ${eventType}`); } // Convert all metrics to a map of time series by dot path const flatMetricsMap = this.flattenMetricsToMap(metrics); // Calculate correlations for this event type bias.momentCorrelations[eventType] = this.calculateAllMetricCorrelations(metrics, eventFrame, eventType); this.logger.info(`Found ${bias.momentCorrelations[eventType].length} correlations for ${eventType}`); } const endTime = performance.now(); this.logger.info(`Bias calculation completed in ${(endTime - startTime).toFixed(2)}ms`); return bias; } catch (error) { this.logger.error(`Error calculating bias: ${error.message}`); throw error; } } /** * Flatten metrics time series data into a map of dot-paths to time series arrays * @param {Object} metrics - Complete metrics object * @returns {Object} Map of dot paths to time series arrays */ flattenMetricsToMap(metrics) { const result = {}; try { if (!metrics || !metrics.timeSeries) { return result; } const timeSeries = metrics.timeSeries; // Process each category Object.keys(timeSeries).forEach(category => { const categoryData = timeSeries[category]; // Skip if category has no data if (!categoryData) return; // Process each metric in the category Object.keys(categoryData).forEach(metricKey => { const dotPath = `${category}.${metricKey}`; const timeSeriesData = categoryData[metricKey]; // Only include valid time series data if (Array.isArray(timeSeriesData) && timeSeriesData.length > 0) { result[dotPath] = timeSeriesData; } // Handle nested objects (for position, velocity, etc.) if (typeof timeSeriesData === 'object' && !Array.isArray(timeSeriesData)) { Object.keys(timeSeriesData).forEach(subKey => { const subData = timeSeriesData[subKey]; const subPath = `${dotPath}.${subKey}`; if (Array.isArray(subData) && subData.length > 0) { result[subPath] = subData; } // Handle doubly-nested objects (e.g., position.left.wrist) if (typeof subData === 'object' && !Array.isArray(subData)) { Object.keys(subData).forEach(deepKey => { const deepData = subData[deepKey]; const deepPath = `${subPath}.${deepKey}`; if (Array.isArray(deepData) && deepData.length > 0) { result[deepPath] = deepData; } }); } }); } }); }); return result; } catch (error) { this.logger.error(`Error flattening metrics: ${error.message}`); return result; } } /** * Calculate the variability of a pattern * @param {Array} pattern - Array of values * @returns {number} Raw variability score */ calculatePatternVariability(pattern) { // Skip if pattern is invalid if (!pattern || pattern.length < 3) { return 0; } try { // Filter out null/undefined values const validValues = pattern.filter(v => v !== null && v !== undefined); // Skip if not enough valid values if (validValues.length < 3) { return 0; } // Calculate mean const mean = validValues.reduce((sum, v) => sum + v, 0) / validValues.length; // Calculate variance (mean squared deviation) const variance = validValues.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / validValues.length; // Return raw variance without normalization return variance; } catch (error) { return 0; } } /** * Get all Phase One metrics flattened into a single map * @param {Object} metrics - Full metrics object * @returns {Object} Flattened metrics map */ getAllPhaseOneMetrics(metrics) { const result = {}; const timeSeriesData = metrics.timeSeries || {}; // Iterate through all time series categories for (const category in timeSeriesData) { for (const metricName in timeSeriesData[category]) { const timeSeries = timeSeriesData[category][metricName]; // Skip if not an array or empty if (!Array.isArray(timeSeries) || timeSeries.length === 0) { continue; } // Add to flattened map result[`${category}.${metricName}`] = timeSeries; } } return result; } /** * Calculate correlations for all metrics against an event frame * @param {Object} metrics - Complete metrics data * @param {number} eventFrame - Frame for event occurrence * @param {string} eventType - Event type * @returns {Array} Correlations sorted by significance */ calculateAllMetricCorrelations(metrics, eventFrame, eventType) { if (!metrics || !metrics.timeSeries || eventFrame === undefined || !eventType) { this.logger.warn(`Missing data for correlation calculation: metrics=${!!metrics}, timeSeries=${!!metrics?.timeSeries}, eventFrame=${eventFrame}, eventType=${eventType}`); // Return empty array instead of mock correlations this.logger.warn(`Insufficient data for correlation calculation, returning empty result`); return []; } const allCorrelations = []; try { // ... rest of the original method (unchanged) // Debug log all available categories this.logger.debug(`Available timeSeries categories: ${Object.keys(metrics.timeSeries).join(', ')}`); const processCategory = (category, priorityMultiplier = 1.0) => { if (!metrics.timeSeries[category]) { this.logger.debug(`Category ${category} not found in time series data`); return; } // Get all metrics in this category const categoryMetrics = metrics.timeSeries[category]; this.logger.debug(`Processing ${Object.keys(categoryMetrics).length} metrics in category ${category}`); // Process each metric in the category Object.keys(categoryMetrics).forEach(metricKey => { const metricPath = `${category}.${metricKey}`; const timeSeries = categoryMetrics[metricKey]; // Skip if time series is not valid if (!Array.isArray(timeSeries) || timeSeries.length === 0) { this.logger.debug(`Skipping ${metricPath}: Invalid time series data`); return; } // Calculate correlation const correlation = this.calculatePreciseCorrelation( timeSeries, metricPath, eventType, eventFrame ); // Add correlation if significant if (correlation) { // Apply priority multiplier correlation.significance *= priorityMultiplier; // Ensure significance stays in valid range correlation.significance = Math.min(1.0, correlation.significance); allCorrelations.push(correlation); this.logger.debug(`Added correlation for ${metricPath} with significance ${correlation.significance.toFixed(2)}`); } else { this.logger.debug(`No significant correlation found for ${metricPath}`); } }); }; // Get priority categories for this event type const priorityCategories = this._getPriorityCategoriesForEvent(eventType); // Process priority categories first with boosted significance priorityCategories.forEach(category => { const priorityIndex = priorityCategories.indexOf(category); const priorityMultiplier = 1.0 + (0.1 * (priorityCategories.length - priorityIndex)) / priorityCategories.length; processCategory(category, priorityMultiplier); }); // Process remaining categories Object.keys(metrics.timeSeries).forEach(category => { if (!priorityCategories.includes(category)) { processCategory(category, 0.9); // Slight penalty for non-priority categories } }); // Enhance correlations for paired metrics this._enhancePairedMetricCorrelations(allCorrelations); // Sort correlations by significance (highest first) allCorrelations.sort((a, b) => b.significance - a.significance); // Get top correlations based on event type let maxCorrelations; switch (eventType) { case systemConfig.EVENT_NAMES.RELEASE_POINT: maxCorrelations = 30; break; case systemConfig.EVENT_NAMES.FRONT_FOOT_LANDING: case systemConfig.EVENT_NAMES.BACK_FOOT_LANDING: maxCorrelations = 25; break; default: maxCorrelations = 20; } // If we have no correlations, return empty array instead of mock data if (allCorrelations.length === 0) { this.logger.warn(`No correlations found for ${eventType}, returning empty array`); return []; } // Ensure we have a mix of different metric types for robustness return this._ensureMetricTypeDiversity(allCorrelations, maxCorrelations); } catch (error) { this.logger.error(`Error calculating metrics correlations: ${error.message}`); // Return empty array instead of mock correlations return []; } } /** * Generate mock correlations for testing * @param {string} eventType - Event type * @param {number} eventFrame - Event frame * @returns {Array} Mock correlations * @private */ _generateMockCorrelations(eventType, eventFrame) { const mockCategories = ['angles', 'velocity', 'acceleration', 'power', 'balance']; const mockCorrelations = []; // Generate some mock metrics for each category mockCategories.forEach((category, categoryIndex) => { // Generate 3-5 mock metrics per category const metricCount = 3 + Math.floor(Math.random() * 3); for (let i = 0; i < metricCount; i++) { const metricIndex = i + 1; const mockMetricPath = `${category}.metric${metricIndex}`; const baseSignificance = 0.85 - (categoryIndex * 0.1) - (i * 0.05); mockCorrelations.push({ metric: mockMetricPath, category, eventType, eventFrame, pattern: [0.5, 0.6, 0.8, 0.9, 0.7], significance: baseSignificance, value: 0.8, isDerivative: category === 'acceleration' || category === 'velocity', isCompound: false, patternVariance: 0.15, patternDistinctiveness: 0.7, valueEstimated: false, isMock: true // Flag to indicate this is mock data }); } }); // Sort by significance mockCorrelations.sort((a, b) => b.significance - a.significance); this.logger.info(`Generated ${mockCorrelations.length} mock correlations for ${eventType}`); return mockCorrelations; } /** * Ensure diversity of metric types in correlations * @param {Array} correlations - All correlations * @param {number} maxTotal - Maximum total correlations to return * @returns {Array} Diverse set of correlations * @private */ _ensureMetricTypeDiversity(correlations, maxTotal) { if (correlations.length <= maxTotal) { return correlations; } // Group correlations by category const categorized = {}; correlations.forEach(corr => { if (!categorized[corr.category]) { categorized[corr.category] = []; } categorized[corr.category].push(corr); }); // Calculate target counts per category const categories = Object.keys(categorized); const baseCount = Math.floor(maxTotal / categories.length); // Allocate extra slots to most significant categories const categorySignificance = {}; categories.forEach(cat => { categorySignificance[cat] = categorized[cat].reduce((sum, corr) => sum + corr.significance, 0); }); // Sort categories by total significance const sortedCategories = categories.sort((a, b) => categorySignificance[b] - categorySignificance[a]); let remainingSlots = maxTotal; const categoryLimits = {}; // Assign base counts to each category sortedCategories.forEach(cat => { categoryLimits[cat] = Math.min(baseCount, categorized[cat].length); remainingSlots -= categoryLimits[cat]; }); // Distribute remaining slots to categories