Repeat analysis: project onto the reference line (lateral drift no longer truncates a rep); pass time counts moving seconds only

This commit is contained in:
4aiman 2026-09-17 23:46:32 +03:00
commit 2ffaf1581d
5 changed files with 73 additions and 24 deletions

View file

@ -151,7 +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`)
- Разбор повторов: `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

View file

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

View file

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

View file

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

View file

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