type audit script, fix misclassified activities, alpineski excluded from ascent
This commit is contained in:
@@ -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.`);
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user