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

288 lines (287 loc) 17.6 kB
"use strict"; 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); }); });