@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
372 lines (371 loc) • 15.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.DataDurabilityEvidence = exports.DURABILITY_PROTOCOL_ELIGIBILITY_THRESHOLDS = exports.DURABILITY_PROTOCOL_VERSION = void 0;
exports.normalizeDurabilityEvidenceValue = normalizeDurabilityEvidenceValue;
exports.normalizeComparablePoolStroke = normalizeComparablePoolStroke;
const data_bare_1 = require("./data.bare");
exports.DURABILITY_PROTOCOL_VERSION = 1;
exports.DURABILITY_PROTOCOL_ELIGIBILITY_THRESHOLDS = Object.freeze({
minimumActivityDurationSeconds: 40 * 60,
minimumQualifyingDurationSeconds: 30 * 60,
minimumCoverageRatio: 0.6,
maximumOutputCoefficientOfVariation: 0.25,
maximumHardZoneRatio: 0.2,
minimumPoolComparableLengths: 24,
minimumPoolThirdLengths: 8
});
/** Compact activity-level durability summary. Timelines and source streams are intentionally not persisted. */
class DataDurabilityEvidence extends data_bare_1.DataBare {
constructor(value) {
super(assertDurabilityEvidenceValue(value));
}
setValue(value) {
this.value = assertDurabilityEvidenceValue(value);
return this;
}
getDurabilityValue() {
return this.value;
}
isValueTypeValid(value) {
return normalizeDurabilityEvidenceValue(value) !== null;
}
toJSON() {
return {
[DataDurabilityEvidence.type]: this.getDurabilityValue()
};
}
}
exports.DataDurabilityEvidence = DataDurabilityEvidence;
DataDurabilityEvidence.type = 'Durability Evidence';
const DURABILITY_DISCIPLINES = new Set([
'cycling',
'running',
'open-water-swimming',
'pool-swimming'
]);
const DURABILITY_OUTPUT_SOURCES = new Set([
'power',
'grade-adjusted-speed',
'speed',
'pool-length-speed'
]);
const DURABILITY_ELIGIBILITY_REASONS = new Set([
'eligible',
'unsupported-activity',
'missing-output',
'missing-heart-rate',
'insufficient-duration',
'insufficient-coverage',
'insufficient-halves',
'too-variable',
'too-intense',
'unsupported-context'
]);
/** Returns a compact canonical value and strips every unknown field, including timelines. */
function normalizeDurabilityEvidenceValue(value) {
const raw = asRecord(value);
if (!raw || raw.protocolVersion !== exports.DURABILITY_PROTOCOL_VERSION) {
return null;
}
const discipline = DURABILITY_DISCIPLINES.has(raw.discipline)
? raw.discipline
: null;
const outputSource = DURABILITY_OUTPUT_SOURCES.has(raw.outputSource)
? raw.outputSource
: null;
const outputUnit = raw.outputUnit === 'W' || raw.outputUnit === 'm/s' ? raw.outputUnit : null;
if (!discipline || !outputSource || !outputUnit || !isCompatibleOutput(discipline, outputSource, outputUnit)) {
return null;
}
const durationSeconds = finiteNonNegative(raw.durationSeconds);
const qualifyingDurationSeconds = finiteNonNegative(raw.qualifyingDurationSeconds);
const coverageRatio = finiteRatio(raw.coverageRatio);
const sourceFingerprint = normalizeSourceFingerprint(raw.sourceFingerprint);
const eligibility = normalizeEligibility(raw.eligibility, discipline === 'pool-swimming');
const context = normalizeContext(raw.context, discipline);
if (durationSeconds === null ||
qualifyingDurationSeconds === null ||
coverageRatio === null ||
!sourceFingerprint ||
!eligibility ||
(discipline === 'pool-swimming' && raw.context !== null && !context) ||
(discipline === 'pool-swimming' && eligibility.eligible && !context) ||
(discipline !== 'pool-swimming' && raw.context !== null)) {
return null;
}
const evidence = eligibility.eligible ? normalizeEvidence(raw.evidence, discipline, context) : null;
if ((eligibility.eligible && !evidence) || (!eligibility.eligible && raw.evidence !== null)) {
return null;
}
const normalized = {
protocolVersion: exports.DURABILITY_PROTOCOL_VERSION,
sourceFingerprint,
discipline,
outputSource,
outputUnit,
context,
durationSeconds,
qualifyingDurationSeconds,
coverageRatio,
eligibility,
evidence
};
return isSemanticallyValidDurabilityEvidence(normalized) ? normalized : null;
}
function normalizeComparablePoolStroke(value) {
const stroke = typeof value === 'string' ? value.trim().toLowerCase() : '';
if (!stroke || ['drill', 'unknown', 'mixed', 'im', 'medley', 'individual medley'].includes(stroke)) {
return null;
}
return stroke;
}
function assertDurabilityEvidenceValue(value) {
const normalized = normalizeDurabilityEvidenceValue(value);
if (!normalized) {
throw new Error('Invalid durability evidence value');
}
return normalized;
}
function normalizeEligibility(value, isPool) {
const raw = asRecord(value);
if (!raw ||
typeof raw.eligible !== 'boolean' ||
!DURABILITY_ELIGIBILITY_REASONS.has(raw.reason)) {
return null;
}
const reason = raw.reason;
if (raw.eligible !== (reason === 'eligible')) {
return null;
}
const validSampleCount = finiteCount(raw.validSampleCount);
const earlySampleCount = finiteCount(raw.earlySampleCount);
const lateSampleCount = finiteCount(raw.lateSampleCount);
const outputCoefficientOfVariation = nullableFiniteNonNegative(raw.outputCoefficientOfVariation);
const hardZoneRatio = nullableFiniteRatio(raw.hardZoneRatio);
if (validSampleCount === null ||
earlySampleCount === null ||
lateSampleCount === null ||
outputCoefficientOfVariation === undefined ||
hardZoneRatio === undefined) {
return null;
}
const comparisonSegments = isPool ? 'outer-thirds' : 'halves';
if (raw.comparisonSegments !== comparisonSegments) {
return null;
}
return {
eligible: raw.eligible,
reason,
validSampleCount,
comparisonSegments,
earlySampleCount,
lateSampleCount,
outputCoefficientOfVariation,
hardZoneRatio
};
}
function normalizeContext(value, discipline) {
if (discipline !== 'pool-swimming') {
return null;
}
const raw = asRecord(value);
const poolLengthMeters = finitePositive(raw === null || raw === void 0 ? void 0 : raw.poolLengthMeters);
const stroke = normalizeComparablePoolStroke(raw === null || raw === void 0 ? void 0 : raw.stroke);
return poolLengthMeters === null || !stroke ? null : { poolLengthMeters, stroke };
}
function normalizeEvidence(value, discipline, context) {
const raw = asRecord(value);
if (!raw) {
return null;
}
if (discipline === 'pool-swimming') {
const poolLengthMeters = finitePositive(raw.poolLengthMeters);
const stroke = normalizeComparablePoolStroke(raw.stroke);
const comparableLengthCount = finiteCount(raw.comparableLengthCount);
const firstPaceSecondsPer100m = finitePositive(raw.firstPaceSecondsPer100m);
const finalPaceSecondsPer100m = finitePositive(raw.finalPaceSecondsPer100m);
const paceRetentionPercent = finitePositive(raw.paceRetentionPercent);
const firstSwolf = nullableFinitePositive(raw.firstSwolf);
const finalSwolf = nullableFinitePositive(raw.finalSwolf);
const swolfChange = nullableFinite(raw.swolfChange);
if (raw.kind !== 'pool-consistency' ||
poolLengthMeters === null ||
!stroke ||
comparableLengthCount === null ||
firstPaceSecondsPer100m === null ||
finalPaceSecondsPer100m === null ||
paceRetentionPercent === null ||
firstSwolf === undefined ||
finalSwolf === undefined ||
swolfChange === undefined ||
!context ||
context.poolLengthMeters !== poolLengthMeters ||
context.stroke !== stroke) {
return null;
}
return {
kind: 'pool-consistency',
poolLengthMeters,
stroke,
comparableLengthCount,
firstPaceSecondsPer100m,
finalPaceSecondsPer100m,
paceRetentionPercent,
firstSwolf,
finalSwolf,
swolfChange
};
}
const firstHalfEfficiency = finitePositive(raw.firstHalfEfficiency);
const secondHalfEfficiency = finitePositive(raw.secondHalfEfficiency);
const decouplingPercent = finite(raw.decouplingPercent);
const firstHalfOutput = finitePositive(raw.firstHalfOutput);
const secondHalfOutput = finitePositive(raw.secondHalfOutput);
const outputRetentionPercent = finitePositive(raw.outputRetentionPercent);
const firstHalfHeartRateBpm = finitePositive(raw.firstHalfHeartRateBpm);
const secondHalfHeartRateBpm = finitePositive(raw.secondHalfHeartRateBpm);
const heartRateDriftBpm = finite(raw.heartRateDriftBpm);
if (raw.kind !== 'aerobic-efficiency' ||
firstHalfEfficiency === null ||
secondHalfEfficiency === null ||
decouplingPercent === null ||
firstHalfOutput === null ||
secondHalfOutput === null ||
outputRetentionPercent === null ||
firstHalfHeartRateBpm === null ||
secondHalfHeartRateBpm === null ||
heartRateDriftBpm === null) {
return null;
}
return {
kind: 'aerobic-efficiency',
firstHalfEfficiency,
secondHalfEfficiency,
decouplingPercent,
firstHalfOutput,
secondHalfOutput,
outputRetentionPercent,
firstHalfHeartRateBpm,
secondHalfHeartRateBpm,
heartRateDriftBpm
};
}
function isSemanticallyValidDurabilityEvidence(value) {
const { eligibility } = value;
if (value.qualifyingDurationSeconds > value.durationSeconds) {
return false;
}
if (value.discipline === 'pool-swimming') {
if (eligibility.earlySampleCount !== eligibility.lateSampleCount ||
eligibility.earlySampleCount !== Math.floor(eligibility.validSampleCount / 3)) {
return false;
}
}
else if (!Number.isInteger(value.qualifyingDurationSeconds) ||
eligibility.validSampleCount !== value.qualifyingDurationSeconds ||
eligibility.earlySampleCount + eligibility.lateSampleCount !== eligibility.validSampleCount) {
return false;
}
if (!eligibility.eligible) {
return true;
}
const thresholds = exports.DURABILITY_PROTOCOL_ELIGIBILITY_THRESHOLDS;
if (value.durationSeconds < thresholds.minimumActivityDurationSeconds ||
value.qualifyingDurationSeconds < thresholds.minimumQualifyingDurationSeconds ||
value.coverageRatio < thresholds.minimumCoverageRatio ||
eligibility.outputCoefficientOfVariation === null ||
eligibility.outputCoefficientOfVariation > thresholds.maximumOutputCoefficientOfVariation ||
(eligibility.hardZoneRatio !== null && eligibility.hardZoneRatio > thresholds.maximumHardZoneRatio) ||
eligibility.earlySampleCount <= 0 ||
eligibility.lateSampleCount <= 0 ||
!value.evidence) {
return false;
}
if (value.discipline === 'pool-swimming') {
return isSemanticallyValidPoolEvidence(value.evidence, eligibility);
}
return isSemanticallyValidAerobicEvidence(value.evidence);
}
function isSemanticallyValidAerobicEvidence(evidence) {
if (evidence.kind !== 'aerobic-efficiency') {
return false;
}
return (approximatelyEqual(evidence.firstHalfEfficiency, evidence.firstHalfOutput / evidence.firstHalfHeartRateBpm, 0.00001) &&
approximatelyEqual(evidence.secondHalfEfficiency, evidence.secondHalfOutput / evidence.secondHalfHeartRateBpm, 0.00001) &&
approximatelyEqual(evidence.decouplingPercent, ((evidence.firstHalfEfficiency - evidence.secondHalfEfficiency) / evidence.firstHalfEfficiency) * 100, 0.01) &&
approximatelyEqual(evidence.outputRetentionPercent, (evidence.secondHalfOutput / evidence.firstHalfOutput) * 100, 0.01) &&
approximatelyEqual(evidence.heartRateDriftBpm, evidence.secondHalfHeartRateBpm - evidence.firstHalfHeartRateBpm, 0.01));
}
function isSemanticallyValidPoolEvidence(evidence, eligibility) {
if (evidence.kind !== 'pool-consistency') {
return false;
}
const thresholds = exports.DURABILITY_PROTOCOL_ELIGIBILITY_THRESHOLDS;
if (eligibility.validSampleCount < thresholds.minimumPoolComparableLengths ||
eligibility.earlySampleCount < thresholds.minimumPoolThirdLengths ||
evidence.comparableLengthCount !== eligibility.validSampleCount ||
!approximatelyEqual(evidence.paceRetentionPercent, (evidence.firstPaceSecondsPer100m / evidence.finalPaceSecondsPer100m) * 100, 0.01)) {
return false;
}
if (evidence.firstSwolf === null || evidence.finalSwolf === null) {
return evidence.swolfChange === null;
}
return (evidence.swolfChange !== null &&
approximatelyEqual(evidence.swolfChange, evidence.finalSwolf - evidence.firstSwolf, 0.01));
}
function normalizeSourceFingerprint(value) {
return typeof value === 'string' && /^durability-v1:[0-9a-f]{16}$/.test(value) ? value : null;
}
function approximatelyEqual(left, right, tolerance) {
return Number.isFinite(right) && Math.abs(left - right) <= tolerance;
}
function isCompatibleOutput(discipline, outputSource, outputUnit) {
if (discipline === 'cycling') {
return outputSource === 'power' && outputUnit === 'W';
}
if (discipline === 'running') {
return (outputSource === 'grade-adjusted-speed' || outputSource === 'speed') && outputUnit === 'm/s';
}
if (discipline === 'open-water-swimming') {
return outputSource === 'speed' && outputUnit === 'm/s';
}
return outputSource === 'pool-length-speed' && outputUnit === 'm/s';
}
function asRecord(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
}
function finite(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function finitePositive(value) {
const number = finite(value);
return number !== null && number > 0 ? number : null;
}
function finiteNonNegative(value) {
const number = finite(value);
return number !== null && number >= 0 ? number : null;
}
function finiteCount(value) {
const number = finiteNonNegative(value);
return number !== null && Number.isInteger(number) ? number : null;
}
function finiteRatio(value) {
const number = finite(value);
return number !== null && number >= 0 && number <= 1 ? number : null;
}
function nullableFinite(value) {
var _a;
return value === null ? null : ((_a = finite(value)) !== null && _a !== void 0 ? _a : undefined);
}
function nullableFinitePositive(value) {
var _a;
return value === null ? null : ((_a = finitePositive(value)) !== null && _a !== void 0 ? _a : undefined);
}
function nullableFiniteNonNegative(value) {
var _a;
return value === null ? null : ((_a = finiteNonNegative(value)) !== null && _a !== void 0 ? _a : undefined);
}
function nullableFiniteRatio(value) {
var _a;
return value === null ? null : ((_a = finiteRatio(value)) !== null && _a !== void 0 ? _a : undefined);
}