From 685151b76b3ffb5dd32a4eab147ff0ff9e03bfd0 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 21 Sep 2026 20:30:38 +0300 Subject: [PATCH] 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) --- docs/session-logs/2026-09-21_ui-fixes.md | 19 ++- frontend/js/gps.js | 202 +++++++++++------------ 2 files changed, 111 insertions(+), 110 deletions(-) diff --git a/docs/session-logs/2026-09-21_ui-fixes.md b/docs/session-logs/2026-09-21_ui-fixes.md index b54994d..bdcb02b 100644 --- a/docs/session-logs/2026-09-21_ui-fixes.md +++ b/docs/session-logs/2026-09-21_ui-fixes.md @@ -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с/сессия на десктопе (перебор порогов). diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 60e97be..d580888 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -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