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

711 lines (710 loc) 37.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.DEFAULT_DURABILITY_PROTOCOL = void 0; exports.calculateActivityDurabilitySourceFingerprint = calculateActivityDurabilitySourceFingerprint; exports.hasActivityDurabilitySourceData = hasActivityDurabilitySourceData; exports.calculateAerobicEfficiency = calculateAerobicEfficiency; exports.analyzeActivityDurability = analyzeActivityDurability; const activity_types_1 = require("../../activities/activity.types"); const data_durability_evidence_1 = require("../../data/data.durability-evidence"); const data_grade_1 = require("../../data/data.grade"); const data_grade_adjusted_speed_1 = require("../../data/data.grade-adjusted-speed"); const data_heart_rate_1 = require("../../data/data.heart-rate"); const data_heart_rate_zone_five_duration_1 = require("../../data/data.heart-rate-zone-five-duration"); const data_heart_rate_zone_four_duration_1 = require("../../data/data.heart-rate-zone-four-duration"); const data_heart_rate_zone_one_duration_1 = require("../../data/data.heart-rate-zone-one-duration"); const data_heart_rate_zone_seven_duration_1 = require("../../data/data.heart-rate-zone-seven-duration"); const data_heart_rate_zone_six_duration_1 = require("../../data/data.heart-rate-zone-six-duration"); const data_heart_rate_zone_three_duration_1 = require("../../data/data.heart-rate-zone-three-duration"); const data_heart_rate_zone_two_duration_1 = require("../../data/data.heart-rate-zone-two-duration"); const data_power_1 = require("../../data/data.power"); const data_power_zone_five_duration_1 = require("../../data/data.power-zone-five-duration"); const data_power_zone_four_duration_1 = require("../../data/data.power-zone-four-duration"); const data_power_zone_one_duration_1 = require("../../data/data.power-zone-one-duration"); const data_power_zone_seven_duration_1 = require("../../data/data.power-zone-seven-duration"); const data_power_zone_six_duration_1 = require("../../data/data.power-zone-six-duration"); const data_power_zone_three_duration_1 = require("../../data/data.power-zone-three-duration"); const data_power_zone_two_duration_1 = require("../../data/data.power-zone-two-duration"); const data_speed_1 = require("../../data/data.speed"); exports.DEFAULT_DURABILITY_PROTOCOL = Object.freeze(Object.assign(Object.assign({ smoothingWindowSeconds: 60, warmupSeconds: 10 * 60, cooldownSeconds: 5 * 60 }, data_durability_evidence_1.DURABILITY_PROTOCOL_ELIGIBILITY_THRESHOLDS), { maximumFlatGradePercent: 2, minimumFlatGradeCoverageRatio: 0.8 })); const HEART_RATE_ZONE_TYPES = [ data_heart_rate_zone_one_duration_1.DataHeartRateZoneOneDuration.type, data_heart_rate_zone_two_duration_1.DataHeartRateZoneTwoDuration.type, data_heart_rate_zone_three_duration_1.DataHeartRateZoneThreeDuration.type, data_heart_rate_zone_four_duration_1.DataHeartRateZoneFourDuration.type, data_heart_rate_zone_five_duration_1.DataHeartRateZoneFiveDuration.type, data_heart_rate_zone_six_duration_1.DataHeartRateZoneSixDuration.type, data_heart_rate_zone_seven_duration_1.DataHeartRateZoneSevenDuration.type ]; const POWER_ZONE_TYPES = [ data_power_zone_one_duration_1.DataPowerZoneOneDuration.type, data_power_zone_two_duration_1.DataPowerZoneTwoDuration.type, data_power_zone_three_duration_1.DataPowerZoneThreeDuration.type, data_power_zone_four_duration_1.DataPowerZoneFourDuration.type, data_power_zone_five_duration_1.DataPowerZoneFiveDuration.type, data_power_zone_six_duration_1.DataPowerZoneSixDuration.type, data_power_zone_seven_duration_1.DataPowerZoneSevenDuration.type ]; /** * Hashes the effective protocol-v1 source inputs without retaining a second copy of long streams. * The digest is an invalidation key, not a security boundary. */ function calculateActivityDurabilitySourceFingerprint(activity) { const fingerprint = new DurabilityFingerprint(); fingerprint.add('protocol-version', data_durability_evidence_1.DURABILITY_PROTOCOL_VERSION); Object.keys(exports.DEFAULT_DURABILITY_PROTOCOL) .sort() .forEach(key => fingerprint.add(`protocol-${key}`, exports.DEFAULT_DURABILITY_PROTOCOL[key])); fingerprint.add('activity-type', activity.type); fingerprint.add('duration-seconds', resolveActivityDurationSeconds(activity)); const group = activity_types_1.ActivityTypesHelper.getActivityGroupForActivityType(activity.type); if (group === activity_types_1.ActivityTypeGroups.CyclingGroup || group === activity_types_1.ActivityTypeGroups.MountainBikingGroup) { addStreamFingerprint(fingerprint, activity, data_power_1.DataPower.type); addStreamFingerprint(fingerprint, activity, data_heart_rate_1.DataHeartRate.type); addStatFingerprints(fingerprint, activity, POWER_ZONE_TYPES); addStatFingerprints(fingerprint, activity, HEART_RATE_ZONE_TYPES); } else if (group === activity_types_1.ActivityTypeGroups.RunningGroup || group === activity_types_1.ActivityTypeGroups.TrailRunningGroup) { addStreamFingerprint(fingerprint, activity, data_grade_adjusted_speed_1.DataGradeAdjustedSpeed.type); addStreamFingerprint(fingerprint, activity, data_speed_1.DataSpeed.type); addStreamFingerprint(fingerprint, activity, data_grade_1.DataGrade.type); addStreamFingerprint(fingerprint, activity, data_heart_rate_1.DataHeartRate.type); addStatFingerprints(fingerprint, activity, HEART_RATE_ZONE_TYPES); } else if (group === activity_types_1.ActivityTypeGroups.SwimmingGroup && activity.type === activity_types_1.ActivityTypes.OpenWaterSwimming) { addStreamFingerprint(fingerprint, activity, data_speed_1.DataSpeed.type); addStreamFingerprint(fingerprint, activity, data_heart_rate_1.DataHeartRate.type); addStatFingerprints(fingerprint, activity, HEART_RATE_ZONE_TYPES); } else if (group === activity_types_1.ActivityTypeGroups.SwimmingGroup) { addSwimLengthFingerprints(fingerprint, activity); addStatFingerprints(fingerprint, activity, HEART_RATE_ZONE_TYPES); } return fingerprint.digest(); } /** True when the activity still contains raw inputs from which durability can be recalculated. */ function hasActivityDurabilitySourceData(activity) { const group = activity_types_1.ActivityTypesHelper.getActivityGroupForActivityType(activity.type); if (group === activity_types_1.ActivityTypeGroups.CyclingGroup || group === activity_types_1.ActivityTypeGroups.MountainBikingGroup) { return hasAnyStreamValues(activity, [data_power_1.DataPower.type, data_heart_rate_1.DataHeartRate.type]); } if (group === activity_types_1.ActivityTypeGroups.RunningGroup || group === activity_types_1.ActivityTypeGroups.TrailRunningGroup) { return hasAnyStreamValues(activity, [ data_grade_adjusted_speed_1.DataGradeAdjustedSpeed.type, data_speed_1.DataSpeed.type, data_grade_1.DataGrade.type, data_heart_rate_1.DataHeartRate.type ]); } if (group === activity_types_1.ActivityTypeGroups.SwimmingGroup && activity.type === activity_types_1.ActivityTypes.OpenWaterSwimming) { return hasAnyStreamValues(activity, [data_speed_1.DataSpeed.type, data_heart_rate_1.DataHeartRate.type]); } if (group === activity_types_1.ActivityTypeGroups.SwimmingGroup) { return safeGetSwimLengths(activity).length > 0; } return false; } class DurabilityFingerprint { constructor() { this.first = 0x811c9dc5; this.second = 0x9e3779b9; } add(label, value) { this.update(`${label.length}:${label}${fingerprintValue(value)}`); } digest() { return `durability-v1:${toHex(this.first)}${toHex(this.second)}`; } update(value) { for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); this.first = Math.imul(this.first ^ code, 0x01000193); this.second = Math.imul(this.second ^ code, 0x85ebca6b); this.second ^= this.second >>> 13; } } } function addStreamFingerprint(fingerprint, activity, type) { const values = safeGetStream(activity, type); fingerprint.add(`${type}-length`, values.length); values.forEach((value, index) => fingerprint.add(`${type}-${index}`, value)); } function addStatFingerprints(fingerprint, activity, types) { types.forEach(type => fingerprint.add(type, safeGetStatValue(activity, type))); } function addSwimLengthFingerprints(fingerprint, activity) { const lengths = safeGetSwimLengths(activity); fingerprint.add('swim-length-count', lengths.length); lengths.forEach((length, position) => { const prefix = `swim-length-${position}`; fingerprint.add(`${prefix}-index`, length.index); fingerprint.add(`${prefix}-type`, length.type); fingerprint.add(`${prefix}-stroke`, length.stroke); fingerprint.add(`${prefix}-pool-length`, safeReadDataValue(length.poolLength)); fingerprint.add(`${prefix}-distance`, safeReadDataValue(length.distance)); fingerprint.add(`${prefix}-timer-time`, safeReadDataValue(length.timerTime)); fingerprint.add(`${prefix}-elapsed-time`, safeReadDataValue(length.elapsedTime)); fingerprint.add(`${prefix}-average-speed`, safeReadDataValue(length.avgSpeed)); fingerprint.add(`${prefix}-swolf`, length.swolf); }); } function hasAnyStreamValues(activity, types) { return types.some(type => safeGetStream(activity, type).length > 0); } function safeGetSwimLengths(activity) { var _a; try { const lengths = (_a = activity.getSwimLengths) === null || _a === void 0 ? void 0 : _a.call(activity); return Array.isArray(lengths) ? lengths : []; } catch (_error) { return []; } } function safeGetStatValue(activity, type) { var _a, _b, _c; try { return (_c = (_b = (_a = activity.getStat) === null || _a === void 0 ? void 0 : _a.call(activity, type)) === null || _b === void 0 ? void 0 : _b.getValue) === null || _c === void 0 ? void 0 : _c.call(_b); } catch (_error) { return null; } } function safeReadDataValue(value) { var _a; try { return (_a = value === null || value === void 0 ? void 0 : value.getValue()) !== null && _a !== void 0 ? _a : null; } catch (_error) { return null; } } function fingerprintValue(value) { if (value === null) { return '4:null'; } if (value === undefined) { return '9:undefined'; } if (typeof value === 'number') { const normalized = Number.isNaN(value) ? 'NaN' : value === Infinity ? 'Infinity' : value === -Infinity ? '-Infinity' : Object.is(value, -0) ? '-0' : `${value}`; return `${normalized.length + 2}:n:${normalized}`; } if (typeof value === 'string') { return `${value.length + 2}:s:${value}`; } if (typeof value === 'boolean') { return value ? '3:b:1' : '3:b:0'; } return `${typeof value}`; } function toHex(value) { return (value >>> 0).toString(16).padStart(8, '0'); } function calculateAerobicEfficiency(output, heartRateBpm) { if (!Number.isFinite(output) || !Number.isFinite(heartRateBpm) || Number(output) <= 0 || Number(heartRateBpm) <= 0) { return null; } return Number(output) / Number(heartRateBpm); } function analyzeActivityDurability(activity, options = {}) { const protocol = exports.DEFAULT_DURABILITY_PROTOCOL; const sourceFingerprint = calculateActivityDurabilitySourceFingerprint(activity); const group = activity_types_1.ActivityTypesHelper.getActivityGroupForActivityType(activity.type); if (group === activity_types_1.ActivityTypeGroups.SwimmingGroup && activity.type !== activity_types_1.ActivityTypes.OpenWaterSwimming) { return analyzePoolDurability(activity, protocol, sourceFingerprint); } const resolvedOutput = resolveAerobicOutput(activity, protocol); if (!resolvedOutput) { return { timeline: [], summary: null }; } return analyzeAerobicDurability(activity, resolvedOutput, protocol, options.includeTimeline !== false, sourceFingerprint); } function analyzeAerobicDurability(activity, resolvedOutput, protocol, includeTimeline, sourceFingerprint) { const durationSeconds = resolveActivityDurationSeconds(activity); const heartRate = safeGetStream(activity, data_heart_rate_1.DataHeartRate.type); const streamLength = Math.max(resolvedOutput.values.length, heartRate.length); const comparisonStart = protocol.warmupSeconds; const boundedStreamLength = durationSeconds > 0 ? Math.min(streamLength, Math.ceil(durationSeconds)) : streamLength; const comparisonEnd = Math.max(comparisonStart, boundedStreamLength - protocol.cooldownSeconds); const comparisonMidpoint = comparisonStart + (comparisonEnd - comparisonStart) / 2; if (resolvedOutput.unavailableReason || !resolvedOutput.values.some(isPositiveFinite)) { return buildIneligibleAerobicResult(resolvedOutput, durationSeconds, resolvedOutput.unavailableReason || 'missing-output', sourceFingerprint); } if (!heartRate.some(isPositiveFinite)) { return buildIneligibleAerobicResult(resolvedOutput, durationSeconds, 'missing-heart-rate', sourceFingerprint); } const processed = processAerobicSamples(resolvedOutput.values, heartRate, boundedStreamLength, protocol.smoothingWindowSeconds, comparisonStart, comparisonEnd, comparisonMidpoint, includeTimeline); const eligibleSpanSeconds = Math.max(0, comparisonEnd - comparisonStart); const coverageRatio = eligibleSpanSeconds > 0 ? Math.min(1, processed.comparison.count / eligibleSpanSeconds) : 0; const earlySpanSeconds = Math.max(0, comparisonMidpoint - comparisonStart); const lateSpanSeconds = Math.max(0, comparisonEnd - comparisonMidpoint); const earlyCoverageRatio = earlySpanSeconds > 0 ? processed.early.count / earlySpanSeconds : 0; const lateCoverageRatio = lateSpanSeconds > 0 ? processed.late.count / lateSpanSeconds : 0; const hardZoneRatio = resolveHardZoneRatio(activity, resolvedOutput.source); const coefficientOfVariation = resolveAccumulatorCoefficientOfVariation(processed.comparison); let reason = 'eligible'; if (durationSeconds < protocol.minimumActivityDurationSeconds) { reason = 'insufficient-duration'; } else if (processed.comparison.count < protocol.minimumQualifyingDurationSeconds || coverageRatio < protocol.minimumCoverageRatio) { reason = 'insufficient-coverage'; } else if (earlyCoverageRatio < protocol.minimumCoverageRatio || lateCoverageRatio < protocol.minimumCoverageRatio) { reason = 'insufficient-halves'; } else if (hardZoneRatio !== null && hardZoneRatio > protocol.maximumHardZoneRatio) { reason = 'too-intense'; } else if (coefficientOfVariation !== null && coefficientOfVariation > protocol.maximumOutputCoefficientOfVariation) { reason = 'too-variable'; } if (reason === 'eligible' && (processed.early.count <= 0 || processed.late.count <= 0)) { reason = 'insufficient-halves'; } const eligibility = buildEligibility(reason, processed.comparison.count, processed.early.count, processed.late.count, coefficientOfVariation, hardZoneRatio, 'halves'); const evidence = reason === 'eligible' ? buildAerobicEvidence(processed.early, processed.late) : null; if (reason === 'eligible' && !evidence) { eligibility.eligible = false; eligibility.reason = 'insufficient-halves'; } return { timeline: processed.timeline, summary: { protocolVersion: data_durability_evidence_1.DURABILITY_PROTOCOL_VERSION, sourceFingerprint, discipline: resolvedOutput.discipline, outputSource: resolvedOutput.source, outputUnit: resolvedOutput.source === 'power' ? 'W' : 'm/s', context: null, durationSeconds: round(durationSeconds, 2), qualifyingDurationSeconds: processed.comparison.count, coverageRatio: round(coverageRatio, 4), eligibility, evidence } }; } function analyzePoolDurability(activity, protocol, sourceFingerprint) { var _a, _b; const durationSeconds = resolveActivityDurationSeconds(activity); const rawActiveLengths = (((_a = activity.getSwimLengths) === null || _a === void 0 ? void 0 : _a.call(activity)) || []).filter(length => `${length.type}`.toLowerCase() === 'active'); const comparableLengths = rawActiveLengths.filter(length => !!(0, data_durability_evidence_1.normalizeComparablePoolStroke)(length.stroke) && resolveLengthPoolMeters(length) !== null && resolveLengthPaceSecondsPer100m(length) !== null && resolveLengthDurationSeconds(length) !== null); const grouped = new Map(); comparableLengths.forEach(length => { const poolLength = resolveLengthPoolMeters(length); const stroke = (0, data_durability_evidence_1.normalizeComparablePoolStroke)(length.stroke); const key = `${round(poolLength, 3)}:${stroke}`; const values = grouped.get(key) || []; values.push(length); grouped.set(key, values); }); const selected = ((_b = [...grouped.entries()].sort((left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0]))[0]) === null || _b === void 0 ? void 0 : _b[1]) || []; const context = selected.length ? { poolLengthMeters: round(resolveLengthPoolMeters(selected[0]), 3), stroke: (0, data_durability_evidence_1.normalizeComparablePoolStroke)(selected[0].stroke) } : null; const thirdCount = Math.floor(selected.length / 3); const selectedDurationSeconds = sumLengthDurations(selected); const rawActiveDurationSeconds = sumLengthDurations(rawActiveLengths); const coverageRatio = rawActiveDurationSeconds > 0 ? Math.min(1, selectedDurationSeconds / rawActiveDurationSeconds) : rawActiveLengths.length > 0 ? selected.length / rawActiveLengths.length : 0; const coefficientOfVariation = resolveCoefficientOfVariation(selected.flatMap(length => { const pace = resolveLengthPaceSecondsPer100m(length); return pace === null ? [] : [pace]; })); const hardZoneRatio = resolveHardZoneRatio(activity, 'pool-length-speed'); let reason = 'eligible'; if (!rawActiveLengths.length) { reason = 'missing-output'; } else if (!context) { reason = comparableLengths.length ? 'unsupported-context' : resolveMissingPoolReason(rawActiveLengths); } else if (durationSeconds < protocol.minimumActivityDurationSeconds) { reason = 'insufficient-duration'; } else if (selectedDurationSeconds < protocol.minimumQualifyingDurationSeconds || coverageRatio < protocol.minimumCoverageRatio) { reason = 'insufficient-coverage'; } else if (selected.length < protocol.minimumPoolComparableLengths || thirdCount < protocol.minimumPoolThirdLengths) { reason = 'insufficient-halves'; } else if (hardZoneRatio !== null && hardZoneRatio > protocol.maximumHardZoneRatio) { reason = 'too-intense'; } else if (coefficientOfVariation !== null && coefficientOfVariation > protocol.maximumOutputCoefficientOfVariation) { reason = 'too-variable'; } return { timeline: [], summary: buildPoolSummary(durationSeconds, context, selected, selectedDurationSeconds, coverageRatio, coefficientOfVariation, hardZoneRatio, reason, sourceFingerprint) }; } function buildPoolSummary(durationSeconds, context, lengths, qualifyingDurationSeconds, coverageRatio, coefficientOfVariation, hardZoneRatio, reason, sourceFingerprint) { const thirdCount = Math.floor(lengths.length / 3); const first = thirdCount > 0 ? lengths.slice(0, thirdCount) : []; const final = thirdCount > 0 ? lengths.slice(lengths.length - thirdCount) : []; const evidence = reason === 'eligible' && context ? buildPoolEvidence(context, lengths.length, first, final) : null; const eligible = reason === 'eligible' && !!evidence; return { protocolVersion: data_durability_evidence_1.DURABILITY_PROTOCOL_VERSION, sourceFingerprint, discipline: 'pool-swimming', outputSource: 'pool-length-speed', outputUnit: 'm/s', context, durationSeconds: round(durationSeconds, 2), qualifyingDurationSeconds: round(Math.min(durationSeconds, qualifyingDurationSeconds), 2), coverageRatio: round(coverageRatio, 4), eligibility: buildEligibility(eligible ? 'eligible' : reason === 'eligible' ? 'insufficient-halves' : reason, lengths.length, first.length, final.length, coefficientOfVariation, hardZoneRatio, 'outer-thirds'), evidence }; } function buildPoolEvidence(context, comparableLengthCount, first, final) { const firstPace = average(first.map(resolveLengthPaceSecondsPer100m)); const finalPace = average(final.map(resolveLengthPaceSecondsPer100m)); if (firstPace === null || finalPace === null || finalPace <= 0) { return null; } const firstSwolf = average(first.map(length => toPositiveFinite(length.swolf))); const finalSwolf = average(final.map(length => toPositiveFinite(length.swolf))); return { kind: 'pool-consistency', poolLengthMeters: round(context.poolLengthMeters, 3), stroke: context.stroke, comparableLengthCount, firstPaceSecondsPer100m: round(firstPace, 3), finalPaceSecondsPer100m: round(finalPace, 3), paceRetentionPercent: round((firstPace / finalPace) * 100, 3), firstSwolf: firstSwolf === null ? null : round(firstSwolf, 3), finalSwolf: finalSwolf === null ? null : round(finalSwolf, 3), swolfChange: firstSwolf === null || finalSwolf === null ? null : round(finalSwolf - firstSwolf, 3) }; } function resolveAerobicOutput(activity, protocol) { const group = activity_types_1.ActivityTypesHelper.getActivityGroupForActivityType(activity.type); if (group === activity_types_1.ActivityTypeGroups.CyclingGroup || group === activity_types_1.ActivityTypeGroups.MountainBikingGroup) { return { discipline: 'cycling', source: 'power', values: safeGetStream(activity, data_power_1.DataPower.type) }; } if (group === activity_types_1.ActivityTypeGroups.RunningGroup || group === activity_types_1.ActivityTypeGroups.TrailRunningGroup) { const gradeAdjusted = safeGetStream(activity, data_grade_adjusted_speed_1.DataGradeAdjustedSpeed.type); if (hasComparisonWindowCoverage(gradeAdjusted, resolveActivityDurationSeconds(activity), protocol)) { return { discipline: 'running', source: 'grade-adjusted-speed', values: gradeAdjusted }; } if (canUseRawRunningSpeed(activity, protocol)) { return { discipline: 'running', source: 'speed', values: safeGetStream(activity, data_speed_1.DataSpeed.type) }; } return { discipline: 'running', source: 'grade-adjusted-speed', values: [], unavailableReason: 'unsupported-context' }; } if (group === activity_types_1.ActivityTypeGroups.SwimmingGroup && activity.type === activity_types_1.ActivityTypes.OpenWaterSwimming) { return { discipline: 'open-water-swimming', source: 'speed', values: safeGetStream(activity, data_speed_1.DataSpeed.type) }; } return null; } function canUseRawRunningSpeed(activity, protocol) { const speed = safeGetStream(activity, data_speed_1.DataSpeed.type); if (!hasComparisonWindowCoverage(speed, resolveActivityDurationSeconds(activity), protocol)) { return false; } if ([activity_types_1.ActivityTypes.Treadmill, activity_types_1.ActivityTypes.IndoorRunning, activity_types_1.ActivityTypes.VirtualRunning].includes(activity.type)) { return true; } const grades = safeGetStream(activity, data_grade_1.DataGrade.type); const [comparisonStart, comparisonEnd] = resolveComparisonWindow(grades.length, resolveActivityDurationSeconds(activity), protocol); const comparisonSpan = comparisonEnd - comparisonStart; if (comparisonSpan <= 0) { return false; } let flatCount = 0; for (let index = comparisonStart; index < comparisonEnd; index += 1) { const grade = toFinite(grades[index]); if (grade !== null && Math.abs(grade) <= protocol.maximumFlatGradePercent) { flatCount += 1; } } return flatCount / comparisonSpan >= protocol.minimumFlatGradeCoverageRatio; } function processAerobicSamples(outputs, heartRates, streamLength, smoothingWindowSeconds, comparisonStart, comparisonEnd, comparisonMidpoint, includeTimeline) { const timeline = []; const queue = []; let queueStart = 0; let outputSum = 0; let heartRateSum = 0; const comparison = createAerobicAccumulator(); const early = createAerobicAccumulator(); const late = createAerobicAccumulator(); for (let elapsedSeconds = 0; elapsedSeconds < streamLength; elapsedSeconds += 1) { const outputValue = toPositiveFinite(outputs[elapsedSeconds]); const heartRateValue = toPositiveFinite(heartRates[elapsedSeconds]); if (outputValue === null || heartRateValue === null) { continue; } const sample = { elapsedSeconds, output: outputValue, heartRateBpm: heartRateValue }; queue.push(sample); outputSum += sample.output; heartRateSum += sample.heartRateBpm; while (queueStart < queue.length - 1 && sample.elapsedSeconds - queue[queueStart].elapsedSeconds >= smoothingWindowSeconds) { outputSum -= queue[queueStart].output; heartRateSum -= queue[queueStart].heartRateBpm; queueStart += 1; } const count = queue.length - queueStart; const output = outputSum / count; const heartRateBpm = heartRateSum / count; const aerobicEfficiency = calculateAerobicEfficiency(output, heartRateBpm); if (aerobicEfficiency === null) { continue; } const inComparisonWindow = elapsedSeconds >= comparisonStart && elapsedSeconds < comparisonEnd; const comparisonHalf = !inComparisonWindow ? null : elapsedSeconds < comparisonMidpoint ? 'first' : 'second'; if (inComparisonWindow) { addAerobicAccumulatorSample(comparison, output, heartRateBpm); addAerobicAccumulatorSample(comparisonHalf === 'first' ? early : late, output, heartRateBpm); } if (includeTimeline) { timeline.push({ elapsedSeconds, sampleDurationSeconds: elapsedSeconds - queue[queueStart].elapsedSeconds + 1, output, heartRateBpm, aerobicEfficiency, rawOutput: sample.output, rawHeartRateBpm: sample.heartRateBpm, inComparisonWindow, comparisonHalf }); } if (queueStart > smoothingWindowSeconds * 2) { queue.splice(0, queueStart); queueStart = 0; } } return { timeline, comparison, early, late }; } function buildAerobicEvidence(firstHalf, secondHalf) { const firstOutput = averageAccumulatorOutput(firstHalf); const secondOutput = averageAccumulatorOutput(secondHalf); const firstHeartRate = averageAccumulatorHeartRate(firstHalf); const secondHeartRate = averageAccumulatorHeartRate(secondHalf); if (firstOutput === null || secondOutput === null || firstHeartRate === null || secondHeartRate === null) { return null; } // Persist canonical base values first, then calculate every derived value from those // rounded bases. Calculating each field independently from full-precision values can // produce a compact summary that fails its own arithmetic validation after rounding, // especially for low speed values such as open-water swimming. const firstHalfOutput = round(firstOutput, 4); const secondHalfOutput = round(secondOutput, 4); const firstHalfHeartRateBpm = round(firstHeartRate, 3); const secondHalfHeartRateBpm = round(secondHeartRate, 3); const canonicalFirstEfficiency = calculateAerobicEfficiency(firstHalfOutput, firstHalfHeartRateBpm); const canonicalSecondEfficiency = calculateAerobicEfficiency(secondHalfOutput, secondHalfHeartRateBpm); if (canonicalFirstEfficiency === null || canonicalSecondEfficiency === null) { return null; } const firstHalfEfficiency = round(canonicalFirstEfficiency, 6); const secondHalfEfficiency = round(canonicalSecondEfficiency, 6); return { kind: 'aerobic-efficiency', firstHalfEfficiency, secondHalfEfficiency, decouplingPercent: round(((firstHalfEfficiency - secondHalfEfficiency) / firstHalfEfficiency) * 100, 3), firstHalfOutput, secondHalfOutput, outputRetentionPercent: round((secondHalfOutput / firstHalfOutput) * 100, 3), firstHalfHeartRateBpm, secondHalfHeartRateBpm, heartRateDriftBpm: round(secondHalfHeartRateBpm - firstHalfHeartRateBpm, 3) }; } function buildIneligibleAerobicResult(resolvedOutput, durationSeconds, reason, sourceFingerprint) { return { timeline: [], summary: { protocolVersion: data_durability_evidence_1.DURABILITY_PROTOCOL_VERSION, sourceFingerprint, discipline: resolvedOutput.discipline, outputSource: resolvedOutput.source, outputUnit: resolvedOutput.source === 'power' ? 'W' : 'm/s', context: null, durationSeconds: round(durationSeconds, 2), qualifyingDurationSeconds: 0, coverageRatio: 0, eligibility: buildEligibility(reason, 0, 0, 0, null, null, 'halves'), evidence: null } }; } function buildEligibility(reason, validSampleCount, earlySampleCount, lateSampleCount, outputCoefficientOfVariation, hardZoneRatio, comparisonSegments) { return { eligible: reason === 'eligible', reason, validSampleCount, comparisonSegments, earlySampleCount, lateSampleCount, outputCoefficientOfVariation: outputCoefficientOfVariation === null ? null : round(outputCoefficientOfVariation, 6), hardZoneRatio: hardZoneRatio === null ? null : round(hardZoneRatio, 6) }; } function resolveHardZoneRatio(activity, outputSource) { const readZones = (types) => types.map(type => { var _a, _b, _c; const value = (_c = (_b = (_a = activity.getStat) === null || _a === void 0 ? void 0 : _a.call(activity, type)) === null || _b === void 0 ? void 0 : _b.getValue) === null || _c === void 0 ? void 0 : _c.call(_b); return toPositiveFinite(value) || 0; }); const powerDurations = outputSource === 'power' ? readZones(POWER_ZONE_TYPES) : []; const durations = powerDurations.reduce((sum, value) => sum + value, 0) > 0 ? powerDurations : readZones(HEART_RATE_ZONE_TYPES); const total = durations.reduce((sum, value) => sum + value, 0); return total > 0 ? (durations[3] + durations[4] + durations[5] + durations[6]) / total : null; } function resolveCoefficientOfVariation(values) { const mean = average(values); if (mean === null || mean <= 0 || !values.length) { return null; } const variance = values.reduce((sum, value) => sum + Math.pow((value - mean), 2), 0) / values.length; return Math.sqrt(variance) / mean; } function resolveAccumulatorCoefficientOfVariation(accumulator) { if (accumulator.count <= 0) { return null; } const mean = accumulator.outputSum / accumulator.count; if (!Number.isFinite(mean) || mean <= 0) { return null; } const variance = Math.max(0, accumulator.outputSquareSum / accumulator.count - Math.pow(mean, 2)); return Math.sqrt(variance) / mean; } function createAerobicAccumulator() { return { count: 0, outputSum: 0, outputSquareSum: 0, heartRateSum: 0 }; } function addAerobicAccumulatorSample(accumulator, output, heartRateBpm) { accumulator.count += 1; accumulator.outputSum += output; accumulator.outputSquareSum += Math.pow(output, 2); accumulator.heartRateSum += heartRateBpm; } function averageAccumulatorOutput(accumulator) { return accumulator.count > 0 ? accumulator.outputSum / accumulator.count : null; } function averageAccumulatorHeartRate(accumulator) { return accumulator.count > 0 ? accumulator.heartRateSum / accumulator.count : null; } function resolveComparisonWindow(streamLength, durationSeconds, protocol) { const boundedLength = durationSeconds > 0 ? Math.min(streamLength, Math.ceil(durationSeconds)) : streamLength; const start = Math.min(boundedLength, protocol.warmupSeconds); return [start, Math.max(start, boundedLength - protocol.cooldownSeconds)]; } function hasComparisonWindowCoverage(values, durationSeconds, protocol) { const [start, end] = resolveComparisonWindow(values.length, durationSeconds, protocol); const span = end - start; if (span <= 0) { return false; } let validCount = 0; for (let index = start; index < end; index += 1) { if (isPositiveFinite(values[index])) { validCount += 1; } } return validCount >= protocol.minimumQualifyingDurationSeconds && validCount / span >= protocol.minimumCoverageRatio; } function resolveMissingPoolReason(lengths) { const hasComparableContext = lengths.some(length => !!(0, data_durability_evidence_1.normalizeComparablePoolStroke)(length.stroke) && resolveLengthPoolMeters(length) !== null); return hasComparableContext ? 'missing-output' : 'unsupported-context'; } function sumLengthDurations(lengths) { return lengths.reduce((sum, length) => sum + (resolveLengthDurationSeconds(length) || 0), 0); } function resolveActivityDurationSeconds(activity) { var _a, _b, _c; const statDuration = toPositiveFinite((_c = (_b = (_a = activity.getDuration) === null || _a === void 0 ? void 0 : _a.call(activity)) === null || _b === void 0 ? void 0 : _b.getValue) === null || _c === void 0 ? void 0 : _c.call(_b)); if (statDuration !== null) { return statDuration; } const elapsed = (+activity.endDate - +activity.startDate) / 1000; return Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0; } function safeGetStream(activity, type) { var _a; try { const values = (_a = activity.getStreamData) === null || _a === void 0 ? void 0 : _a.call(activity, type); return Array.isArray(values) ? values : []; } catch (_error) { return []; } } function resolveLengthPoolMeters(length) { var _a, _b; return toPositiveFinite((_a = length.poolLength) === null || _a === void 0 ? void 0 : _a.getValue()) || toPositiveFinite((_b = length.distance) === null || _b === void 0 ? void 0 : _b.getValue()); } function resolveLengthDurationSeconds(length) { var _a, _b; return toPositiveFinite((_a = length.timerTime) === null || _a === void 0 ? void 0 : _a.getValue()) || toPositiveFinite((_b = length.elapsedTime) === null || _b === void 0 ? void 0 : _b.getValue()); } function resolveLengthPaceSecondsPer100m(length) { var _a, _b; const speed = toPositiveFinite((_a = length.avgSpeed) === null || _a === void 0 ? void 0 : _a.getValue()); if (speed !== null) { return 100 / speed; } const duration = resolveLengthDurationSeconds(length); const distance = toPositiveFinite((_b = length.distance) === null || _b === void 0 ? void 0 : _b.getValue()) || resolveLengthPoolMeters(length); return duration !== null && distance !== null ? (duration / distance) * 100 : null; } function average(values) { const valid = values.filter((value) => value !== null && Number.isFinite(value)); return valid.length ? valid.reduce((sum, value) => sum + value, 0) / valid.length : null; } function isPositiveFinite(value) { return toPositiveFinite(value) !== null; } function toPositiveFinite(value) { const numeric = toFinite(value); return numeric !== null && numeric > 0 ? numeric : null; } function toFinite(value) { if (value === null || value === undefined || typeof value === 'boolean') { return null; } if (typeof value === 'string' && !value.trim()) { return null; } const numeric = typeof value === 'number' ? value : Number(value); return Number.isFinite(numeric) ? numeric : null; } function round(value, decimals) { const factor = Math.pow(10, decimals); return Math.round(value * factor) / factor; }