@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
288 lines (287 loc) • 17.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const activity_utilities_1 = require("./activity.utilities");
const activity_1 = require("../../activities/activity");
const data_power_1 = require("../../data/data.power");
const data_power_curve_1 = require("../../data/data.power-curve");
const data_critical_power_1 = require("../../data/data.critical-power");
const data_w_prime_1 = require("../../data/data.w-prime");
const data_ftp_1 = require("../../data/data.ftp");
const data_power_normalized_1 = require("../../data/data.power-normalized");
const data_power_intensity_factor_1 = require("../../data/data.power-intensity-factor");
const data_training_stress_score_1 = require("../../data/data.training-stress-score");
const data_heart_rate_1 = require("../../data/data.heart-rate");
const data_training_stress_score_method_1 = require("../../data/data.training-stress-score-method");
const activity_types_1 = require("../../activities/activity.types");
const event_1 = require("../event");
const event_utilities_1 = require("./event.utilities");
const file_type_enum_1 = require("../adapters/file-type.enum");
const intensity_zones_1 = require("../../intensity-zones/intensity-zones");
describe('Power Analytics Integration', () => {
it("should automatically generate Power Curve, FTP, Critical Power and W' when generating stats", () => {
// Create an activity with ~20 mins of power data
// 360W for 3 mins (180s)
// 260W for remaining 17 mins (1020s)
// Total 1200s
// CP should be around ~245-250W
const powerValues = new Array(1200).fill(260);
for (let i = 0; i < 180; i++) {
powerValues[i] = 360;
}
const startDate = new Date();
const endDate = new Date(startDate.getTime() + powerValues.length * 1000);
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
const powerStream = activity.createStream(data_power_1.DataPower.type);
powerStream.setData(powerValues);
activity.addStream(powerStream);
// Verify stats are missing initially
expect(activity.getStat(data_power_curve_1.DataPowerCurve.type)).toBeFalsy();
expect(activity.getStat(data_critical_power_1.DataCriticalPower.type)).toBeFalsy();
expect(activity.getStat(data_w_prime_1.DataWPrime.type)).toBeFalsy();
// Run the pipeline
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
// Verify Power Curve
const curveStat = activity.getStat(data_power_curve_1.DataPowerCurve.type);
expect(curveStat).not.toBeNull();
// @ts-ignore
const points = curveStat.getValue();
expect(points.length).toBeGreaterThan(0);
// Verify CP & W'
const ftpStat = activity.getStat(data_ftp_1.DataFTP.type);
const cpStat = activity.getStat(data_critical_power_1.DataCriticalPower.type);
const wPrimeStat = activity.getStat(data_w_prime_1.DataWPrime.type);
expect(ftpStat).not.toBeNull();
expect(cpStat).not.toBeNull();
expect(wPrimeStat).not.toBeNull();
if (ftpStat && cpStat && wPrimeStat) {
console.log(`Calculated FTP: ${ftpStat.getValue()}, CP: ${cpStat.getValue()}, W': ${wPrimeStat.getValue()}`);
expect(ftpStat.getValue()).toBe(261);
expect(cpStat.getValue()).toBeGreaterThan(200);
expect(wPrimeStat.getValue()).toBeGreaterThan(10000);
}
});
it('should calculate missing IF and TSS when power data and FTP are available', () => {
var _a;
const powerValues = new Array(3600).fill(300);
const startDate = new Date();
const endDate = new Date(startDate.getTime() + powerValues.length * 1000);
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
const powerStream = activity.createStream(data_power_1.DataPower.type);
powerStream.setData(powerValues);
activity.addStream(powerStream);
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
const normalizedPower = activity.getStat(data_power_normalized_1.DataPowerNormalized.type);
const ftp = activity.getStat(data_ftp_1.DataFTP.type);
const intensityFactor = activity.getStat(data_power_intensity_factor_1.DataPowerIntensityFactor.type);
const tss = activity.getStat(data_training_stress_score_1.DataTrainingStressScore.type);
expect(normalizedPower).toBeDefined();
expect(ftp).toBeDefined();
expect(intensityFactor).toBeDefined();
expect(tss).toBeDefined();
expect(normalizedPower.getValue()).toBeCloseTo(300, 1);
expect(ftp.getValue()).toBe(285);
expect(intensityFactor.getValue()).toBeCloseTo(1.053, 3);
expect(tss.getValue()).toBeCloseTo(109.9, 1);
expect((_a = activity.getStat(data_training_stress_score_method_1.DataTrainingStressScoreMethod.type)) === null || _a === void 0 ? void 0 : _a.getValue()).toBe(data_training_stress_score_method_1.TrainingStressScoreMethod.POWER);
});
it('should not calculate TSS from IF alone when no supported method inputs are available', () => {
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 600 * 1000);
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
const powerStream = activity.createStream(data_power_1.DataPower.type);
powerStream.setData(new Array(600).fill(250));
activity.addStream(powerStream);
activity.addStat(new data_power_intensity_factor_1.DataPowerIntensityFactor(0.8));
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
const ftp = activity.getStat(data_ftp_1.DataFTP.type);
const tss = activity.getStat(data_training_stress_score_1.DataTrainingStressScore.type);
const method = activity.getStat(data_training_stress_score_method_1.DataTrainingStressScoreMethod.type);
expect(ftp).toBeFalsy();
expect(tss).toBeFalsy();
expect(method).toBeFalsy();
});
it('should calculate TSS from heart-rate stream when power-based TSS is unavailable', () => {
var _a;
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 3600 * 1000);
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Running, { toJSON: () => ({}) });
const hrStream = activity.createStream(data_heart_rate_1.DataHeartRate.type);
hrStream.setData(new Array(3600).fill(160));
activity.addStream(hrStream);
const zones = new intensity_zones_1.IntensityZones(data_heart_rate_1.DataHeartRate.type);
zones.zone1Duration = 0;
zones.zone2Duration = 0;
zones.zone3Duration = 0;
zones.zone4Duration = 0;
zones.zone5Duration = 0;
zones.zone5LowerLimit = 170;
activity.intensityZones.push(zones);
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
const tss = activity.getStat(data_training_stress_score_1.DataTrainingStressScore.type);
expect(tss).toBeDefined();
expect(tss.getValue()).toBeCloseTo(100, 1);
expect((_a = activity.getStat(data_training_stress_score_method_1.DataTrainingStressScoreMethod.type)) === null || _a === void 0 ? void 0 : _a.getValue()).toBe(data_training_stress_score_method_1.TrainingStressScoreMethod.HR);
});
it('should prefer power-based TSS over heart-rate-zone fallback when both are available', () => {
var _a;
const powerValues = new Array(3600).fill(300);
const startDate = new Date();
const endDate = new Date(startDate.getTime() + powerValues.length * 1000);
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
const powerStream = activity.createStream(data_power_1.DataPower.type);
powerStream.setData(powerValues);
activity.addStream(powerStream);
const hrStream = activity.createStream(data_heart_rate_1.DataHeartRate.type);
hrStream.setData(new Array(3600).fill(160));
activity.addStream(hrStream);
const zones = new intensity_zones_1.IntensityZones(data_heart_rate_1.DataHeartRate.type);
zones.zone1Duration = 0;
zones.zone2Duration = 0;
zones.zone3Duration = 0;
zones.zone4Duration = 0;
zones.zone5Duration = 0;
zones.zone5LowerLimit = 170;
activity.intensityZones.push(zones);
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
const tss = activity.getStat(data_training_stress_score_1.DataTrainingStressScore.type);
expect(tss).toBeDefined();
expect(tss.getValue()).toBeCloseTo(109.9, 1);
expect((_a = activity.getStat(data_training_stress_score_method_1.DataTrainingStressScoreMethod.type)) === null || _a === void 0 ? void 0 : _a.getValue()).toBe(data_training_stress_score_method_1.TrainingStressScoreMethod.POWER);
});
it("should serialize Power Curve, FTP, Critical Power and W' correctly in toJSON", () => {
const powerValues = new Array(1200).fill(260); // 1200 points
for (let i = 0; i < 180; i++) {
powerValues[i] = 360;
}
const startDate = new Date();
const endDate = new Date(startDate.getTime() + powerValues.length * 1000);
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
const powerStream = activity.createStream(data_power_1.DataPower.type);
powerStream.setData(powerValues);
activity.addStream(powerStream);
// Generate stats
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
const json = activity.toJSON();
// Check PowerCurve property serialization
expect(json.powerCurve).toBeDefined();
// @ts-ignore
expect(json.powerCurve[data_power_curve_1.DataPowerCurve.type]).toBeDefined();
// @ts-ignore
expect(Array.isArray(json.powerCurve[data_power_curve_1.DataPowerCurve.type])).toBeTruthy();
// Check structure of first point
// @ts-ignore
const firstPoint = json.powerCurve[data_power_curve_1.DataPowerCurve.type][0];
expect(firstPoint.duration).toBeDefined();
expect(firstPoint.power).toBeDefined();
// Check CP and W' in stats
expect(json.stats).toBeDefined();
expect(json.stats[data_ftp_1.DataFTP.type]).toBeDefined();
expect(json.stats[data_critical_power_1.DataCriticalPower.type]).toBeDefined();
expect(json.stats[data_w_prime_1.DataWPrime.type]).toBeDefined();
expect(typeof json.stats[data_ftp_1.DataFTP.type]).toBe('number');
expect(json.stats[data_ftp_1.DataFTP.type]).toBe(261);
expect(typeof json.stats[data_critical_power_1.DataCriticalPower.type]).toBe('number');
expect(json.stats[data_critical_power_1.DataCriticalPower.type]).toBeGreaterThan(200);
expect(typeof json.stats[data_w_prime_1.DataWPrime.type]).toBe('number');
expect(json.stats[data_w_prime_1.DataWPrime.type]).toBeGreaterThan(0);
});
it("should aggregate Power Curve and calculate CP/W' correctly for Events", () => {
// Activity 1: Constant 360W for 180s, then 260W for 420s (Total 600s)
const p1 = new Array(600).fill(260);
for (let i = 0; i < 180; i++)
p1[i] = 360;
const start1 = new Date();
const end1 = new Date(start1.getTime() + 600000);
// @ts-ignore
const a1 = new activity_1.Activity(start1, end1, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
const s1 = a1.createStream(data_power_1.DataPower.type);
s1.setData(p1);
a1.addStream(s1);
// Activity 2: Constant 250W for 10 mins (will be ignored by max power mostly)
const p2 = new Array(600).fill(250);
const start2 = new Date(end1.getTime() + 1000);
const end2 = new Date(start2.getTime() + 600000);
// @ts-ignore
const a2 = new activity_1.Activity(start2, end2, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
const s2 = a2.createStream(data_power_1.DataPower.type);
s2.setData(p2);
a2.addStream(s2);
const event = new event_1.Event('Test Event', start1, end2, file_type_enum_1.FileType.FIT);
event.addActivity(a1);
event.addActivity(a2);
// Process event
event_utilities_1.EventUtilities.generateStatsForAll(event);
// Check Event level stats
expect(event.getStat(data_power_curve_1.DataPowerCurve.type)).toBeDefined();
expect(event.getStat(data_critical_power_1.DataCriticalPower.type)).toBeDefined();
expect(event.getStat(data_w_prime_1.DataWPrime.type)).toBeDefined();
const json = event.toJSON();
expect(json.powerCurve).toBeDefined();
expect(json.stats[data_critical_power_1.DataCriticalPower.type]).toBeDefined();
expect(json.stats[data_w_prime_1.DataWPrime.type]).toBeDefined();
console.log(`Event Aggregated CP: ${json.stats[data_critical_power_1.DataCriticalPower.type]}, W': ${json.stats[data_w_prime_1.DataWPrime.type]}`);
});
it('should not throw and handle activities with no power data', () => {
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 60000); // 1 min
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
expect(() => {
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
}).not.toThrow();
expect(activity.getStat(data_power_curve_1.DataPowerCurve.type)).toBeFalsy();
expect(activity.getStat(data_ftp_1.DataFTP.type)).toBeFalsy();
expect(activity.getStat(data_critical_power_1.DataCriticalPower.type)).toBeFalsy();
expect(activity.getStat(data_w_prime_1.DataWPrime.type)).toBeFalsy();
});
it("should not calculate FTP/CP/W' if activity is too short", () => {
const powerValues = new Array(120).fill(300); // 2 mins
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 120000);
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
const powerStream = activity.createStream(data_power_1.DataPower.type);
powerStream.setData(powerValues);
activity.addStream(powerStream);
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
// Power Curve should still be generated (MMP for 1s, 2s etc)
expect(activity.getStat(data_power_curve_1.DataPowerCurve.type)).toBeDefined();
// FTP should NOT be generated because 20-min duration point is missing
expect(activity.getStat(data_ftp_1.DataFTP.type)).toBeFalsy();
// CP/W' should NOT be generated because min duration for CP is 180s
expect(activity.getStat(data_critical_power_1.DataCriticalPower.type)).toBeFalsy();
expect(activity.getStat(data_w_prime_1.DataWPrime.type)).toBeFalsy();
});
it('should handle activities with all null power values', () => {
const powerValues = new Array(600).fill(null);
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 600000);
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
const powerStream = activity.createStream(data_power_1.DataPower.type);
powerStream.setData(powerValues);
activity.addStream(powerStream);
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
// MMP will be all zeros, CP/W' calculation will likely result in null or 0 slope/intercept
expect(activity.getStat(data_ftp_1.DataFTP.type)).toBeFalsy();
expect(activity.getStat(data_critical_power_1.DataCriticalPower.type)).toBeFalsy();
expect(activity.getStat(data_w_prime_1.DataWPrime.type)).toBeFalsy();
});
it('should calculate FTP from existing power curve when power stream is missing', () => {
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1200000);
// @ts-ignore
const activity = new activity_1.Activity(startDate, endDate, activity_types_1.ActivityTypes.Cycling, { toJSON: () => ({}) });
activity.addStat(new data_power_curve_1.DataPowerCurve([{ duration: 1200, power: 300 }]));
activity_utilities_1.ActivityUtilities.generateMissingStreamsAndStatsForActivity(activity);
const ftpStat = activity.getStat(data_ftp_1.DataFTP.type);
expect(ftpStat).toBeDefined();
expect(ftpStat.getValue()).toBe(285);
});
});