@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
JavaScript
"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;