UNPKG

@sports-alliance/sports-lib

Version:

A Library to for importing / exporting and processing GPX, TCX, FIT and JSON files from services such as Strava, Movescount, Garmin, Polar etc

307 lines (306 loc) 12.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.TssCalculator = void 0; const data_training_stress_score_method_1 = require("../../../data/data.training-stress-score-method"); const BANISTER_A = 0.64; const BANISTER_K_MALE_OR_UNKNOWN = 1.92; const BANISTER_K_FEMALE = 1.67; const DEFAULT_HR_THRESHOLD_RATIO = 0.85; const DEFAULT_MET_THRESHOLD = 10; class TssCalculator { static estimateMetScore(energyConsumption, userWeight, totalDurationWithoutPauses) { if (energyConsumption <= 0 || userWeight <= 0 || totalDurationWithoutPauses <= 0) { return 0; } return (3600 * energyConsumption) / (userWeight * totalDurationWithoutPauses); } static calculatePowerTss(input) { const functionalThresholdPower = this.positive(input.functionalThresholdPower); const normalizedPower = this.positive(input.normalizedPower); const totalDurationWithoutPauses = this.positive(input.totalDurationWithoutPauses); if (functionalThresholdPower === null || normalizedPower === null || totalDurationWithoutPauses === null) { return null; } try { const intensityFactor = normalizedPower / functionalThresholdPower; const effectiveDuration = Math.max(totalDurationWithoutPauses - 29, 0); const tss = (100 * effectiveDuration * normalizedPower * intensityFactor) / (functionalThresholdPower * 3600); return this.applyRangeCheck({ calculationMethod: data_training_stress_score_method_1.TrainingStressScoreMethod.POWER, trainingStressScore: tss, intensityFactor, normalizedPower }); } catch (_a) { return null; } } static calculatePaceTss(input) { const thresholdSpeed = this.positive(input.functionalThresholdPace); if (thresholdSpeed === null) { return null; } const samples = input.samples .filter(sample => Number.isFinite(sample.speed)) .sort((left, right) => left.duration - right.duration); if (!samples.length) { return null; } try { const adjustedSpeedSamples = samples .map(sample => { const speed = sample.speed; if (speed <= 0) { return null; } const grade = this.resolveSampleGrade(sample, speed); if (grade === null) { return null; } const clampedGrade = this.clamp(grade, -0.45, 0.45); const cost = this.runningCostMinetti(clampedGrade); if (!Number.isFinite(cost) || cost <= 0) { return null; } const adjustedSpeed = speed * (3.6 / cost); if (!Number.isFinite(adjustedSpeed) || adjustedSpeed < 0) { return null; } return { duration: sample.duration, adjustedSpeed }; }) .filter((sample) => sample !== null); if (!adjustedSpeedSamples.length) { return null; } const adjusted30sList = adjustedSpeedSamples.map((sample, index) => { if (sample.duration < 30) { return null; } const index30SecAgo = Math.max(0, adjustedSpeedSamples.slice(0, index).findIndex(prev => sample.duration - prev.duration < 30)); const mean = this.average(adjustedSpeedSamples.slice(index30SecAgo, index + 1).map(item => item.adjustedSpeed)); return Number.isNaN(mean) ? null : mean; }); let roll4Sum = 0; let roll4Count = 0; adjusted30sList.forEach(value => { if (value !== null) { roll4Sum += Math.pow(value, 4); roll4Count += 1; } }); if (roll4Count === 0) { return null; } const normalizedGradedSpeed = Math.pow(roll4Sum / roll4Count, 0.25); const intensityFactor = normalizedGradedSpeed / thresholdSpeed; const durationSeconds = adjustedSpeedSamples[adjustedSpeedSamples.length - 1].duration; const tss = 100 * (durationSeconds / 3600) * Math.pow(intensityFactor, 2); return this.applyRangeCheck({ calculationMethod: data_training_stress_score_method_1.TrainingStressScoreMethod.PACE, trainingStressScore: tss, intensityFactor, averageGradeAdjustedPace: normalizedGradedSpeed }); } catch (_a) { return null; } } static calculateHrTss(input) { const maxHeartRate = this.positive(input.maxHeartRate); if (maxHeartRate === null) { return null; } const samples = input.samples .filter(sample => Number.isFinite(sample.hr) && sample.hr > 0) .sort((left, right) => left.duration - right.duration); if (!samples.length) { return null; } const restingHeartRate = this.positive(input.restingHeartRate); const banister = this.calculateBanisterHrTss(samples, maxHeartRate, restingHeartRate, input.lactateThresholdHR, input.gender); if (banister) { return banister; } return this.calculateEdwardsHrTss(samples, maxHeartRate, input.lactateThresholdHR); } static calculateSwimTss(input) { const swimSpeed = this.positive(input.swimSpeed); const thresholdSwimSpeed = this.positive(input.thresholdSwimSpeed); const duration = this.positive(input.totalDurationWithoutPauses); if (swimSpeed === null || thresholdSwimSpeed === null || duration === null) { return null; } try { const intensityFactor = swimSpeed / thresholdSwimSpeed; const tss = 100 * (duration / 3600) * Math.pow(intensityFactor, 3); return this.applyRangeCheck({ calculationMethod: data_training_stress_score_method_1.TrainingStressScoreMethod.SWIM_PACE, trainingStressScore: tss, intensityFactor }); } catch (_a) { return null; } } static calculateMetTss(input) { var _a; const metScore = this.positive(input.metScore); const duration = this.positive(input.totalDurationWithoutPauses); const thresholdMet = (_a = this.positive(input.thresholdMet)) !== null && _a !== void 0 ? _a : DEFAULT_MET_THRESHOLD; if (metScore === null || duration === null || thresholdMet <= 0) { return null; } try { const intensityFactor = metScore / thresholdMet; const tss = 100 * (duration / 3600) * Math.pow(intensityFactor, 2); return this.applyRangeCheck({ calculationMethod: data_training_stress_score_method_1.TrainingStressScoreMethod.MET, trainingStressScore: tss, intensityFactor }); } catch (_b) { return null; } } static calculateBanisterHrTss(samples, maxHeartRate, restingHeartRate, lactateThresholdHR, gender) { if (restingHeartRate === null || maxHeartRate <= restingHeartRate) { return null; } const k = this.isFemale(gender) ? BANISTER_K_FEMALE : BANISTER_K_MALE_OR_UNKNOWN; const sessionLoad = this.accumulateSampleLoad(samples, sample => { const hr = sample.hr; const dHrr = this.clamp((hr - restingHeartRate) / (maxHeartRate - restingHeartRate), 0, 1); return dHrr * BANISTER_A * Math.exp(k * dHrr); }); const thresholdDhr = this.resolveBanisterThresholdDhr(maxHeartRate, restingHeartRate, this.positive(lactateThresholdHR)); const thresholdHourLoad = 60 * thresholdDhr * BANISTER_A * Math.exp(k * thresholdDhr); if (!Number.isFinite(sessionLoad) || !Number.isFinite(thresholdHourLoad) || thresholdHourLoad <= 0) { return null; } return this.applyRangeCheck({ calculationMethod: data_training_stress_score_method_1.TrainingStressScoreMethod.HR, trainingStressScore: (100 * sessionLoad) / thresholdHourLoad }); } static calculateEdwardsHrTss(samples, maxHeartRate, lactateThresholdHR) { if (maxHeartRate <= 0) { return null; } const sessionLoad = this.accumulateSampleLoad(samples, sample => { const hr = sample.hr; return this.edwardsZoneWeight(hr / maxHeartRate); }); const thresholdRatio = this.resolveEdwardsThresholdRatio(maxHeartRate, this.positive(lactateThresholdHR)); const thresholdWeight = this.edwardsZoneWeight(thresholdRatio); const thresholdHourLoad = 60 * thresholdWeight; if (!Number.isFinite(sessionLoad) || !Number.isFinite(thresholdHourLoad) || thresholdHourLoad <= 0) { return null; } return this.applyRangeCheck({ calculationMethod: data_training_stress_score_method_1.TrainingStressScoreMethod.HR, trainingStressScore: (100 * sessionLoad) / thresholdHourLoad }); } static accumulateSampleLoad(samples, loadPerMinuteForSample) { let previousDuration = 0; let sum = 0; samples.forEach(sample => { const dtSeconds = sample.duration - previousDuration; previousDuration = sample.duration; if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) { return; } const loadPerMinute = loadPerMinuteForSample(sample); if (!Number.isFinite(loadPerMinute) || loadPerMinute < 0) { return; } sum += loadPerMinute * (dtSeconds / 60); }); return sum; } static resolveBanisterThresholdDhr(maxHeartRate, restingHeartRate, lactateThresholdHR) { if (lactateThresholdHR !== null && maxHeartRate > restingHeartRate) { return this.clamp((lactateThresholdHR - restingHeartRate) / (maxHeartRate - restingHeartRate), 0, 1); } return DEFAULT_HR_THRESHOLD_RATIO; } static resolveEdwardsThresholdRatio(maxHeartRate, lactateThresholdHR) { if (lactateThresholdHR !== null && maxHeartRate > 0) { return this.clamp(lactateThresholdHR / maxHeartRate, 0, 1); } return DEFAULT_HR_THRESHOLD_RATIO; } static edwardsZoneWeight(hrRatio) { const percentage = hrRatio * 100; if (!Number.isFinite(percentage) || percentage < 50) { return 0; } if (percentage < 60) { return 1; } if (percentage < 70) { return 2; } if (percentage < 80) { return 3; } if (percentage < 90) { return 4; } return 5; } static resolveSampleGrade(sample, speed) { if (Number.isFinite(sample.grade)) { return sample.grade; } if (Number.isFinite(sample.verticalSpeed) && speed > 0) { return sample.verticalSpeed / speed; } return null; } static runningCostMinetti(grade) { return (155.4 * Math.pow(grade, 5) - 30.4 * Math.pow(grade, 4) - 43.3 * Math.pow(grade, 3) + 46.3 * Math.pow(grade, 2) + 19.5 * grade + 3.6); } static isFemale(gender) { if (!gender) { return false; } const normalized = gender.trim().toLowerCase(); return normalized === 'f' || normalized === 'female' || normalized === 'woman'; } static applyRangeCheck(result) { if (!Number.isFinite(result.trainingStressScore) || result.trainingStressScore < this.TSS_MIN || result.trainingStressScore > this.TSS_MAX) { return null; } return result; } static average(values) { if (!values.length) { return NaN; } return values.reduce((sum, value) => sum + value, 0) / values.length; } static positive(value) { return Number.isFinite(value) && value > 0 ? value : null; } static clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } } exports.TssCalculator = TssCalculator; TssCalculator.TSS_MIN = 0; TssCalculator.TSS_MAX = 9999;