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

775 lines (677 loc) 26.3 kB
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();