Repeat analysis: sweep the hot-cell threshold and keep the most passes (out-and-back reps were lost when a twice-ridden approach inflated the reference)

This commit is contained in:
4aiman 2026-09-21 20:30:38 +03:00
commit 685151b76b
2 changed files with 111 additions and 110 deletions

View file

@ -9,8 +9,17 @@
- берём последние 7 дней. Корневой off-by-one в `getDailyFoodTotals` не трогал, чтобы не
сдвинуть график 30/90.
## #2 сегодняшние повторы не опознались (в работе - нужен телефон)
- Пользователь делал интервалы сегодня, «Разбор повторов» их не нашёл.
- Диагностика требует сегодняшнего трека (pull с телефона + прогон `detectRepeats`).
На момент правки #1 устройство было не подключено - жду телефон, тяну базу, смотрю,
почему не сработало (порог/длина/снос/структура).
## #2 сегодняшние повторы не опознались (исправлено)
- Трек 1790002321789 (out-and-back A(47.4915,40.0817)<->B(47.4917,40.1025), ~1.5км, 5 раз
туда-обратно). `detectRepeats` -> «не найдено».
- Диагностика: горячих ячеек полно (197 с >=3 входами, макс 10). Эталон (самый длинный проход)
при пороге горячей ячейки **2** раздувается до **2190м** - тянет разово/дважды накатанный
подъезд и развороты. Полный заезд 1585м покрывает лишь 72% этого эталона (<80%), поэтому
засчитывался лишь 1 проход. При пороге 3+ эталон сжимается до ядра **1585м** и находятся все 5.
- Причина глубже минусов порога: жадный порог 2 (нужен для «2 заездов») ломает эталон, когда
подъезд накатан 2 раза.
- Фикс: `detectRepeats` теперь ПЕРЕБИРАЕТ порог горячей ячейки от 2 до макс.входов и берёт тот,
что даёт БОЛЬШЕ проходов (при равенстве - длиннее участок). `minPasses` -> только финальный
порог числа проходов (`minReps`), развязан с порогом горячей ячейки. Вынесено в `detectAt(T)`.
- Проверка (харнесс): СЕГОДНЯ 5× 1590м [16.8,17.6,17.1,17.1,16.8]; 11сен 5×, 17сен 5×,
16сен(склеен) 5×; 5 обычных поездок - 0 ложных. ~0.9с/сессия на десктопе (перебор порогов).

View file

@ -105,16 +105,12 @@ GPS.detectRepeats = function(points, opts) {
opts = opts || {};
let cell = opts.cellM ?? 10; // grid cell ~10 m (as in tools/track heightmap)
let minLenM = opts.minLenM ?? 300; // repeated segment must be at least this long
let minPasses = opts.minPasses ?? 2; // ridden at least this many times (2: a segment ridden
// twice is already a repeat; 3 missed the normal "there and
// back twice" case. Crossroads are rejected by minLenM, not
// by the count; same-direction monotonic passes reject a plain
// out-and-back commute - verified 0 false positives on rides)
let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment (projection
// handles lateral drift, so a full rep reads as full coverage
// and this stays strict, as in tools/track pers.py)
let minReps = opts.minPasses ?? 2; // minimum passes to REPORT (final gate)
let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment
let bridgeSec = opts.bridgeSec ?? 90; // bridge an interruption inside a pass up to this long
let bridgeM = opts.bridgeM ?? 60; // ...as long as the rider stays within this of the corridor
let projTolM = opts.projTolM ?? 30; // max lateral distance to count as on the corridor
let backM = opts.backM ?? 30; // progress may dip this far without ending the pass
let n = points ? points.length : 0;
if (n < 20) return { found: false };
@ -123,115 +119,111 @@ GPS.detectRepeats = function(points, opts) {
for (let i = 0; i < n; i++) {
let la = points[i].lat, lo = points[i].lon;
if (la == null || lo == null) { key[i] = null; continue; }
let ky = Math.floor(la * 111320 / cell);
let kx = Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell);
key[i] = ky + "," + kx;
key[i] = Math.floor(la * 111320 / cell) + "," + Math.floor(lo * 111320 * Math.cos(la * Math.PI / 180) / cell);
}
// Entries per cell: count each time the track enters a cell (key changes into it).
// A corridor ridden 5x (up+down the same road) racks up ~10 entries; approach/return
// cells get 1. So >= minPasses entries marks the repeated corridor.
let visits = new Map();
for (let i = 0; i < n; i++) {
if (key[i] == null) continue;
if (i === 0 || key[i] !== key[i - 1]) visits.set(key[i], (visits.get(key[i]) || 0) + 1);
}
let isHot = (k) => k != null && (visits.get(k) || 0) >= minPasses;
let maxEntries = 0; for (let v of visits.values()) if (v > maxEntries) maxEntries = v;
if (maxEntries < 2) return { found: false };
// Reference segment = the LONGEST clean traversal of the corridor. One traversal =
// start at a cell, add each NEW hot cell in order, STOP at the first revisit (turned
// back / next lap) or a gap out of the corridor. Gluing laps together inflates length
// and scrambles order (measured: absurd 9 s "passes" over 4.4 km); a single traversal
// fixes that. But picking the FIRST hot cell is also wrong — a ride can START inside a
// hot cell (its approach overlaps the corridor), giving a short gap-truncated seq
// (measured: 729 m of a 1572 m climb). So try a traversal from every hot-run start and
// keep the longest.
let buildTraversal = (start) => {
let seq = [], order = new Map(), end = start;
for (let k = start, gap = 0; k < n; k++) {
let c = key[k];
if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor
gap = 0;
if (seq.length && c === seq[seq.length - 1]) { end = k; continue; } // still in the same cell
if (order.has(c)) break; // revisit of an earlier cell → traversal complete
order.set(c, seq.length); seq.push(c); end = k;
}
let len = 0;
for (let k = start + 1; k <= end; k++)
len += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
return { seq, len, start, end };
};
let best = null;
for (let k = 0; k < n; k++) {
if (!isHot(key[k]) || (k > 0 && isHot(key[k - 1]))) continue; // only at a hot-run start
let t = buildTraversal(k);
if (!best || t.len > best.len) best = t;
}
if (!best || best.seq.length < 3 || best.len < minLenM) return { found: false };
// Reference polyline = the best traversal's points, with cumulative distance = progress.
// Progress is measured by PROJECTING each ride point onto this line, not by exact cell
// match: so a lap that drifts a few metres off the common line (measured: fixing a
// dropping seat pulled the track sideways) still reads as full coverage instead of a
// truncated 74%. A cell→vertex index keeps the nearest-vertex search O(1) per point.
let projTolM = opts.projTolM ?? 30; // max lateral distance to count as on the corridor
let backM = opts.backM ?? 30; // progress may dip this far without ending the pass
let rLat = [], rLon = [], cum = [0];
for (let k = best.start; k <= best.end; k++) {
rLat.push(points[k].lat); rLon.push(points[k].lon);
if (k > best.start) cum.push(cum[cum.length - 1] + GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon));
}
let refLen = cum[cum.length - 1];
if (rLat.length < 3 || refLen < minLenM) return { found: false };
let refCell = new Map();
for (let v = 0; v < rLat.length; v++) {
let ck = Math.floor(rLat[v] * 111320 / cell) + "," + Math.floor(rLon[v] * 111320 * Math.cos(rLat[v] * Math.PI / 180) / cell);
(refCell.get(ck) || refCell.set(ck, []).get(ck)).push(v);
}
let rad = Math.ceil(projTolM / cell);
let prog = new Array(n).fill(null);
for (let i = 0; i < n; i++) {
if (points[i].lat == null) continue;
let cy = Math.floor(points[i].lat * 111320 / cell), cx = Math.floor(points[i].lon * 111320 * Math.cos(points[i].lat * Math.PI / 180) / cell);
let bv = -1, bd = projTolM;
for (let da = -rad; da <= rad; da++) for (let db = -rad; db <= rad; db++) {
let arr = refCell.get((cy + da) + "," + (cx + db)); if (!arr) continue;
for (let v of arr) { let d = GPS.distance(points[i].lat, points[i].lon, rLat[v], rLon[v]); if (d < bd) { bd = d; bv = v; } }
}
if (bv >= 0) prog[i] = cum[bv];
}
// Passes: a run where progress rises from near the bottom to >= minFrac of the segment.
// A bridged interruption (stop / GPS wobble) keeps it one pass; a descent (progress falls
// and the rider moves far) ends it. Pass time counts MOVING seconds only (>= 0.8 m/s by
// real displacement, not the fused speed field) - stops at the edges or middle of a rep
// are not climbing, so they are trimmed out.
let passes = [];
let p = 0;
while (p < n) {
if (prog[p] == null || prog[p] > refLen * 0.15) { p++; continue; }
let q = p, lo = prog[p], hi = prog[p], anchor = p, gap = 0;
while (q < n - 1) {
q++;
if (prog[q] != null && prog[q] >= hi - backM) { gap = 0; hi = Math.max(hi, prog[q]); anchor = q; continue; }
let dtStop = (new Date(points[q].ts) - new Date(points[anchor].ts)) / 1000;
let dispStop = GPS.distance(points[anchor].lat, points[anchor].lon, points[q].lat, points[q].lon);
if ((dtStop <= bridgeSec && dispStop <= bridgeM) || ++gap <= 25) continue;
break;
}
if ((hi - lo) >= refLen * minFrac) {
let mov = 0;
for (let k = p + 1; k <= anchor; k++) {
let dt = (new Date(points[k].ts) - new Date(points[k - 1].ts)) / 1000;
let disp = GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
if (dt > 0 && disp / dt >= 0.8) mov += dt; // moving; a stop (dropped seat) is excluded
// Find passes for a given hot-cell threshold (cells entered >= hotThr times = the corridor).
// Returns { segLength, passes } or null. The reference is the LONGEST clean traversal of hot
// cells; each ride point is PROJECTED onto it (progress in metres); a pass is a run where
// progress rises from near the bottom to >= minFrac, tolerating drift/stops (bridge) and a
// small backstep. Pass time = moving seconds only (stops trimmed).
let detectAt = (hotThr) => {
let isHot = (k) => k != null && (visits.get(k) || 0) >= hotThr;
let buildTraversal = (start) => {
let seq = [], order = new Map(), end = start;
for (let k = start, gap = 0; k < n; k++) {
let c = key[k];
if (c == null || !isHot(c)) { gap++; if (gap > 5) break; continue; } // left the corridor
gap = 0;
if (seq.length && c === seq[seq.length - 1]) { end = k; continue; } // still in the same cell
if (order.has(c)) break; // revisit of an earlier cell -> traversal complete
order.set(c, seq.length); seq.push(c); end = k;
}
passes.push({ startIdx: p, endIdx: anchor, movingSec: Math.round(mov) });
let len = 0;
for (let k = start + 1; k <= end; k++)
len += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
return { seq, len, start, end };
};
let best = null;
for (let k = 0; k < n; k++) {
if (!isHot(key[k]) || (k > 0 && isHot(key[k - 1]))) continue; // only at a hot-run start
let t = buildTraversal(k);
if (!best || t.len > best.len) best = t;
}
p = Math.max(q, anchor) + 1;
if (!best || best.seq.length < 3 || best.len < minLenM) return null;
let rLat = [], rLon = [], cum = [0];
for (let k = best.start; k <= best.end; k++) {
rLat.push(points[k].lat); rLon.push(points[k].lon);
if (k > best.start) cum.push(cum[cum.length - 1] + GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon));
}
let refLen = cum[cum.length - 1];
if (rLat.length < 3 || refLen < minLenM) return null;
let refCell = new Map();
for (let v = 0; v < rLat.length; v++) {
let ck = Math.floor(rLat[v] * 111320 / cell) + "," + Math.floor(rLon[v] * 111320 * Math.cos(rLat[v] * Math.PI / 180) / cell);
(refCell.get(ck) || refCell.set(ck, []).get(ck)).push(v);
}
let rad = Math.ceil(projTolM / cell);
let prog = new Array(n).fill(null);
for (let i = 0; i < n; i++) {
if (points[i].lat == null) continue;
let cy = Math.floor(points[i].lat * 111320 / cell), cx = Math.floor(points[i].lon * 111320 * Math.cos(points[i].lat * Math.PI / 180) / cell);
let bv = -1, bd = projTolM;
for (let da = -rad; da <= rad; da++) for (let db = -rad; db <= rad; db++) {
let arr = refCell.get((cy + da) + "," + (cx + db)); if (!arr) continue;
for (let v of arr) { let d = GPS.distance(points[i].lat, points[i].lon, rLat[v], rLon[v]); if (d < bd) { bd = d; bv = v; } }
}
if (bv >= 0) prog[i] = cum[bv];
}
let passes = [];
let p = 0;
while (p < n) {
if (prog[p] == null || prog[p] > refLen * 0.15) { p++; continue; }
let q = p, lo = prog[p], hi = prog[p], anchor = p, gap = 0;
while (q < n - 1) {
q++;
if (prog[q] != null && prog[q] >= hi - backM) { gap = 0; hi = Math.max(hi, prog[q]); anchor = q; continue; }
let dtStop = (new Date(points[q].ts) - new Date(points[anchor].ts)) / 1000;
let dispStop = GPS.distance(points[anchor].lat, points[anchor].lon, points[q].lat, points[q].lon);
if ((dtStop <= bridgeSec && dispStop <= bridgeM) || ++gap <= 25) continue;
break;
}
if ((hi - lo) >= refLen * minFrac) {
let mov = 0;
for (let k = p + 1; k <= anchor; k++) {
let dt = (new Date(points[k].ts) - new Date(points[k - 1].ts)) / 1000;
let disp = GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon);
if (dt > 0 && disp / dt >= 0.8) mov += dt; // moving; a stop (dropped seat) is excluded
}
passes.push({ startIdx: p, endIdx: anchor, movingSec: Math.round(mov) });
}
p = Math.max(q, anchor) + 1;
}
return { segLength: Math.round(refLen), passes };
};
// A too-low hot threshold pulls the once/twice-ridden approach and turnaround into the
// reference, inflating it so a full rep no longer covers minFrac (measured on a real
// out-and-back: 1 pass at threshold 2, 5 at threshold 3+). Try every threshold from 2 up to
// the busiest cell and keep the one yielding the MOST passes (tie -> the longest segment).
let bestRes = null;
for (let T = 2; T <= Math.min(maxEntries, 20); T++) {
let r = detectAt(T);
if (!r || r.passes.length < minReps) continue;
if (!bestRes || r.passes.length > bestRes.passes.length ||
(r.passes.length === bestRes.passes.length && r.segLength > bestRes.segLength)) bestRes = r;
}
if (passes.length < minPasses) return { found: false };
return { found: true, segLength: Math.round(refLen), passCount: passes.length, passes };
if (!bestRes) return { found: false };
return { found: true, segLength: bestRes.segLength, passCount: bestRes.passes.length, passes: bestRes.passes };
};
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes