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,
|
'via ferrata': 3,
|
||||||
alpinism: 3,
|
alpinism: 3,
|
||||||
ski: 6,
|
ski: 6,
|
||||||
|
alpineski: 6,
|
||||||
|
skitour: 6,
|
||||||
|
langlauf: 6,
|
||||||
'backcountry ski': 6,
|
'backcountry ski': 6,
|
||||||
'nordic ski': 6,
|
'nordic ski': 6,
|
||||||
snowboard: 6,
|
snowboard: 6,
|
||||||
snowshoe: 6,
|
snowshoe: 6,
|
||||||
|
mtb: 2,
|
||||||
|
gravel: 2,
|
||||||
|
vita: 1,
|
||||||
|
iceskate: 4,
|
||||||
|
inlineskate: 4,
|
||||||
|
inline: 4,
|
||||||
|
'hike & fly': 3,
|
||||||
|
watersport: 5,
|
||||||
segment: 7
|
segment: 7
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
// Lift-assisted downhill types: their recorded elevation gain comes from
|
// Lift-assisted downhill types: their recorded elevation gain comes from
|
||||||
// lifts, so it doesn't count toward ascent totals. Touring variants
|
// lifts, so it doesn't count toward ascent totals. Touring variants
|
||||||
// ("ski touring", "backcountry ski") earn their metres and do count.
|
// ("skitour", "backcountry ski", "langlauf") earn their metres and do count.
|
||||||
export const LIFT_ASSISTED_TYPES = ['ski', 'alpine ski', 'downhill ski', 'snowboard'];
|
export const LIFT_ASSISTED_TYPES = [
|
||||||
|
'ski',
|
||||||
|
'alpine ski',
|
||||||
|
'alpineski',
|
||||||
|
'downhill ski',
|
||||||
|
'snowboard'
|
||||||
|
];
|
||||||
|
|
||||||
export function countsTowardAscent(type: string): boolean {
|
export function countsTowardAscent(type: string): boolean {
|
||||||
return !LIFT_ASSISTED_TYPES.includes(type.toLowerCase().trim());
|
return !LIFT_ASSISTED_TYPES.includes(type.toLowerCase().trim());
|
||||||
|
|||||||
Reference in New Issue
Block a user