type audit script, fix misclassified activities, alpineski excluded from ascent

This commit is contained in:
Vincent van der Wal
2026-07-22 17:24:38 +02:00
parent 512860c863
commit c4512d3550
3 changed files with 157 additions and 2 deletions
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
// Audit activity types against their measured stats and fix contradictions.
//
// node scripts/audit-types.js # dry run: show proposed changes
// node scripts/audit-types.js --apply # apply + log to data/type-fixes.json
//
// Custom/curated types (skitour, vita, iceskate, …) are left alone; only
// clear physics contradictions in the generic foot/bike types plus the
// "outdoor" fallback are reclassified, using centroids computed from the
// database's own labelled activities.
import Database from 'better-sqlite3';
import fs from 'node:fs';
const APPLY = process.argv.includes('--apply');
const dbPath = (process.env.STREBA_DATA_DIR ?? 'data') + '/streba.db';
const db = new Database(dbPath);
const rows = db
.prepare(
`SELECT id, name, type, distance_m, elev_gain_m,
coalesce(moving_s, duration_s) time_s
FROM activities`
)
.all()
.map((r) => ({
...r,
kmh: r.time_s > 0 ? (r.distance_m / r.time_s) * 3.6 : null,
gainPerKm: r.distance_m > 0 ? r.elev_gain_m / (r.distance_m / 1000) : 0
}));
// generic classes we are willing to assign
const FOOT_SLOW = new Set(['hike', 'walk', 'walking']);
const FOOT_FAST = new Set(['run', 'running', 'trail run']);
const BIKE = new Set(['ride', 'cycling', 'bike', 'mtb', 'gravel']);
const GENERIC = new Set([...FOOT_SLOW, ...FOOT_FAST, ...BIKE, 'outdoor']);
// centroids from the user's own labelled data (fall back to sensible defaults)
function centroid(types, defKmh, defGain, defKm) {
const sample = rows.filter((r) => types.has(r.type) && r.kmh !== null);
if (sample.length < 3) return { kmh: defKmh, gainPerKm: defGain, km: defKm };
return {
kmh: sample.reduce((s, r) => s + r.kmh, 0) / sample.length,
gainPerKm: sample.reduce((s, r) => s + r.gainPerKm, 0) / sample.length,
km: sample.reduce((s, r) => s + r.distance_m / 1000, 0) / sample.length
};
}
const CLASSES = [
{ name: 'hike', c: centroid(FOOT_SLOW, 4, 70, 10) },
{ name: 'run', c: centroid(FOOT_FAST, 11, 25, 8) },
{ name: 'ride', c: centroid(new Set(['ride', 'cycling', 'bike']), 21, 15, 40) },
{ name: 'mtb', c: centroid(new Set(['mtb']), 13.5, 45, 27) }
];
function classify(r) {
if (r.kmh === null) return null;
let best = null;
let bestScore = Infinity;
for (const cls of CLASSES) {
// normalised distance in (speed, climb-rate, length) space
const ds = (r.kmh - cls.c.kmh) / 6;
const dg = (r.gainPerKm - cls.c.gainPerKm) / 40;
const dd = (r.distance_m / 1000 - cls.c.km) / 20;
const score = ds * ds + dg * dg + dd * dd;
if (score < bestScore) {
bestScore = score;
best = cls.name;
}
}
return best;
}
function contradiction(r) {
if (r.kmh === null) return false;
if (r.type === 'outdoor') return true;
if (/^\d+$/.test(r.type)) return true;
if (FOOT_SLOW.has(r.type) && r.kmh > 9) return true;
if (FOOT_FAST.has(r.type) && (r.kmh < 4.5 || r.kmh > 16)) return true;
if (BIKE.has(r.type) && r.kmh < 5.5) return true;
return false;
}
const changes = [];
for (const r of rows) {
if (!GENERIC.has(r.type) && !/^\d+$/.test(r.type)) continue; // curated type: hands off
if (!contradiction(r)) continue;
const suggestion = classify(r);
if (!suggestion || suggestion === r.type) continue;
changes.push({
id: r.id,
name: r.name,
from: r.type,
to: suggestion,
kmh: r.kmh === null ? null : Math.round(r.kmh * 10) / 10,
gainPerKm: Math.round(r.gainPerKm)
});
}
// borderline cases worth a human look (reported, never auto-changed)
const review = rows.filter(
(r) =>
(r.type === 'ride' && r.gainPerKm > 35 && r.kmh < 16) ||
(r.type === 'mtb' && r.gainPerKm < 12 && r.kmh > 17) ||
(FOOT_FAST.has(r.type) && r.kmh !== null && r.kmh < 6) ||
(FOOT_SLOW.has(r.type) && r.kmh !== null && r.kmh > 7 && r.kmh <= 9)
);
if (review.length > 0) {
console.log('\nBorderline (left unchanged - review by hand if they look off):');
console.table(
review.map((r) => ({
id: r.id,
name: r.name,
type: r.type,
kmh: Math.round((r.kmh ?? 0) * 10) / 10,
gainPerKm: Math.round(r.gainPerKm),
km: Math.round(r.distance_m / 100) / 10
}))
);
}
if (changes.length === 0) {
console.log('No contradictions found.');
process.exit(0);
}
console.table(changes);
if (!APPLY) {
console.log(`Dry run - ${changes.length} change(s) proposed. Re-run with --apply.`);
process.exit(0);
}
const update = db.prepare('UPDATE activities SET type = ? WHERE id = ?');
db.transaction(() => {
for (const change of changes) update.run(change.to, change.id);
})();
fs.writeFileSync(
(process.env.STREBA_DATA_DIR ?? 'data') + '/type-fixes.json',
JSON.stringify({ applied: new Date().toISOString(), changes }, null, 2)
);
console.log(`Applied ${changes.length} change(s); log written to data/type-fixes.json.`);
+11
View File
@@ -31,10 +31,21 @@ const TYPE_SLOT: Record<string, number> = {
'via ferrata': 3,
alpinism: 3,
ski: 6,
alpineski: 6,
skitour: 6,
langlauf: 6,
'backcountry ski': 6,
'nordic ski': 6,
snowboard: 6,
snowshoe: 6,
mtb: 2,
gravel: 2,
vita: 1,
iceskate: 4,
inlineskate: 4,
inline: 4,
'hike & fly': 3,
watersport: 5,
segment: 7
};
+8 -2
View File
@@ -1,7 +1,13 @@
// Lift-assisted downhill types: their recorded elevation gain comes from
// lifts, so it doesn't count toward ascent totals. Touring variants
// ("ski touring", "backcountry ski") earn their metres and do count.
export const LIFT_ASSISTED_TYPES = ['ski', 'alpine ski', 'downhill ski', 'snowboard'];
// ("skitour", "backcountry ski", "langlauf") earn their metres and do count.
export const LIFT_ASSISTED_TYPES = [
'ski',
'alpine ski',
'alpineski',
'downhill ski',
'snowboard'
];
export function countsTowardAscent(type: string): boolean {
return !LIFT_ASSISTED_TYPES.includes(type.toLowerCase().trim());