@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
775 lines (677 loc) • 26.3 kB
text/typescript
import * as fs from 'fs';
import * as path from 'path';
import { ActivityParsingOptions } from '../src/activities/activity-parsing-options';
import { ActivityTypeGroups, ActivityTypesHelper } from '../src/activities/activity.types';
import { type ActivityInterface } from '../src/activities/activity.interface';
import { DataEnergy } from '../src/data/data.energy';
import { DataFTP } from '../src/data/data.ftp';
import { DataGender } from '../src/data/data.gender';
import { DataGrade } from '../src/data/data.grade';
import { DataGradeSmooth } from '../src/data/data.grade-smooth';
import { DataHeartRate } from '../src/data/data.heart-rate';
import { DataHeartRateMax } from '../src/data/data.heart-rate-max';
import { DataMaxHRSetting } from '../src/data/data.max-hr-setting';
import { DataPower } from '../src/data/data.power';
import { DataTrainingStressScore } from '../src/data/data.training-stress-score';
import {
DataTrainingStressScoreMethod,
TrainingStressScoreMethod,
type TrainingStressScoreMethodType
} from '../src/data/data.training-stress-score-method';
import { DataSpeed } from '../src/data/data.speed';
import { DataSpeedAvg } from '../src/data/data.speed-avg';
import { DataSwimPace } from '../src/data/data.swim-pace';
import { DataVerticalSpeed } from '../src/data/data.vertical-speed';
import { DataWeight } from '../src/data/data.weight';
import { type TssCalculationResult } from '../src/events/utilities/tss/tss-calculator';
import { ActivityUtilities } from '../src/events/utilities/activity.utilities';
import { SportsLib } from '../src/index';
type MethodName = TrainingStressScoreMethodType;
type AttemptMethod = Exclude<MethodName, 'IMPORTED'>;
interface AttemptResultSnapshot {
tss: number;
intensityFactor?: number;
normalizedPower?: number;
averageGradeAdjustedPace?: number;
}
interface MethodAttemptDiagnostics {
method: AttemptMethod;
attempted: boolean;
eligible: boolean;
success: boolean;
reason?: string;
inputs: Record<string, unknown>;
result?: AttemptResultSnapshot;
}
interface ActivityDiagnostics {
selectedMethod: MethodName | null;
selectedTss: number | null;
durationSeconds: number;
enableHeuristicFallbacks: boolean;
attempts: MethodAttemptDiagnostics[];
}
interface ComparisonRow {
file: string;
activityIndex: number;
activityType: string;
reportedTss: number;
reportedMethod: MethodName | 'n/a';
computedTss: number | null;
delta: number | null;
absDelta: number | null;
method: MethodName | 'n/a';
diagnostics: ActivityDiagnostics | null;
}
interface ParseErrorRow {
file: string;
error: string;
}
interface PrivateStreamSample {
duration: number;
value: number;
}
interface ActivityUtilitiesPrivate {
resolveFunctionalThresholdPower(activity: ActivityInterface): number | null;
resolveMaxHeartRate(activity: ActivityInterface): number | null;
resolveRestingHeartRate(activity: ActivityInterface): number | null;
resolveLactateThresholdHr(activity: ActivityInterface): number | null;
resolveFunctionalThresholdPace(activity: ActivityInterface): number | null;
resolveSwimSpeed(activity: ActivityInterface): number | null;
resolveThresholdSwimSpeed(activity: ActivityInterface, swimSpeed: number): number | null;
resolveMetScore(activity: ActivityInterface, durationSeconds: number): number | null;
resolveMetThreshold(activity: ActivityInterface): number;
getDurationSecondsWithoutPauses(activity: ActivityInterface): number;
getStreamSamplesByDuration(activity: ActivityInterface, streamType: string): PrivateStreamSample[];
supportsPaceTss(activity: ActivityInterface): boolean;
calculatePowerTss(activity: ActivityInterface): TssCalculationResult | null;
calculateHrTss(activity: ActivityInterface): TssCalculationResult | null;
calculatePaceTss(activity: ActivityInterface): TssCalculationResult | null;
calculateSwimPaceTss(activity: ActivityInterface): TssCalculationResult | null;
calculateMetTss(activity: ActivityInterface): TssCalculationResult | null;
}
const ROOT_DIR = path.resolve(__dirname, '..');
const SEARCH_DIRS = ['samples', 'src/specs/fixtures'];
const REPORTS_DIR = path.join(ROOT_DIR, 'reports');
const TABLE_JSON_PATH = path.join(REPORTS_DIR, 'tss-validation-table.json');
const TABLE_MD_PATH = path.join(REPORTS_DIR, 'tss-validation-table.md');
const DIAGNOSTICS_JSON_PATH = path.join(REPORTS_DIR, 'tss-validation-diagnostics.json');
const utilities = ActivityUtilities as unknown as ActivityUtilitiesPrivate;
const round = (value: number, decimals = 1): number => {
const factor = Math.pow(10, decimals);
return Math.round(value * factor) / factor;
};
const finiteOrNull = (value: unknown): number | null => {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
};
const positiveOrNull = (value: unknown): number | null => {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
};
const toRelative = (absolutePath: string): string => path.relative(ROOT_DIR, absolutePath);
const walkFitFiles = (directory: string): string[] => {
if (!fs.existsSync(directory)) {
return [];
}
const entries = fs.readdirSync(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...walkFitFiles(absolute));
continue;
}
if (entry.isFile() && entry.name.toLowerCase().endsWith('.fit')) {
files.push(absolute);
}
}
return files;
};
const getFitFiles = (): string[] => {
const dedup = new Set<string>();
for (const relativeDir of SEARCH_DIRS) {
const absoluteDir = path.join(ROOT_DIR, relativeDir);
walkFitFiles(absoluteDir).forEach(file => dedup.add(file));
}
return Array.from(dedup.values()).sort((a, b) => a.localeCompare(b));
};
const getFiniteStatValue = (activity: ActivityInterface, type: string): number | null => {
const stat = activity.getStat(type);
if (!stat) {
return null;
}
return finiteOrNull(stat.getValue());
};
const getPositiveOverride = (activity: ActivityInterface, key: string): number | null => {
const overrides = activity.parseOptions?.tss?.overrides as Record<string, unknown> | undefined;
return positiveOrNull(overrides?.[key]);
};
const getZoneFiveLowerLimit = (activity: ActivityInterface, zoneType: string): number | null => {
const zone = activity.intensityZones.find(item => item.type === zoneType);
return positiveOrNull(zone?.zone5LowerLimit);
};
const snapshotResult = (result: TssCalculationResult | null): AttemptResultSnapshot | undefined => {
if (!result) {
return undefined;
}
return {
tss: round(result.trainingStressScore, 3),
intensityFactor: finiteOrNull(result.intensityFactor ?? null) ?? undefined,
normalizedPower: finiteOrNull(result.normalizedPower ?? null) ?? undefined,
averageGradeAdjustedPace: finiteOrNull(result.averageGradeAdjustedPace ?? null) ?? undefined
};
};
const buildPowerDiagnostics = (activity: ActivityInterface): MethodAttemptDiagnostics => {
const ftpOverride = getPositiveOverride(activity, 'functionalThresholdPower');
const ftpStatBefore = positiveOrNull(getFiniteStatValue(activity, DataFTP.type));
const ftpResolved = positiveOrNull(utilities.resolveFunctionalThresholdPower(activity));
const powerSamples = utilities.getStreamSamplesByDuration(activity, DataPower.type) as Array<{
duration: number;
value: number;
}>;
const result = utilities.calculatePowerTss(activity) as TssCalculationResult | null;
let ftpSource = 'missing';
if (ftpOverride !== null) {
ftpSource = 'override';
} else if (ftpStatBefore !== null) {
ftpSource = 'DataFTP';
} else if (ftpResolved !== null) {
ftpSource = 'derived';
}
let reason: string | undefined;
if (ftpResolved === null) {
reason = 'missing_functionalThresholdPower';
} else if (!powerSamples.length) {
reason = 'missing_power_samples';
} else if (!result) {
reason = 'power_calculation_failed_or_out_of_range';
}
return {
method: TrainingStressScoreMethod.POWER,
attempted: true,
eligible: true,
success: !!result,
reason,
inputs: {
ftpOverride,
ftpStatBefore,
ftpResolved,
ftpSource,
powerSampleCount: powerSamples.length
},
result: snapshotResult(result)
};
};
const buildHrDiagnostics = (activity: ActivityInterface): MethodAttemptDiagnostics => {
const maxHrOverride = getPositiveOverride(activity, 'maxHeartRate');
const maxHrSetting = positiveOrNull(getFiniteStatValue(activity, DataMaxHRSetting.type));
const maxHrStat = positiveOrNull(getFiniteStatValue(activity, DataHeartRateMax.type));
const maxHeartRate = positiveOrNull(utilities.resolveMaxHeartRate(activity));
const restingHrOverride = getPositiveOverride(activity, 'restingHeartRate');
const restingHeartRate = positiveOrNull(utilities.resolveRestingHeartRate(activity));
const lthrOverride = getPositiveOverride(activity, 'lactateThresholdHR');
const lthrZone5 = getZoneFiveLowerLimit(activity, DataHeartRate.type);
const lactateThresholdHR = positiveOrNull(utilities.resolveLactateThresholdHr(activity));
const hrSamples = utilities.getStreamSamplesByDuration(activity, DataHeartRate.type) as Array<{
duration: number;
value: number;
}>;
const hrAverage =
hrSamples.length > 0 ? round(hrSamples.reduce((sum, sample) => sum + sample.value, 0) / hrSamples.length, 3) : null;
const gender = activity.getStat(DataGender.type)?.getValue();
const result = utilities.calculateHrTss(activity) as TssCalculationResult | null;
let maxHrSource = 'missing';
if (maxHrOverride !== null) {
maxHrSource = 'override';
} else if (maxHrSetting !== null) {
maxHrSource = 'DataMaxHRSetting';
} else if (maxHrStat !== null) {
maxHrSource = 'DataHeartRateMax';
}
let restingHrSource = 'missing';
if (restingHrOverride !== null) {
restingHrSource = 'override';
}
let lthrSource = 'missing';
if (lthrOverride !== null) {
lthrSource = 'override';
} else if (lthrZone5 !== null) {
lthrSource = 'HR_zone5_lower';
}
const banisterPossible = maxHeartRate !== null && restingHeartRate !== null && maxHeartRate > restingHeartRate;
const hrAlgorithm = banisterPossible ? 'BANISTER' : 'EDWARDS';
let reason: string | undefined;
if (maxHeartRate === null) {
reason = 'missing_maxHeartRate';
} else if (!hrSamples.length) {
reason = 'missing_hr_samples';
} else if (!result) {
reason = 'hr_calculation_failed_or_out_of_range';
}
return {
method: TrainingStressScoreMethod.HR,
attempted: true,
eligible: true,
success: !!result,
reason,
inputs: {
maxHrOverride,
maxHrSetting,
maxHrStat,
maxHeartRate,
maxHrSource,
restingHrOverride,
restingHeartRate,
restingHrSource,
lthrOverride,
lthrZone5,
lactateThresholdHR,
lthrSource,
hrSampleCount: hrSamples.length,
hrAverage,
gender,
hrAlgorithm
},
result: snapshotResult(result)
};
};
const buildPaceDiagnostics = (activity: ActivityInterface): MethodAttemptDiagnostics => {
const paceEligible = !!utilities.supportsPaceTss(activity);
const enableHeuristicFallbacks = activity.parseOptions?.tss?.enableHeuristicFallbacks ?? true;
const thresholdOverride = getPositiveOverride(activity, 'functionalThresholdPace');
const speedZone5 = getZoneFiveLowerLimit(activity, DataSpeed.type);
const thresholdPace = positiveOrNull(utilities.resolveFunctionalThresholdPace(activity));
const speedSamples = utilities.getStreamSamplesByDuration(activity, DataSpeed.type) as Array<{
duration: number;
value: number;
}>;
const gradeSmoothByDuration = new Map<number, number>(
(
utilities.getStreamSamplesByDuration(activity, DataGradeSmooth.type) as Array<{ duration: number; value: number }>
).map(sample => [sample.duration, sample.value])
);
const gradeByDuration = new Map<number, number>(
(utilities.getStreamSamplesByDuration(activity, DataGrade.type) as Array<{ duration: number; value: number }>).map(
sample => [sample.duration, sample.value]
)
);
const verticalByDuration = new Map<number, number>(
(
utilities.getStreamSamplesByDuration(activity, DataVerticalSpeed.type) as Array<{
duration: number;
value: number;
}>
).map(sample => [sample.duration, sample.value])
);
let fromGradeSmooth = 0;
let fromGrade = 0;
let fromVertical = 0;
let fromHeuristicZero = 0;
let unresolved = 0;
speedSamples.forEach(sample => {
if (gradeSmoothByDuration.has(sample.duration)) {
fromGradeSmooth += 1;
return;
}
if (gradeByDuration.has(sample.duration)) {
fromGrade += 1;
return;
}
const vertical = verticalByDuration.get(sample.duration);
if (vertical !== undefined && sample.value > 0) {
fromVertical += 1;
return;
}
if (enableHeuristicFallbacks) {
fromHeuristicZero += 1;
return;
}
unresolved += 1;
});
let thresholdSource = 'missing';
if (thresholdOverride !== null) {
thresholdSource = 'override';
} else if (speedZone5 !== null) {
thresholdSource = 'speed_zone5_lower';
}
let attempted = false;
let eligible = paceEligible;
let result: TssCalculationResult | null = null;
let reason: string | undefined;
if (!paceEligible) {
attempted = false;
reason = 'sport_not_pace_enabled';
} else {
attempted = true;
result = utilities.calculatePaceTss(activity) as TssCalculationResult | null;
if (thresholdPace === null) {
reason = 'missing_functionalThresholdPace';
} else if (!speedSamples.length) {
reason = 'missing_speed_samples';
} else if (!enableHeuristicFallbacks && unresolved > 0) {
reason = 'missing_grade_data_with_heuristics_disabled';
} else if (!result) {
reason = 'pace_calculation_failed_or_out_of_range';
}
}
return {
method: TrainingStressScoreMethod.PACE,
attempted,
eligible,
success: !!result,
reason,
inputs: {
thresholdOverride,
speedZone5,
thresholdPace,
thresholdSource,
speedSampleCount: speedSamples.length,
enableHeuristicFallbacks,
gradeSourceCounts: {
gradeSmooth: fromGradeSmooth,
grade: fromGrade,
verticalSpeedOverSpeed: fromVertical,
heuristicZero: fromHeuristicZero,
unresolved
}
},
result: snapshotResult(result)
};
};
const buildSwimDiagnostics = (activity: ActivityInterface): MethodAttemptDiagnostics => {
const activityGroup = ActivityTypesHelper.getActivityGroupForActivityType(activity.type);
const isSwimGroup = activityGroup === ActivityTypeGroups.SwimmingGroup;
const enableHeuristicFallbacks = activity.parseOptions?.tss?.enableHeuristicFallbacks ?? true;
const swimSpeedAvgStat = positiveOrNull(getFiniteStatValue(activity, DataSpeedAvg.type));
const speedSamples = utilities.getStreamSamplesByDuration(activity, DataSpeed.type) as Array<{
duration: number;
value: number;
}>;
const swimSpeedResolved = positiveOrNull(utilities.resolveSwimSpeed(activity));
const thresholdOverride = getPositiveOverride(activity, 'thresholdSwimSpeed');
const thresholdLegacy = getPositiveOverride(activity, 'refSwimSpeed');
const thresholdSwimZone5 = getZoneFiveLowerLimit(activity, DataSwimPace.type);
const thresholdSpeedZone5 = getZoneFiveLowerLimit(activity, DataSpeed.type);
const thresholdResolved =
swimSpeedResolved === null
? null
: positiveOrNull(utilities.resolveThresholdSwimSpeed(activity, swimSpeedResolved));
let thresholdSource = 'missing';
if (thresholdOverride !== null) {
thresholdSource = 'override_thresholdSwimSpeed';
} else if (thresholdLegacy !== null) {
thresholdSource = 'legacy_refSwimSpeed';
} else if (thresholdSwimZone5 !== null) {
thresholdSource = 'swim_zone5_lower';
} else if (thresholdSpeedZone5 !== null) {
thresholdSource = 'speed_zone5_lower';
} else if (thresholdResolved !== null && swimSpeedResolved !== null && enableHeuristicFallbacks) {
thresholdSource = 'session_swim_speed_heuristic';
}
let swimSpeedSource = 'missing';
if (swimSpeedAvgStat !== null) {
swimSpeedSource = 'DataSpeedAvg';
} else if (swimSpeedResolved !== null) {
swimSpeedSource = 'speed_stream_average';
}
let attempted = false;
let eligible = isSwimGroup;
let result: TssCalculationResult | null = null;
let reason: string | undefined;
if (!isSwimGroup) {
attempted = false;
reason = 'not_swimming_group';
} else {
attempted = true;
result = utilities.calculateSwimPaceTss(activity) as TssCalculationResult | null;
if (swimSpeedResolved === null) {
reason = 'missing_swim_speed';
} else if (thresholdResolved === null) {
reason = 'missing_threshold_swim_speed';
} else if (!result) {
reason = 'swim_tss_calculation_failed_or_out_of_range';
}
}
return {
method: TrainingStressScoreMethod.SWIM_PACE,
attempted,
eligible,
success: !!result,
reason,
inputs: {
isSwimGroup,
swimSpeedAvgStat,
speedSampleCount: speedSamples.length,
swimSpeedResolved,
swimSpeedSource,
thresholdOverride,
thresholdLegacy,
thresholdSwimZone5,
thresholdSpeedZone5,
thresholdResolved,
thresholdSource,
enableHeuristicFallbacks
},
result: snapshotResult(result)
};
};
const buildMetDiagnostics = (activity: ActivityInterface): MethodAttemptDiagnostics => {
const durationSeconds = positiveOrNull(utilities.getDurationSecondsWithoutPauses(activity)) ?? 0;
const metOverride = getPositiveOverride(activity, 'metScore');
const energy = positiveOrNull(getFiniteStatValue(activity, DataEnergy.type));
const weight = positiveOrNull(getFiniteStatValue(activity, DataWeight.type));
const metResolved = durationSeconds > 0 ? positiveOrNull(utilities.resolveMetScore(activity, durationSeconds)) : null;
const thresholdOverride = getPositiveOverride(activity, 'thresholdMet');
const thresholdMet = positiveOrNull(utilities.resolveMetThreshold(activity));
let metSource = 'missing';
if (metOverride !== null) {
metSource = 'override_metScore';
} else if (metResolved !== null && energy !== null && weight !== null) {
metSource = 'estimated_from_energy_weight_duration';
}
const thresholdSource = thresholdOverride !== null ? 'override_thresholdMet' : 'default_thresholdMet_10';
const result = utilities.calculateMetTss(activity) as TssCalculationResult | null;
let reason: string | undefined;
if (durationSeconds <= 0) {
reason = 'missing_duration';
} else if (metResolved === null) {
reason = 'missing_met_score';
} else if (!result) {
reason = 'met_tss_calculation_failed_or_out_of_range';
}
return {
method: TrainingStressScoreMethod.MET,
attempted: true,
eligible: true,
success: !!result,
reason,
inputs: {
durationSeconds,
metOverride,
energy,
weight,
metResolved,
metSource,
thresholdOverride,
thresholdMet,
thresholdSource
},
result: snapshotResult(result)
};
};
const buildActivityDiagnostics = (activity: ActivityInterface): ActivityDiagnostics => {
const durationSeconds = positiveOrNull(utilities.getDurationSecondsWithoutPauses(activity)) ?? 0;
const enableHeuristicFallbacks = activity.parseOptions?.tss?.enableHeuristicFallbacks ?? true;
const power = buildPowerDiagnostics(activity);
const hr = buildHrDiagnostics(activity);
const swim = buildSwimDiagnostics(activity);
const pace = buildPaceDiagnostics(activity);
const met = buildMetDiagnostics(activity);
const activityGroup = ActivityTypesHelper.getActivityGroupForActivityType(activity.type);
const attempts: MethodAttemptDiagnostics[] = [power, hr];
if (activityGroup === ActivityTypeGroups.SwimmingGroup) {
attempts.push(swim);
} else {
attempts.push(pace);
}
attempts.push(met);
return {
selectedMethod:
(activity.getStat(DataTrainingStressScoreMethod.type)?.getValue() as MethodName | undefined) ?? null,
selectedTss: finiteOrNull(activity.getStat(DataTrainingStressScore.type)?.getValue()),
durationSeconds,
enableHeuristicFallbacks,
attempts
};
};
const renderMarkdown = (rows: ComparisonRow[]): string => {
const header = '| File | Idx | Type | Reported TSS | Reported Method | Computed TSS | Delta | Abs Delta | Method |\n';
const separator = '|---|---:|---|---:|---|---:|---:|---:|---|\n';
const body = rows
.map(row => {
const computed = row.computedTss === null ? 'n/a' : `${row.computedTss}`;
const delta = row.delta === null ? 'n/a' : `${row.delta}`;
const absDelta = row.absDelta === null ? 'n/a' : `${row.absDelta}`;
return `| ${row.file} | ${row.activityIndex} | ${row.activityType} | ${row.reportedTss} | ${row.reportedMethod} | ${computed} | ${delta} | ${absDelta} | ${row.method} |`;
})
.join('\n');
return `${header}${separator}${body}\n`;
};
const parseFitFile = async (
absolutePath: string,
options?: ActivityParsingOptions
): Promise<ReturnType<typeof SportsLib.importFromFit>> => {
const buffer = fs.readFileSync(absolutePath);
return SportsLib.importFromFit(buffer, options);
};
const computeRows = async (): Promise<{
rows: ComparisonRow[];
parseErrors: ParseErrorRow[];
scannedFitFiles: number;
}> => {
const files = getFitFiles();
const rows: ComparisonRow[] = [];
const parseErrors: ParseErrorRow[] = [];
for (const filePath of files) {
const relativeFile = toRelative(filePath);
try {
const defaultEvent = await parseFitFile(filePath);
const recomputeEvent = await parseFitFile(
filePath,
new ActivityParsingOptions({
tss: {
preserveImportedTss: false
}
})
);
const defaultActivities = defaultEvent.getActivities();
const recomputeActivities = recomputeEvent.getActivities();
defaultActivities.forEach((defaultActivity, activityIndex) => {
const reportedMethod =
(defaultActivity.getStat(DataTrainingStressScoreMethod.type)?.getValue() as MethodName | undefined) ?? 'n/a';
const reportedTss = finiteOrNull(defaultActivity.getStat(DataTrainingStressScore.type)?.getValue());
if (reportedTss === null) {
return;
}
const recomputeActivity = recomputeActivities[activityIndex];
if (!recomputeActivity) {
rows.push({
file: relativeFile,
activityIndex,
activityType: defaultActivity.type,
reportedTss: round(reportedTss, 1),
reportedMethod,
computedTss: null,
delta: null,
absDelta: null,
method: 'n/a',
diagnostics: null
});
return;
}
const computedTss = finiteOrNull(recomputeActivity.getStat(DataTrainingStressScore.type)?.getValue());
const computedMethod =
(recomputeActivity.getStat(DataTrainingStressScoreMethod.type)?.getValue() as MethodName | undefined) ??
'n/a';
const delta = computedTss === null ? null : round(computedTss - reportedTss, 1);
const absDelta = delta === null ? null : round(Math.abs(delta), 1);
rows.push({
file: relativeFile,
activityIndex,
activityType: defaultActivity.type,
reportedTss: round(reportedTss, 1),
reportedMethod,
computedTss: computedTss === null ? null : round(computedTss, 1),
delta,
absDelta,
method: computedMethod,
diagnostics: buildActivityDiagnostics(recomputeActivity)
});
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
parseErrors.push({
file: relativeFile,
error: message
});
}
}
rows.sort((left, right) => {
const leftAbs = left.absDelta ?? -1;
const rightAbs = right.absDelta ?? -1;
if (rightAbs !== leftAbs) {
return rightAbs - leftAbs;
}
if (left.file !== right.file) {
return left.file.localeCompare(right.file);
}
return left.activityIndex - right.activityIndex;
});
return {
rows,
parseErrors,
scannedFitFiles: files.length
};
};
const writeReports = async (): Promise<void> => {
const { rows, parseErrors, scannedFitFiles } = await computeRows();
const computedRows = rows.filter(row => row.computedTss !== null && row.absDelta !== null);
const summary = {
scannedFitFiles,
parseErrors: parseErrors.length,
entriesWithReportedTss: rows.length,
computed: computedRows.length,
missingComputed: rows.length - computedRows.length,
within1_0: computedRows.filter(row => (row.absDelta ?? 0) <= 1).length,
above1_0: computedRows.filter(row => (row.absDelta ?? 0) > 1).length
};
const tablePayload = {
summary,
parseErrors,
rows: rows.map(row => ({
file: row.file,
activityIndex: row.activityIndex,
activityType: row.activityType,
reportedTss: row.reportedTss,
reportedMethod: row.reportedMethod,
computedTss: row.computedTss,
delta: row.delta,
absDelta: row.absDelta,
method: row.method
}))
};
const diagnosticsPayload = {
summary,
parseErrors,
rows
};
fs.mkdirSync(REPORTS_DIR, { recursive: true });
fs.writeFileSync(TABLE_JSON_PATH, JSON.stringify(tablePayload, null, 2));
fs.writeFileSync(TABLE_MD_PATH, renderMarkdown(tablePayload.rows));
fs.writeFileSync(DIAGNOSTICS_JSON_PATH, JSON.stringify(diagnosticsPayload, null, 2));
console.log(`Wrote ${toRelative(TABLE_JSON_PATH)}`);
console.log(`Wrote ${toRelative(TABLE_MD_PATH)}`);
console.log(`Wrote ${toRelative(DIAGNOSTICS_JSON_PATH)}`);
console.log(
`Summary: scanned=${summary.scannedFitFiles}, reported=${summary.entriesWithReportedTss}, computed=${summary.computed}, >1.0=${summary.above1_0}`
);
};
void writeReports();