diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 3f87437..bc4a5f2 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -151,6 +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`) - Роутинг по проезженному: 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 b817e18..59da995 100644 --- a/README.md +++ b/README.md @@ -24,6 +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) - так и пишет. - **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (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 new file mode 100644 index 0000000..f648790 --- /dev/null +++ b/docs/session-logs/2026-09-17_repeat-intervals.md @@ -0,0 +1,48 @@ +# Сессия 2026-09-17 - автоматический разбор повторов внутри поездки (интервалы) + +## Контекст +Заказ из `docs/gps-model-findings.md` (раздел «БЭКЛОГ: автоматический разбор повторов»). +Пользователь катает интервалы: один подъём 5 раз за поездку, один трек, без стоп/старт. +Нужно: повторы находятся сами по самоподобию, таблица заездов и число «последний/первый». +Действовали по утверждённому плану `~/.claude/plans/merry-discovering-dream.md` +(в docs/plans не копировал - план короткий и одноразовый; здесь ссылка). + +## Сделано +- `GPS.detectRepeats(points, opts)` (gps.js) - порт `tools/track/pers.py`, но эталонный + проход находится сам: + 1. сетка ~10м (формула из `wind_by_passes`/`heightmap.py`); + 2. горячие ячейки = >= minPasses (3) входов (заход = смена ключа ячейки); + 3. эталон = ОДИН чистый проход: от первой горячей ячейки, добавляя новые, до первого + возврата к более ранней (склейка кругов давала абсурд - см. баги); + 4. проходы = порт `passes()`: позиция в seq растёт от старта (допуск назад 3, гэп 25), + покрытие >= 80%; старт-гвард `pos<=15%` не даёт спуску открыть заезд; + 5. нормировка: сравнение по `L_seq / T_прохода` (снимает 5% разброса длины GPS). + Направление без явной проверки bearing: seq хранит первую (подъёмную) позицию ячейки, + спуск роняет позицию и закрывает заезд. +- `avgPower` в возврат `GPS.computeStats` (накопление `power*dt` в вело-ветке / время + педалирования) - для колонки «Вт». +- UI: секция-аккордеон «Разбор повторов» в `showSessionDetail` (только вело), таблица + (№/время/км-ч норм/каденс/Вт), вердикт последний/первый (зелёный >=100%, жёлтый >=90%, + красный <90%), подсветка участка на карте (бирюза `#00e5ff`). CSS в styles.css. + +## Баги по пути (и фиксы) +- Эталон как «самый длинный горячий ран» склеивал 5 кругов -> segLength 4406м и заезды по + 9с (норм. скорость 1762 км/ч). Фикс: эталон = один проход до первого возврата. +- Следующая точка в ТОЙ ЖЕ ячейке считалась «возвратом» и сразу обрывала seq (seq=1). + Фикс: `c===seq[last]` -> продолжить, не обрывать. + +## Проверка +- Node-харнесс на `tools/track/baldav.db` (реальные данные): + - 11 сен (3214 тчк): участок 1577м, **5 заездов**, норм. скорости 15.0/17.1/17.8/17.9/17.5, + последний/первый 117%. + - 17 сен (3079 тчк): участок 1630м, **5 заездов**, 16.9/15.8/17.3/16.4/17.7, 105%. + Совпадает с находками («11 и 17 сентября, по 5 повторов»). + - Контроль: 5 обычных вело-поездок (8-23 км) + 2 короткие -> «повторов не найдено» (0 ложных). +- Десктоп (Xvfb): временно подсажена сессия 17 сен в дев-БД (бэкап + точечное удаление + после, БД байт-в-байт восстановлена). Секция рисуется, аккордеон работает, таблица 5 строк + совпадает с харнессом, вердикт зелёный 105%, участок подсвечен бирюзой на карте. Скрин. +- Далее: сборка APK + проверка на телефоне (реальные интервалы там же). + +## Не в этой задаче +Из того же файла НЕ делалось: поправка модели п.2 (аддитивная мощность вместо max(физика,MET); +п.1 про Crr уже был сделан ранее), массовое уточнение высот, ветер (Часть 2 - там вопросы). diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 168b50c..a3edd9b 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1542,6 +1542,18 @@ body.dark-map .poi-badge { background: #111; border-color: #eee; } font-weight: 600; } +/* Repeat / interval analysis table + verdict */ +.repeats-head { font-size: 12px; color: var(--on-bg); opacity: 0.8; margin-bottom: 8px; } +.repeats-tbl { width: 100%; border-collapse: collapse; font-size: 13px; } +.repeats-tbl th, .repeats-tbl td { padding: 5px 6px; text-align: right; border-bottom: 1px solid var(--border); } +.repeats-tbl th:first-child, .repeats-tbl td:first-child { text-align: left; } +.repeats-tbl th { color: var(--primary); font-weight: 600; } +.repeats-verdict { margin-top: 10px; padding: 10px; border-radius: var(--radius); text-align: center; line-height: 1.4; } +.repeats-verdict .rv-num { font-size: 22px; font-weight: 700; } +.repeats-verdict.ok { background: rgba(46, 160, 67, 0.18); color: #4caf50; } +.repeats-verdict.warn { background: rgba(255, 179, 0, 0.16); color: #ffb300; } +.repeats-verdict.bad { background: rgba(207, 102, 121, 0.18); color: #cf6679; } + .sd-row { display: flex; gap: 8px; diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 7b91e7e..3031599 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -91,6 +91,88 @@ GPS.detectWalk = function(points, opts) { return out; }; +// Auto-detect repeated segments within ONE ride (interval training: the same climb +// ridden N times, one track, no stop/restart). The repeated corridor is found by +// self-similarity — no user marks. Ported from tools/track/pers.py (a pass = a run +// where position along a reference segment rises monotonically), but the reference +// segment is DISCOVERED from the ride itself (the longest corridor of cells the ride +// revisits), not given as road_points ids. +// Returns { found:false } or { found:true, segLength(m), passCount, passes:[{startIdx,endIdx}] }. +// Direction is handled without an explicit bearing check: seq keeps each cell's FIRST +// (uphill) position, so coasting back down makes position fall and ends the pass, and +// the "must start near seq beginning" guard stops a descent from opening a new pass. +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 ?? 3; // and ridden at least this many times + let minFrac = opts.minFrac ?? 0.8; // a pass must cover this fraction of the segment + let n = points ? points.length : 0; + if (n < 20) return { found: false }; + + // Grid cell key per point. + let key = new Array(n); + 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; + } + + // 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; + + // Reference segment = ONE clean traversal of the corridor: start at the first hot + // cell, add each new hot cell in order, and STOP at the first revisit (that means the + // rider turned back / started the next lap). Gluing several laps into the reference + // (a long hot run) inflates its length and scrambles the order — measured, gives + // absurd 9-second "passes" over a 4.4 km "segment". One traversal fixes both. + let s0 = 0; + while (s0 < n && !isHot(key[s0])) s0++; + if (s0 >= n) return { found: false }; + let seq = [], order = new Map(), s1 = s0; + for (let k = s0, 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]) { s1 = k; continue; } // still in the same cell + if (order.has(c)) break; // revisit of an earlier cell → first traversal complete + order.set(c, seq.length); seq.push(c); s1 = k; + } + let segLength = 0; + for (let k = s0 + 1; k <= s1; k++) + segLength += GPS.distance(points[k - 1].lat, points[k - 1].lon, points[k].lat, points[k].lon); + if (seq.length < 3 || segLength < 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)); + 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], gap = 0; + while (q < n - 1) { + q++; + if (pos[q] == null) { gap++; if (gap > 25) break; continue; } + if (pos[q] < hi - 3) break; // went backward — pass ended (e.g. coasting down) + gap = 0; hi = Math.max(hi, pos[q]); + } + if ((hi - lo) >= seq.length * minFrac) passes.push({ startIdx: p, endIdx: q }); + p = q + 1; + } + if (passes.length < minPasses) return { found: false }; + return { found: true, segLength: Math.round(segLength), passCount: passes.length, passes }; +}; + // One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes // "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives. // Only touch the value if it's still the old default; respect a user-set threshold. @@ -246,6 +328,9 @@ GPS.computeStats = function(points, weightKg, opts) { let totalKcal = 0; const basalPerMin = (1.0 * 3.5 * wKg) / 200; + // Mechanical work while pedalling, for avgPower output (used by the repeat/interval + // analysis to compare passes). Averaged over pedalling time, like tools/track pers.py. + let mechWorkJ = 0, powerSec = 0; // Cadence tracking let totalPedalRevs = 0; @@ -407,6 +492,7 @@ GPS.computeStats = function(points, weightKg, opts) { // 2. MET floor: Compendium cycling METs by speed, MINUS the 1 MET of rest // BMR already covers — the empirically measured cost. const physicsKcal = Math.max(0, power) * dt / efficiency / 4184; + mechWorkJ += Math.max(0, power) * dt; powerSec += dt; // avg mechanical power const vKmh = v * 3.6; if (vKmh >= 5) { const met = vKmh < 16 ? 6.0 : vKmh < 19 ? 6.8 : vKmh < 22 ? 8.0 : vKmh < 25 ? 10.0 : vKmh < 28 ? 12.0 : 14.0; @@ -474,6 +560,8 @@ GPS.computeStats = function(points, weightKg, opts) { const avgCadence = (hasGearData && pedalingSeconds > 0) ? Math.round(totalPedalRevs / (pedalingSeconds / 60)) : null; + // Average mechanical power (W) over pedalling time, null if never pedalled. + const avgPower = powerSec > 0 ? Math.round(mechWorkJ / powerSec) : null; // --------------------------------------------------------------------------- // Auto climb segments: detect climb (gradient > 2%), descent (< -2%), flat @@ -570,6 +658,7 @@ GPS.computeStats = function(points, weightKg, opts) { kcal: Math.round(totalKcal), duration: Math.round(duration), avgCadence, + avgPower, segments, walkBikeSec: Math.round(walkBikeSec), // time spent pushing the bike (single-speed, ≥30s runs) walkBikeMeters: Math.round(walkBikeDist), // distance walked pushing it (excluded from ride distance) diff --git a/frontend/js/main.js b/frontend/js/main.js index e55336d..90e00b4 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -5231,6 +5231,12 @@ async function showSessionDetail(sessionId) {
` : ""} + ${session.activity_type === 'bike' ? ` +
+ Разбор повторов +
Считаю...
+
` : ""} +