Repeat/interval analysis: auto-find repeated segments in one ride, table + last/first verdict

This commit is contained in:
4aiman 2026-09-17 22:11:10 +03:00
commit 7717a73129
6 changed files with 203 additions and 0 deletions

View file

@ -151,6 +151,7 @@
- Рецепты: showRecipeDetailModal, showRecipeEditorModal, showAddIngredientModal
- Тренировки: showSessionDetail (карта, графики скорости/высоты, секция «Сегменты» - сворачиваемый `<details>`-аккордеон, свёрнут по умолчанию, чтобы кнопки внизу были доступны; пересчёт, оверлей маршрута, панель де-джиттера 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

View file

@ -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, до десятков штук) свёрнут в аккордеон (`<details>`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла.
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.

View file

@ -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 - там вопросы).

View file

@ -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;

View file

@ -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)

View file

@ -5231,6 +5231,12 @@ async function showSessionDetail(sessionId) {
</div>
</details>` : ""}
${session.activity_type === 'bike' ? `
<details class="sd-section sd-accordion" id="sd-repeats">
<summary class="sd-title sd-acc-title">Разбор повторов</summary>
<div id="sd-repeats-body" class="repeats-body">Считаю...</div>
</details>` : ""}
<div class="sd-section" id="sd-goals-section" style="display:none">
<div class="sd-title">Цели</div>
<div id="sd-goals-list"></div>
@ -5573,6 +5579,52 @@ async function showSessionDetail(sessionId) {
if (ws && wSec >= 60) ws.textContent = "пешком: " + Math.round(wSec / 60) + " мин · " + (wDist / 1000).toFixed(2) + " км";
}
// Interval / repeat analysis: the same segment ridden several times in one ride
// (intervals). Passes found by self-similarity (GPS.detectRepeats), per-pass stats
// via computeStats, plus the last/first verdict; the corridor is highlighted on the
// map. Length-normalised speed (segLength / pass time) cancels GPS length noise so
// passes compare fairly. Bike only.
if (session.activity_type === "bike") {
(async () => {
let body = document.querySelector("#sd-repeats-body");
if (!body) return;
let rep = GPS.detectRepeats(points);
if (!rep.found) { body.textContent = "Повторов не найдено (обычная поездка)."; return; }
let bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike();
let weightKg = state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80;
let opts = {
activityType: "bike", bikeWeightKg: bike?.weight_kg ?? 0, surfaceCrr: bike?.surface_type,
chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth,
wheelDiameterInch: bike?.wheel_diameter_inch, gearCount: bike?.gear_count,
};
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);
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);
let verdict, vClass;
if (ratio >= 1.0) { verdict = "последний не медленнее первого - можно повторить завтра"; vClass = "ok"; }
else if (ratio >= 0.90) { verdict = "нормальная тяжёлая тренировка"; vClass = "warn"; }
else { verdict = "просел больше 10% - два дня спокойно"; vClass = "bad"; }
let segStr = rep.segLength >= 1000 ? (rep.segLength / 1000).toFixed(2) + " км" : rep.segLength + " м";
body.innerHTML =
`<div class="repeats-head">Участок ${segStr}, заездов ${rep.passCount} (скорость нормирована на длину участка)</div>` +
`<table class="repeats-tbl"><thead><tr><th>№</th><th>время</th><th>км/ч</th><th>каденс</th><th>Вт</th></tr></thead><tbody>` +
rows.map(r => `<tr><td>${r.i}</td><td>${GPS.formatDuration(Math.round(r.T))}</td><td>${r.normV.toFixed(1)}</td><td>${r.cad ?? "-"}</td><td>${r.pw ?? "-"}</td></tr>`).join("") +
`</tbody></table>` +
`<div class="repeats-verdict ${vClass}"><span class="rv-num">${pct}%</span> последний / первый<br>${verdict}</div>`;
// Highlight the repeated corridor on the map (every pass draws the same line).
try {
for (let p of rep.passes) {
let seg = points.slice(p.startIdx, p.endIdx + 1).map(q => [q.lat, q.lon]);
if (seg.length >= 2) L.polyline(seg, { color: "#00e5ff", weight: 5, opacity: 0.85 }).addTo(map);
}
} catch {}
})();
}
// Invisible but tappable hit-targets on top of track
for (let i = 0; i < points.length; i++) {
let p = points[i];