add more train stats per driver

This commit is contained in:
larssand
2026-08-29 09:56:45 +02:00
parent 28e5c96010
commit e5e1754185
9 changed files with 722 additions and 8 deletions

View File

@@ -70,7 +70,25 @@ export function normalizeTrainingState(training, { uid }) {
})
: [];
return { goals, ledger, shares };
const markers = Array.isArray(source.markers)
? source.markers
.map((marker) => ({
id: String(marker?.id || uid("training_marker")),
driverId: String(marker?.driverId || ""),
classId: String(marker?.classId || ""),
sessionId: String(marker?.sessionId || ""),
timestamp: Math.max(0, Number(marker?.timestamp || 0) || 0),
type: ["setup", "tires", "suspension", "car", "note"].includes(String(marker?.type || "").toLowerCase())
? String(marker.type).toLowerCase()
: "setup",
note: String(marker?.note || "").trim(),
createdAt: String(marker?.createdAt || new Date().toISOString()),
}))
.filter((marker) => marker.driverId && marker.classId && marker.timestamp > 0)
.sort((left, right) => left.timestamp - right.timestamp)
: [];
return { goals, ledger, shares, markers };
}
function getPassingSourceKey(sessionId, passing, index) {
@@ -111,7 +129,7 @@ export function syncTrainingLedger(training, state, { uid }) {
});
});
return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares }, { uid });
return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares, markers: normalized.markers }, { uid });
}
export function getDriverTrainingLaps(state, driverId, classId = "") {
@@ -145,6 +163,137 @@ function consistencySpread(values) {
return mean ? ((percentile(values, 0.9) - percentile(values, 0.1)) / mean) * 100 : 0;
}
function trimmedAverage(values, trimRatio = 0.1) {
if (!values.length) return 0;
const sorted = [...values].sort((left, right) => left - right);
const trimCount = sorted.length >= 5 ? Math.max(1, Math.floor(sorted.length * trimRatio)) : 0;
const kept = trimCount ? sorted.slice(trimCount, sorted.length - trimCount) : sorted;
return average(kept);
}
export function getDriverTrainingMarkers(state, driverId, classId = "") {
const profileReferences = getTrainingDriverIds(state, driverId, classId);
const normalizedClassId = String(classId || "");
return (Array.isArray(state?.training?.markers) ? state.training.markers : [])
.filter((marker) => (
profileReferences.has(String(marker?.driverId || ""))
&& (!normalizedClassId || String(marker?.classId || "") === normalizedClassId)
))
.map((marker) => ({ ...marker }))
.sort((left, right) => Number(left.timestamp || 0) - Number(right.timestamp || 0));
}
export function buildTrainingPaceBuckets(state, driverId, classId = "", bucketMinutes = 30) {
const laps = getDriverTrainingLaps(state, driverId, classId);
const bucketMs = Math.max(5, Number(bucketMinutes || 30)) * 60 * 1000;
const buckets = new Map();
laps.forEach((lap) => {
const date = new Date(lap.timestamp);
const bucketMinutesValue = bucketMs / 60000;
const startTs = new Date(
date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(),
Math.floor(date.getMinutes() / bucketMinutesValue) * bucketMinutesValue, 0, 0
).getTime();
const key = String(startTs);
const bucket = buckets.get(key) || { startTs, endTs: startTs + bucketMs, laps: [], sessionIds: new Set() };
bucket.laps.push(lap);
if (lap.sessionId) bucket.sessionIds.add(lap.sessionId);
buckets.set(key, bucket);
});
const markers = getDriverTrainingMarkers(state, driverId, classId);
return [...buckets.values()]
.sort((left, right) => left.startTs - right.startTs)
.map((bucket) => {
const values = bucket.laps.map((lap) => lap.lapMs);
return {
startTs: bucket.startTs,
endTs: bucket.endTs,
lapCount: values.length,
paceMs: trimmedAverage(values),
medianMs: percentile(values, 0.5),
bestLapMs: values.length ? Math.min(...values) : 0,
sessionIds: [...bucket.sessionIds],
markers: markers.filter((marker) => marker.timestamp >= bucket.startTs && marker.timestamp < bucket.endTs),
};
});
}
export function buildTrainingWeekdayStats(state, driverId, classId = "") {
const groups = Array.from({ length: 7 }, (_, weekday) => ({ weekday, laps: [] }));
getDriverTrainingLaps(state, driverId, classId).forEach((lap) => groups[new Date(lap.timestamp).getDay()].laps.push(lap.lapMs));
return groups.map((group) => ({
weekday: group.weekday,
lapCount: group.laps.length,
paceMs: trimmedAverage(group.laps),
bestLapMs: group.laps.length ? Math.min(...group.laps) : 0,
}));
}
export function buildTrainingMarkerComparisons(state, driverId, classId = "", windowMinutes = 30) {
const laps = getDriverTrainingLaps(state, driverId, classId);
const windowMs = Math.max(5, Number(windowMinutes || 30)) * 60 * 1000;
return getDriverTrainingMarkers(state, driverId, classId).map((marker) => {
const comparableLaps = marker.sessionId ? laps.filter((lap) => lap.sessionId === marker.sessionId) : laps;
const before = comparableLaps.filter((lap) => lap.timestamp < marker.timestamp && lap.timestamp >= marker.timestamp - windowMs);
const after = comparableLaps.filter((lap) => lap.timestamp >= marker.timestamp && lap.timestamp < marker.timestamp + windowMs);
const beforePaceMs = before.length >= 3 ? trimmedAverage(before.map((lap) => lap.lapMs)) : 0;
const afterPaceMs = after.length >= 3 ? trimmedAverage(after.map((lap) => lap.lapMs)) : 0;
return {
...marker,
beforeLapCount: before.length,
afterLapCount: after.length,
beforePaceMs,
afterPaceMs,
improvementMs: beforePaceMs && afterPaceMs ? beforePaceMs - afterPaceMs : null,
};
});
}
function getBestConsecutiveAverage(laps, count) {
let best = 0;
for (let index = 0; index <= laps.length - count; index += 1) {
const candidate = average(laps.slice(index, index + count).map((lap) => lap.lapMs));
if (!best || candidate < best) best = candidate;
}
return best;
}
export function buildTrainingStintAnalysis(state, driverId, classId = "") {
const stints = splitTrainingStints(getDriverTrainingLaps(state, driverId, classId));
const rows = stints.map((stint, index) => {
const laps = stint.laps.filter((lap) => !lap.excludedFromPace);
const values = laps.map((lap) => lap.lapMs);
const medianMs = percentile(values, 0.5);
const mistakeThresholdMs = medianMs ? medianMs * 1.15 : 0;
const mistakes = values.filter((lapMs) => mistakeThresholdMs && lapMs > mistakeThresholdMs).length;
const comparisonSize = values.length >= 6 ? Math.max(3, Math.floor(values.length / 3)) : 0;
const startPaceMs = comparisonSize ? trimmedAverage(values.slice(0, comparisonSize)) : 0;
const endPaceMs = comparisonSize ? trimmedAverage(values.slice(-comparisonSize)) : 0;
return {
id: `${stint.sessionId}:${stint.startedAt}:${index}`,
number: index + 1,
sessionId: stint.sessionId,
startedAt: stint.startedAt,
endedAt: stint.endedAt,
durationMs: stint.durationMs,
lapCount: values.length,
bestLapMs: values.length ? Math.min(...values) : 0,
paceMs: trimmedAverage(values),
racePace10Ms: values.length >= 10 ? getBestConsecutiveAverage(laps, 10) : 0,
mistakes,
mistakePct: values.length ? (mistakes / values.length) * 100 : 0,
consistencyPct: values.length >= 3 ? consistencySpread(values) : null,
startPaceMs,
endPaceMs,
endChangeMs: startPaceMs && endPaceMs ? startPaceMs - endPaceMs : null,
insufficient: values.length < 3,
};
});
const eligible = rows.filter((row) => !row.insufficient);
const best = eligible.reduce((current, row) => (!current || row.paceMs < current.paceMs ? row : current), null);
return { rows: rows.sort((left, right) => right.startedAt - left.startedAt), best };
}
export function buildTrainingAchievements(stats) {
const source = stats && typeof stats === "object" ? stats : {};
const definitions = [