diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index bc4a5f2..cb02235 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -151,7 +151,7 @@ - Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal - Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `
`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера SG), showRoutesScreen (сохранённые маршруты) - Похвастаться: showShareModal / buildShareImage (сторис-PNG: форма трека + статы; `opts.fields` галочками в модалке, выбор в `localStorage.baldav_share_fields`; прозрачный/фото; в стеке окон - `winPush("share")`), exportShareImage (нативный share/save) -- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=3 входов), один эталонный проход, разметка проходов по возрастанию позиции (порт `tools/track/pers.py:passes`); возвращает `{found,segLength,passCount,passes}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица заездов + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - из `avgPower` (новое поле возврата `GPS.computeStats`) +- Разбор повторов: `GPS.detectRepeats(points)` (gps.js) - самоподобие: сетка ~10м, горячие ячейки (>=3 входов), самый длинный эталонный проход -> опорная линия; каждая точка ПРОЕЦИРУЕТСЯ на неё (cell-индекс, допуск вбок 30м) -> прогресс; проход = прогресс растёт от низа до >=80% (порт идеи `tools/track/pers.py`). Мост через короткие остановки/дрожь; время заезда `movingSec` = только движение (стопы вырезаны). Возвращает `{found,segLength,passCount,passes:[{startIdx,endIdx,movingSec}]}`. Секция «Разбор повторов» в `showSessionDetail` (только вело): таблица + вердикт последний/первый + подсветка участка на карте. Мощность на заезд - `avgPower` (новое поле возврата `GPS.computeStats`) - Роутинг по проезженному: loadRideGraph (кэш графа из `get_ride_graph`, grid-индекс) / rideRoute (Dijkstra между концами ноги, снап ≤60м, отсев кривого) - в `showRouteEditor` пробуется до BRouter - Прочее: showWeightModal, showSleepModal, showActivityModal, showProfileModal, showBikeModal, showMapDownloadModal, showLogSupplementModal diff --git a/README.md b/README.md index 59da995..12c35d3 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ - **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей. - **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё». - **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется. -- **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, один эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость нормированная на длину участка, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет. +- **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, самый длинный эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Каждая точка проецируется на опорную линию (снос вбок в пределах ~30м, например при возне с седлом, не режет покрытие); время заезда - только секунды в движении (остановки вырезаются, скорость нормируется на длину участка). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет. - **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках. - **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`
`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла. - **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает. diff --git a/docs/session-logs/2026-09-17_repeat-intervals.md b/docs/session-logs/2026-09-17_repeat-intervals.md index da57cfe..fd822a4 100644 --- a/docs/session-logs/2026-09-17_repeat-intervals.md +++ b/docs/session-logs/2026-09-17_repeat-intervals.md @@ -73,6 +73,21 @@ первый самый медленный (штраф за 89с стоп), вердикт 121%. 11/17 сен без изменений (по 5), контроль 5 обычных - 0 ложных при 0.7. Десктоп: 16 сен подсажен, таблица 4 строки. +## Фикс 3: проекция на опорную линию + время только в движении +- Пользователь: снос вбок не случайность (седло падало, подкручивал) - проецировать на + опорную линию, чтобы боковой снос не резал покрытие; остановки на краях заездов не считать + во время. +- Сделано: детект проходов переписан с «точного совпадения ячеек» на ПРОЕКЦИЮ. Опорная линия = + точки лучшего прохода с накопленной дистанцией; каждая точка поездки проецируется на неё + (ближайшая вершина через cell-индекс, допуск вбок `projTolM` 30м) -> прогресс в метрах. + Проход = прогресс растёт от низа до >= minFrac. Снос на 30м больше не роняет покрытие, + поэтому `minFrac` вернул к строгому 0.8 (как pers.py). +- Время заезда = только СЕКУНДЫ В ДВИЖЕНИИ (`movingSec`, по фактическому смещению >=0.8 м/с), + стопы на краях и в середине вырезаются. Нормировка норм.скор = длина/движ.время. +- Проверка (харнесс, minFrac 0.8): 16 сен -> 4 заезда, движ.время [281,268,271,252]с (89с стоп + вырезан из первого), норм.скор [20.1,21.1,20.9,22.5], посл/перв 112%. 11 сен 5× (16.6..18.8, + 113%), 17 сен 5× (101%), контроль 5 обычных - 0 ложных, короткие - нет. + ## Не в этой задаче Из того же файла НЕ делалось: поправка модели п.2 (аддитивная мощность вместо max(физика,MET); п.1 про Crr уже был сделан ранее), массовое уточнение высот, ветер (Часть 2 - там вопросы). diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 634f14a..1725d30 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -106,9 +106,9 @@ GPS.detectRepeats = function(points, 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 ?? 3; // and ridden at least this many times - let minFrac = opts.minFrac ?? 0.7; // a pass must cover this fraction of the segment (0.7: - // a full rep whose GPS drifted off the common line near - // the top still counts; its stop time penalises its speed) + 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 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 n = points ? points.length : 0; @@ -155,7 +155,7 @@ GPS.detectRepeats = function(points, opts) { 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, order, len }; + return { seq, len, start, end }; }; let best = null; for (let k = 0; k < n; k++) { @@ -163,37 +163,71 @@ GPS.detectRepeats = function(points, opts) { let t = buildTraversal(k); if (!best || t.len > best.len) best = t; } - if (!best) return { found: false }; - let seq = best.seq, order = best.order, segLength = best.len; - if (seq.length < 3 || segLength < minLenM) return { found: false }; + if (!best || best.seq.length < 3 || best.len < minLenM) return { found: false }; - // Passes: scan the ride; a pass is a run where position along seq rises from the - // start (tolerating 3-cell backsteps and gaps), covering >= minFrac of the segment. - let pos = key.map(k => (order.has(k) ? order.get(k) : null)); + // 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 (pos[p] == null || pos[p] > seq.length * 0.15) { p++; continue; } - let q = p, lo = pos[p], hi = pos[p], anchor = p, gap = 0; + 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 (pos[q] != null && pos[q] >= hi - 3) { gap = 0; hi = Math.max(hi, pos[q]); anchor = q; continue; } - // Off the corridor (a gap) or a step backward. BRIDGE a short interruption near the - // corridor - a stop (dropped seat, mechanical) or GPS wobble/divergence - so it stays - // ONE pass, with the idle time kept in the pass duration (a fair speed penalty). A real - // descent between reps moves far and long, exceeding the bridge, and ends the pass. + 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; } - // Pass spans [p, anchor] (last on-corridor point); duration ts[anchor]-ts[p] includes any - // bridged stop, so a pass with a rest reads slower, exactly as intended. - if ((hi - lo) >= seq.length * minFrac) passes.push({ startIdx: p, endIdx: anchor }); + 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; } if (passes.length < minPasses) return { found: false }; - return { found: true, segLength: Math.round(segLength), passCount: passes.length, passes }; + return { found: true, segLength: Math.round(refLen), passCount: passes.length, passes }; }; // One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes diff --git a/frontend/js/main.js b/frontend/js/main.js index 90e00b4..4aa2c58 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -5600,7 +5600,7 @@ async function showSessionDetail(sessionId) { let rows = rep.passes.map((p, i) => { let seg = points.slice(p.startIdx, p.endIdx + 1); let st = GPS.computeStats(seg, weightKg, opts); - let T = Math.max(1, (new Date(seg[seg.length - 1].ts) - new Date(seg[0].ts)) / 1000); + let T = Math.max(1, p.movingSec); // moving time only (stops trimmed) return { i: i + 1, T, normV: rep.segLength / T * 3.6, cad: st.avgCadence, pw: st.avgPower }; }); let ratio = rows[rows.length - 1].normV / rows[0].normV, pct = Math.round(ratio * 100);