glutamatt's picture
glutamatt HF Staff
some fixes
fcc9f70 verified
raw
history blame
18.5 kB
import type { Activity, MetricACWRData } from '@/types';
/**
* Format date to YYYY-MM-DD in local timezone (not UTC)
*/
function formatDateLocal(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Calculate optimal activity values for the next 7 days to minimize MSE between ACWR and target
* Uses optimization to find values that maintain ACWR close to target across all predicted days
* @param allDates - Array of all historical dates
* @param dailyValues - Map of date strings to activity values
* @param targetACWR - Target ACWR to achieve
* @param numFutureDays - Number of future days to predict (default 7)
* @param startOffset - Day offset to start predictions (0 for today, 1 for tomorrow)
*/
function calculateOptimalFutureDays(
allDates: Date[],
dailyValues: Map<string, number>,
targetACWR: number,
numFutureDays: number = 7,
startOffset: number = 1
): {
futureDates: string[];
futureValues: number[];
futureAverage7d: number[];
futureAverage28d: number[];
futureAcwr: number[];
allFutureDates: string[];
allFutureValues: number[];
allFutureAverage7d: number[];
allFutureAverage28d: number[];
allFutureAcwr: number[];
} {
// Variance weight parameter (0-1): controls trade-off between ACWR accuracy and value consistency
// Higher weight = more preference for consistent daily values
const varianceWeight = 0.5;
// Helper function to calculate variance of predicted values
const calculateVariance = (values: number[]): number => {
if (values.length === 0) return 0;
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
return variance;
};
// Helper function to simulate predictions and calculate error (MSE + variance penalty)
const simulateAndCalculateError = (values: number[]): { error: number; acwrValues: number[]; variance: number } => {
const simulatedDailyValues = new Map(dailyValues);
const simulatedAllDates = [...allDates];
const lastDate = new Date(allDates[allDates.length - 1]);
const acwrValues: number[] = [];
for (let dayOffset = 0; dayOffset < numFutureDays; dayOffset++) {
const futureDate = new Date(lastDate);
futureDate.setDate(lastDate.getDate() + 1 + startOffset + dayOffset);
const futureDateStr = formatDateLocal(futureDate);
simulatedDailyValues.set(futureDateStr, values[dayOffset]);
simulatedAllDates.push(futureDate);
const currentIndex = simulatedAllDates.length - 1;
// Calculate 7-day average
let acuteSum = 0;
for (let i = Math.max(0, currentIndex - 6); i <= currentIndex; i++) {
const checkDateStr = formatDateLocal(simulatedAllDates[i]);
acuteSum += simulatedDailyValues.get(checkDateStr) || 0;
}
const acuteAvg = acuteSum / 7;
// Calculate 28-day average
let chronicSum = 0;
for (let i = Math.max(0, currentIndex - 27); i <= currentIndex; i++) {
const checkDateStr = formatDateLocal(simulatedAllDates[i]);
chronicSum += simulatedDailyValues.get(checkDateStr) || 0;
}
const chronicAvg = chronicSum / 28;
const acwr = chronicAvg > 0 ? acuteAvg / chronicAvg : 0;
acwrValues.push(acwr);
}
// Calculate MSE for ACWR
let mse = 0;
for (const acwr of acwrValues) {
const diff = acwr - targetACWR;
mse += diff * diff;
}
mse /= numFutureDays;
// Calculate variance of predicted values
const variance = calculateVariance(values);
// Normalize variance by mean to make it scale-independent
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const normalizedVariance = mean > 0 ? variance / (mean * mean) : 0;
// Combined error: MSE + weighted variance penalty
const error = mse + varianceWeight * normalizedVariance;
return { error, acwrValues, variance };
};
// Initialize with greedy solution as starting point
const initialValues: number[] = [];
const simulatedDailyValues = new Map(dailyValues);
const simulatedAllDates = [...allDates];
const lastDate = new Date(allDates[allDates.length - 1]);
for (let dayOffset = 0; dayOffset < numFutureDays; dayOffset++) {
const futureDate = new Date(lastDate);
futureDate.setDate(lastDate.getDate() + 1 + startOffset + dayOffset);
const futureDateStr = formatDateLocal(futureDate);
simulatedAllDates.push(futureDate);
const currentIndex = simulatedAllDates.length - 1;
// Calculate sums for greedy solution
let acuteSum = 0;
for (let i = Math.max(0, currentIndex - 6); i < currentIndex; i++) {
const checkDateStr = formatDateLocal(simulatedAllDates[i]);
acuteSum += simulatedDailyValues.get(checkDateStr) || 0;
}
let chronicSum = 0;
for (let i = Math.max(0, currentIndex - 27); i < currentIndex; i++) {
const checkDateStr = formatDateLocal(simulatedAllDates[i]);
chronicSum += simulatedDailyValues.get(checkDateStr) || 0;
}
// Greedy optimal value
const numerator = 4 * acuteSum - targetACWR * chronicSum;
const denominator = targetACWR - 4;
let optimalValue = 0;
if (Math.abs(denominator) > 0.001) {
optimalValue = numerator / denominator;
if (optimalValue < 0) optimalValue = 0;
} else {
optimalValue = acuteSum / 7;
}
initialValues.push(optimalValue);
simulatedDailyValues.set(futureDateStr, optimalValue);
}
// Iterative refinement to minimize error (MSE + variance penalty)
let bestValues = [...initialValues];
let bestError = simulateAndCalculateError(bestValues).error;
// Simple gradient descent-like optimization
const iterations = 50;
const learningRate = 0.3;
for (let iter = 0; iter < iterations; iter++) {
const currentResult = simulateAndCalculateError(bestValues);
// Try adjusting each day's value
for (let dayIdx = 0; dayIdx < numFutureDays; dayIdx++) {
const originalValue = bestValues[dayIdx];
// Calculate gradient by finite difference
const delta = Math.max(1, originalValue * 0.1);
const testValues = [...bestValues];
testValues[dayIdx] = originalValue + delta;
const upResult = simulateAndCalculateError(testValues);
const gradient = (upResult.error - currentResult.error) / delta;
// Update value using gradient
let newValue = originalValue - learningRate * gradient * originalValue;
newValue = Math.max(0, newValue); // Ensure non-negative
// Test if this improves error
testValues[dayIdx] = newValue;
const newResult = simulateAndCalculateError(testValues);
if (newResult.error < bestError) {
bestValues[dayIdx] = newValue;
bestError = newResult.error;
}
}
}
// Build final results with optimized values
// Filter out insignificant values (< 1) - likely rest days
const MIN_THRESHOLD = 1;
const futureDates: string[] = [];
const futureValues: number[] = [];
const futureAverage7d: number[] = [];
const futureAverage28d: number[] = [];
const futureAcwr: number[] = [];
const finalSimulatedDailyValues = new Map(dailyValues);
const finalSimulatedAllDates = [...allDates];
// We need to track ALL dates and their ACWR for curve continuity
const allFutureDates: string[] = [];
const allFutureValues: number[] = [];
const allFutureAverage7d: number[] = [];
const allFutureAverage28d: number[] = [];
const allFutureAcwr: number[] = [];
for (let dayOffset = 0; dayOffset < numFutureDays; dayOffset++) {
const futureDate = new Date(lastDate);
futureDate.setDate(lastDate.getDate() + 1 + startOffset + dayOffset);
const futureDateStr = formatDateLocal(futureDate);
// Store ALL dates for ACWR calculation
allFutureDates.push(futureDateStr);
allFutureValues.push(bestValues[dayOffset]);
// Only include days with significant values (>= MIN_THRESHOLD) in the filtered arrays
if (bestValues[dayOffset] >= MIN_THRESHOLD) {
futureDates.push(futureDateStr);
futureValues.push(bestValues[dayOffset]);
}
finalSimulatedDailyValues.set(futureDateStr, bestValues[dayOffset]);
finalSimulatedAllDates.push(futureDate);
const currentIndex = finalSimulatedAllDates.length - 1;
// Calculate averages and ACWR for ALL days to maintain curve continuity
// Calculate 7-day average
let newAcuteSum = 0;
for (let i = Math.max(0, currentIndex - 6); i <= currentIndex; i++) {
const checkDateStr = formatDateLocal(finalSimulatedAllDates[i]);
newAcuteSum += finalSimulatedDailyValues.get(checkDateStr) || 0;
}
const newAcuteAvg = newAcuteSum / 7;
allFutureAverage7d.push(newAcuteAvg);
// Calculate 28-day average
let newChronicSum = 0;
for (let i = Math.max(0, currentIndex - 27); i <= currentIndex; i++) {
const checkDateStr = formatDateLocal(finalSimulatedAllDates[i]);
newChronicSum += finalSimulatedDailyValues.get(checkDateStr) || 0;
}
const newChronicAvg = newChronicSum / 28;
allFutureAverage28d.push(newChronicAvg);
// Calculate ACWR
const newACWR = newChronicAvg > 0 ? newAcuteAvg / newChronicAvg : 0;
allFutureAcwr.push(newACWR);
}
return {
futureDates,
futureValues,
futureAverage7d,
futureAverage28d,
futureAcwr,
// Include all dates and ACWR for curve continuity
allFutureDates,
allFutureValues,
allFutureAverage7d,
allFutureAverage28d,
allFutureAcwr,
};
}
/**
* Calculate ACWR for a specific metric (distance, duration, or TSS)
* @param activities - Array of activities
* @param metricExtractor - Function to extract the metric value from an activity
* @param dateRange - Optional date range to maintain consistency
* @param targetACWR - Target ACWR value for predictions
*/
export function calculateMetricACWR(
activities: Activity[],
metricExtractor: (activity: Activity) => number | undefined,
dateRange?: { start: Date; end: Date },
targetACWR: number = 1.3
): MetricACWRData {
if (activities.length === 0 && !dateRange) {
return {
dates: [],
values: [],
average7d: [],
average28d: [],
acwr: [],
};
}
// Sort activities by date
const sortedActivities = [...activities].sort((a, b) => a.date.getTime() - b.date.getTime());
// Get date range
const startDate = dateRange?.start || (sortedActivities.length > 0 ? new Date(sortedActivities[0].date) : new Date());
const endDate = dateRange?.end || (sortedActivities.length > 0 ? new Date(sortedActivities[sortedActivities.length - 1].date) : new Date());
// Create a map of date -> daily sum
const dailyValues = new Map<string, number>();
// Create a map of date -> activities
const activitiesByDate = new Map<string, Activity[]>();
sortedActivities.forEach(activity => {
const dateStr = formatDateLocal(activity.date);
const value = metricExtractor(activity);
if (value !== undefined) {
dailyValues.set(dateStr, (dailyValues.get(dateStr) || 0) + value);
}
// Group activities by date
if (!activitiesByDate.has(dateStr)) {
activitiesByDate.set(dateStr, []);
}
activitiesByDate.get(dateStr)!.push(activity);
});
const dates: string[] = [];
const values: (number | null)[] = [];
const average7d: (number | null)[] = [];
const average28d: (number | null)[] = [];
const acwr: (number | null)[] = [];
// Generate all dates in range
const allDates: Date[] = [];
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
allDates.push(new Date(d));
}
// Calculate for each date
allDates.forEach((date, index) => {
const dateStr = formatDateLocal(date);
dates.push(dateStr);
// Get daily value
const dailyValue = dailyValues.get(dateStr) || null;
values.push(dailyValue);
// Calculate 7-day rolling average (acute load)
let acuteSum = 0;
let acuteCount = 0;
for (let i = Math.max(0, index - 6); i <= index; i++) {
const checkDateStr = formatDateLocal(allDates[i]);
const val = dailyValues.get(checkDateStr);
if (val !== undefined) {
acuteSum += val;
acuteCount++;
}
}
const acuteAvg = acuteCount > 0 ? acuteSum / 7 : null;
average7d.push(acuteAvg);
// Calculate 28-day rolling average
let chronicSum = 0;
let chronicCount = 0;
for (let i = Math.max(0, index - 27); i <= index; i++) {
const checkDateStr = formatDateLocal(allDates[i]);
const val = dailyValues.get(checkDateStr);
if (val !== undefined) {
chronicSum += val;
chronicCount++;
}
}
const chronicAvg = chronicCount > 0 ? chronicSum / 28 : null;
average28d.push(chronicAvg);
// Calculate ACWR only after we have at least 28 days of data range
// This ensures accuracy by having a full chronic period (28 days)
if (index >= 27 && acuteAvg !== null && chronicAvg !== null && chronicAvg > 0) {
acwr.push(acuteAvg / chronicAvg);
} else {
acwr.push(null);
}
}); // Calculate tomorrow's required value to reach ACWR
let targetTomorrowValue: number | null = null;
if (allDates.length >= 28) {
// Calculate tomorrow's 7-day acute sum (last 6 days including zeros)
let tomorrowAcuteSum = 0;
for (let i = allDates.length - 6; i < allDates.length; i++) {
const checkDateStr = formatDateLocal(allDates[i]);
const val = dailyValues.get(checkDateStr) || 0; // Include 0 for rest days
tomorrowAcuteSum += val;
}
// Calculate tomorrow's 28-day chronic sum (last 27 days including zeros)
let tomorrowChronicSum = 0;
for (let i = allDates.length - 27; i < allDates.length; i++) {
const checkDateStr = formatDateLocal(allDates[i]);
const val = dailyValues.get(checkDateStr) || 0; // Include 0 for rest days
tomorrowChronicSum += val;
}
// Solve for tomorrow's value (X):
// Tomorrow's ACWR = [(tomorrowAcuteSum + X) / 7] / [(tomorrowChronicSum + X) / 28]
// Simplifies to: targetACWR = (tomorrowAcuteSum + X) / (tomorrowChronicSum + X) * 28 / 7
// targetACWR = (tomorrowAcuteSum + X) / (tomorrowChronicSum + X) * 4
// targetACWR * (tomorrowChronicSum + X) = 4 * (tomorrowAcuteSum + X)
// targetACWR * tomorrowChronicSum + targetACWR * X = 4 * tomorrowAcuteSum + 4 * X
// targetACWR * X - 4 * X = 4 * tomorrowAcuteSum - targetACWR * tomorrowChronicSum
// X * (targetACWR - 4) = 4 * tomorrowAcuteSum - targetACWR * tomorrowChronicSum
// X = (4 * tomorrowAcuteSum - targetACWR * tomorrowChronicSum) / (targetACWR - 4)
const numerator = 4 * tomorrowAcuteSum - targetACWR * tomorrowChronicSum;
const denominator = targetACWR - 4;
if (Math.abs(denominator) > 0.001) {
targetTomorrowValue = numerator / denominator;
// If negative, set to 0 (rest day recommended)
if (targetTomorrowValue < 0) {
targetTomorrowValue = 0;
}
} else {
// Special case: targetACWR ≈ 4, need different approach
// If ACWR = 4, then acute = 4 * chronic, which means you need massive increase
targetTomorrowValue = null;
}
}
// Calculate what ACWR would be with a rest day tomorrow
let restTomorrowACWR: number | null = null;
if (allDates.length >= 28) {
// Calculate tomorrow's 7-day acute sum with rest (last 6 days + 0)
let tomorrowAcuteSum = 0;
for (let i = allDates.length - 6; i < allDates.length; i++) {
const checkDateStr = formatDateLocal(allDates[i]);
const val = dailyValues.get(checkDateStr) || 0;
tomorrowAcuteSum += val;
}
// Add 0 for rest day (no need to add)
// Calculate tomorrow's 28-day chronic sum with rest (last 27 days + 0)
let tomorrowChronicSum = 0;
for (let i = allDates.length - 27; i < allDates.length; i++) {
const checkDateStr = formatDateLocal(allDates[i]);
const val = dailyValues.get(checkDateStr) || 0;
tomorrowChronicSum += val;
}
// Add 0 for rest day (no need to add)
// Calculate ACWR with rest day
const tomorrowAcuteAvg = tomorrowAcuteSum / 7;
const tomorrowChronicAvg = tomorrowChronicSum / 28;
if (tomorrowChronicAvg > 0) {
restTomorrowACWR = tomorrowAcuteAvg / tomorrowChronicAvg;
}
}
return {
dates,
values,
average7d,
average28d,
acwr,
targetTomorrowValue,
targetACWR: allDates.length >= 28 ? targetACWR : undefined,
restTomorrowACWR: allDates.length >= 28 ? restTomorrowACWR : undefined,
todayValue: undefined,
activitiesByDate,
// Add future predictions if we have enough data
// Predictions always start from the day after lastDate
...(allDates.length >= 28 ? calculateOptimalFutureDays(allDates, dailyValues, targetACWR, 7, 0) : {}),
};
}