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
JavaScript
/**
* @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