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