Files
pdga-rating/src/services/rating-calculator.js
T

242 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function parseDate(dateStr) {
const multiDayMatch = dateStr.match(/^(\d{1,2})(-([A-Za-z]{3}))?(\s+to\s+)(\d{1,2})-([A-Za-z]{3})-(\d{4})$/);
if (multiDayMatch) {
const day = parseInt(multiDayMatch[1]);
const month = multiDayMatch[3] || multiDayMatch[6];
const year = parseInt(multiDayMatch[7]);
const monthMap = {
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
};
return new Date(year, monthMap[month], day);
}
const formats = [
/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/,
/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/
];
for (const format of formats) {
const match = dateStr.match(format);
if (match) {
if (format === formats[0]) {
const monthMap = {
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
};
const day = parseInt(match[1]);
const month = monthMap[match[2]];
const year = parseInt(match[3]);
return new Date(year, month, day);
}
}
}
return new Date(dateStr);
}
function getNextPDGAUpdateDate() {
const today = new Date();
const currentMonth = today.getMonth();
const currentYear = today.getFullYear();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1);
const firstTuesday = new Date(firstDayOfMonth);
const daysUntilTuesday = (2 - firstDayOfMonth.getDay() + 7) % 7;
firstTuesday.setDate(1 + daysUntilTuesday);
const secondTuesday = new Date(firstTuesday);
secondTuesday.setDate(firstTuesday.getDate() + 7);
if (today <= secondTuesday) {
return secondTuesday;
} else {
const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1;
const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear;
const firstDayNextMonth = new Date(nextYear, nextMonth, 1);
const firstTuesdayNext = new Date(firstDayNextMonth);
const daysUntilTuesdayNext = (2 - firstDayNextMonth.getDay() + 7) % 7;
firstTuesdayNext.setDate(1 + daysUntilTuesdayNext);
const secondTuesdayNext = new Date(firstTuesdayNext);
secondTuesdayNext.setDate(firstTuesdayNext.getDate() + 7);
return secondTuesdayNext;
}
}
function calculateStandardDeviation(ratings) {
if (!ratings || ratings.length === 0) return 0;
const mean = ratings.reduce((sum, r) => sum + r, 0) / ratings.length;
const variance = ratings.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / ratings.length;
return Math.sqrt(variance);
}
function calculatePredictedRating(roundRatings) {
const debugLog = [];
debugLog.push('=== PDGA RATING CALCULATION (Following Official Rules) ===');
if (!roundRatings || roundRatings.length === 0) {
debugLog.push('❌ No rounds provided for prediction');
return { rating: 0, debugLog, excludedRoundsCount: null, cutoffRating: null };
}
debugLog.push(`📊 Starting with ${roundRatings.length} total rounds`);
const nextUpdateDate = getNextPDGAUpdateDate();
debugLog.push(`🎯 PDGA Update Simulation: Next update date is ${nextUpdateDate.toDateString()}`);
debugLog.push(` Only including rounds played before ${nextUpdateDate.toDateString()}`);
const allSortedRounds = roundRatings
.filter(r => r.rating > 0 && r.date < nextUpdateDate)
.sort((a, b) => b.date - a.date);
if (allSortedRounds.length === 0) {
debugLog.push('❌ No valid rounds after filtering for update date');
return { rating: 0, debugLog, excludedRoundsCount: null, cutoffRating: null };
}
debugLog.push(`📊 After update date filter: ${allSortedRounds.length} rounds`);
const twelveMonthsBeforeUpdate = new Date(nextUpdateDate);
twelveMonthsBeforeUpdate.setFullYear(twelveMonthsBeforeUpdate.getFullYear() - 1);
const mostRecentDate = allSortedRounds[0].date;
debugLog.push(`📅 Most recent round: ${mostRecentDate.toDateString()}`);
debugLog.push(`📅 12-month cutoff: ${twelveMonthsBeforeUpdate.toDateString()} (1 year before update)`);
let eligibleRounds = allSortedRounds.filter(r => r.date >= twelveMonthsBeforeUpdate);
debugLog.push('🗓️ 12-MONTH FILTERING:');
debugLog.push(`✅ Rounds in last 12 months: ${eligibleRounds.length}`);
if (eligibleRounds.length < 8) {
const twentyFourMonthsBeforeUpdate = new Date(nextUpdateDate);
twentyFourMonthsBeforeUpdate.setFullYear(twentyFourMonthsBeforeUpdate.getFullYear() - 2);
eligibleRounds = allSortedRounds.filter(r => r.date >= twentyFourMonthsBeforeUpdate);
debugLog.push(`⚠️ Extended to 24 months before update (${twentyFourMonthsBeforeUpdate.toDateString()}) - now ${eligibleRounds.length} rounds`);
}
if (eligibleRounds.length === 0) {
debugLog.push('❌ No eligible rounds found');
return { rating: 0, debugLog, excludedRoundsCount: null, cutoffRating: null };
}
debugLog.push(`📈 ELIGIBLE ROUNDS: ${eligibleRounds.length}`);
eligibleRounds.forEach((round, index) => {
debugLog.push(` ${index + 1}. ${round.date.toDateString()}: ${round.rating} (${round.competition})`);
});
let workingRounds = [...eligibleRounds];
let workingRatings = workingRounds.map(r => r.rating);
let excludedRoundsCount = 0;
let cutoffRating = null;
if (workingRatings.length >= 7) {
debugLog.push('🔍 OUTLIER EXCLUSION (≥7 rounds available):');
const mean = workingRatings.reduce((sum, r) => sum + r, 0) / workingRatings.length;
const stdDev = calculateStandardDeviation(workingRatings);
debugLog.push(` Mean: ${mean.toFixed(1)}`);
debugLog.push(` Std Dev: ${stdDev.toFixed(1)}`);
const stdDevCutoff = mean - 2.5 * stdDev;
const hundredPointCutoff = mean - 100;
debugLog.push(` 2.5σ cutoff: ${stdDevCutoff.toFixed(1)}`);
debugLog.push(` 100-point cutoff: ${hundredPointCutoff.toFixed(1)}`);
const filteredRatings = workingRatings.filter(rating =>
rating >= stdDevCutoff && rating >= hundredPointCutoff
);
const stdDevOutliers = workingRatings.filter(rating => rating < stdDevCutoff);
const hundredPointOutliers = workingRatings.filter(rating => rating < hundredPointCutoff && rating >= stdDevCutoff);
excludedRoundsCount = stdDevOutliers.length + hundredPointOutliers.length;
cutoffRating = Math.round(Math.max(stdDevCutoff, hundredPointCutoff));
if (stdDevOutliers.length > 0) {
debugLog.push(` ❌ 2.5σ outliers removed: ${stdDevOutliers.length} rounds`);
stdDevOutliers.forEach(rating => {
const round = workingRounds.find(r => r.rating === rating);
debugLog.push(` - ${rating} (${round.date.toDateString()}: ${round.competition})`);
});
}
if (hundredPointOutliers.length > 0) {
debugLog.push(` ❌ 100-point outliers removed: ${hundredPointOutliers.length} rounds`);
hundredPointOutliers.forEach(rating => {
const round = workingRounds.find(r => r.rating === rating);
debugLog.push(` - ${rating} (${round.date.toDateString()}: ${round.competition})`);
});
}
if (stdDevOutliers.length === 0 && hundredPointOutliers.length === 0) {
debugLog.push(` ✅ No outliers detected`);
}
if (filteredRatings.length >= 4) {
workingRounds = workingRounds.filter(round =>
round.rating >= stdDevCutoff && round.rating >= hundredPointCutoff
);
workingRatings = filteredRatings;
debugLog.push(` ✅ Using ${filteredRatings.length} rounds after outlier removal`);
} else {
debugLog.push(` ⚠️ Too few rounds after outlier removal (${filteredRatings.length}), keeping all rounds`);
excludedRoundsCount = 0;
cutoffRating = null;
}
} else {
debugLog.push(`⏭️ OUTLIER EXCLUSION SKIPPED (only ${workingRatings.length} rounds, need ≥7)`);
}
debugLog.push('⚖️ WEIGHTING (Most recent 25% count double if ≥9 rounds):');
const weightedRatings = [];
if (workingRatings.length >= 9) {
const recentCount = Math.round(workingRatings.length * 0.25);
debugLog.push(` ✅ Double-weighting most recent ${recentCount} rounds`);
weightedRatings.push(...workingRatings);
for (let i = 0; i < recentCount; i++) {
weightedRatings.push(workingRatings[i]);
const round = workingRounds[i];
debugLog.push(` 2x weight: ${workingRatings[i]} (${round.date.toDateString()}: ${round.competition})`);
}
debugLog.push(` 📊 Total values: ${workingRatings.length} + ${recentCount} double-weighted = ${weightedRatings.length}`);
} else {
debugLog.push(` ➡️ No double weighting (${workingRatings.length} rounds, need ≥9)`);
weightedRatings.push(...workingRatings);
}
const sum = weightedRatings.reduce((sum, r) => sum + r, 0);
const average = sum / weightedRatings.length;
const finalRating = Math.round(average);
const stdDev = calculateStandardDeviation(weightedRatings);
debugLog.push('🎯 FINAL CALCULATION:');
debugLog.push(` Sum: ${sum}`);
debugLog.push(` Count: ${weightedRatings.length}`);
debugLog.push(` Average: ${average.toFixed(1)}`);
debugLog.push(` Standard Deviation: ${stdDev.toFixed(1)}`);
debugLog.push(` Final Rating: ${finalRating}`);
debugLog.push('=== END PDGA CALCULATION ===');
return { rating: finalRating, stdDev: Math.round(stdDev), debugLog, excludedRoundsCount, cutoffRating };
}
module.exports = { parseDate, getNextPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation };