diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index beec2d2..73679fc 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -103,7 +103,7 @@ | `activity_log` | Лог активности | date, type, minutes, kcal, activity_type (squats/jumprope/stretch/swim/manual), sets, reps, distance_m | | `supplements` | Добавки (БАД) | name, daily_dose_g, витамины/минералы | | `supplement_log` | Приём добавок | date, supplement_id, dose_g | -| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз) | +| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз), wind_marks (JSON меток «ветер в лицо») | | `gps_points` | Точки трека | session_id, ts, lat, lon, altitude_gps_m, altitude_srtm_m, speed_ms, seq, bearing_deg, accuracy_m, lat_smoothed, lon_smoothed (обратимый де-джиттер), heart_rate, spo2, bp_sys, bp_dia, accel_rms/max, gyro_pitch/yaw | | `routes` | Сохранённые маршруты (оверлей навигации) | name, color, points_json (`{lat,lon,ele?}`), created_at | | `bikes` | Велосипеды | name, weight_kg, wheel, звёзды, surface_type, is_active | @@ -223,6 +223,10 @@ distance += segDist; высота/maxSpeed — только на «норме»; подъём не склеивается с ездовым), усилие считается как ходьба (walk-MET нетто), не по физике. #7: исключается КАЖДАЯ такая точка без оглядки на длительность (краткие торможения тоже, безвредно ~0м); в отчёт как «пешком» идут только сплошные пробеги ≥30с. Возврат computeStats: walkBikeSec (время пуша ≥30с-пробегами), walkBikeMeters (пешая дистанция). opts.gearCount прокинут во все вызовы. +Метки ветра (разметка, НЕ расчёт): кнопка «Ветер в лицо» на экране записи — тап открывает интервал встречного ветра, + тап закрывает. GPS.toggleWindMark()/_closeOpenWindMark(); GPS.recording.windMarks=[{startTs,endTs}] мс, персист в localStorage, + запись в БД (DB.setSessionWindMarks) на финише + восстановление в recovery. Колонка gps_sessions.wind_marks (JSON). Это ground + truth для проверки оценки ветра по треку (tools/track/track.py, wind_by_passes) — на distance/kcal/power НЕ влияет. Голос — потом. ``` ### Ходьба/бег — MET-таблица diff --git a/README.md b/README.md index ce19f55..24db5ac 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - **Высоты маршрута (рельеф до поездки)** — экран предпросмотра маршрута (тап по названию): профиль высоты (крутые участки красным), длина/набор/сброс/макс. уклон, тап по точке — высота и уклон, разворот карты на весь экран. Топографическая обводка линии с тогглом **уклон/высота**: уклон дискретными полосами по 1% (зелёный→жёлтый→красный→коричневый→чёрный, ≥10% чёрный) или гипсометрия по высоте (зелёный→жёлтый→коричневый). Обводка и тоггл доступны и в оверлее на карте записанного трека и во время записи; маршрут рисуется ПОД треком. Высоты (`` из GPX или онлайн) тянутся по цепочке: **GPXZ** (Copernicus GLO-30, точнее для уклонов; ключ в GPS-настройках, хранится только на устройстве) → open-elevation → open-meteo → OpenTopoData (без ключа). Калории пересчитываются по сглаженным точкам; для уклона высоты сглаживаются (гасит вертикальный шум SRTM). - **Редактор маршрутов** — создание («Новый маршрут») и правка сохранённого маршрута прямо в приложении: тап по карте — добавить точку в конец, тап по линии — вставить точку в это место, тащить точку — двигать, тап по точке — удалить. Высоты подтягиваются онлайн после отрисовки (из предпросмотра). - **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает. +- **Метки ветра** — кнопка «Ветер в лицо» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее. - **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON). - **Вес, сон, добавки** — логирование с историей. diff --git a/docs/session-logs/2026-09-07_gps-model-findings-impl.md b/docs/session-logs/2026-09-07_gps-model-findings-impl.md index e56dfe0..1166631 100644 --- a/docs/session-logs/2026-09-07_gps-model-findings-impl.md +++ b/docs/session-logs/2026-09-07_gps-model-findings-impl.md @@ -23,12 +23,18 @@ - **#5/#6** — к сведению (пульс потерян браслет; ветер в track.py, не в приложении). ## Ветер (часть 2 доки) -Пользователь: калории от неточного ветра не считать. Хочет простые отслеживаемые вещи — голосовые -метки («ветер в лицо») и/или кнопки в UI (нажал когда дует в лицо, отжал когда перестал), писать -данные и делать выводы после набора километража. Это вариант 5 в роли РАЗМЕТКИ, не измерения. -Трубка Пито (вариант 3) — точно нет. Вариант 2 (предзагрузка прогноза) — пользователь не понял, -нужно объяснить. Реализация ветер-фичи — отдельным шагом (нужны решения по UX: триггер-слова, -место кнопки, схема хранения событий). +Пользователь: калории от неточного ветра не считать. Хочет простые отслеживаемые вещи — метки +«ветер в лицо», писать данные и делать выводы после набора километража. Это вариант 5 в роли +РАЗМЕТКИ, не измерения. Трубка Пито (вариант 3) — точно нет. Вариант 2 (предзагрузка прогноза) — +объяснил (скачать прогноз дома по проводу как подсказку-затравку для оценки по треку; изъян тот же, +что у «уточнить высоты» — надо не забыть нажать перед выездом). + +Решения по UX (AskUserQuestion): кнопка-тоггл, голос потом; только «в лицо» (бинарно). Реализовано: +- кнопка «Ветер в лицо» на экране записи (тап открыть интервал / тап закрыть, активная подсвечена); +- GPS.toggleWindMark()/_closeOpenWindMark(); windMarks=[{startTs,endTs}] мс, персист в localStorage, + запись DB.setSessionWindMarks на финише, восстановление в recovery (недозакрытый интервал закрывается); +- колонка gps_sessions.wind_marks (JSON), парсинг в getGpsSession. На distance/kcal/power НЕ влияет. +- Анализ (сравнение меток с оценкой ветра по треку) — позже в tools/track/track.py, после километража. ## Результат Модельные правки #1/#3/#7 внедрены, #2/#4 задокументированы. `node --check` чист по всем файлам. @@ -36,5 +42,6 @@ ## Следующие шаги - Собрать/поставить новую сборку, проверить: на грунтах ккал выше; пеший подъём на односкоростном - не в ездовой статистике и не склеивается с ездовым подъёмом. -- Ветер: спроектировать и внедрить разметку событий (голос/кнопки). + не в ездовой статистике и не склеивается с ездовым подъёмом; кнопка «Ветер в лицо» пишет + интервалы в gps_sessions.wind_marks. +- Ветер: добавить голосовые метки (отдельным шагом); анализ меток vs оценка по треку в track.py. diff --git a/frontend/css/styles.css b/frontend/css/styles.css index 89b5c49..14faeec 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1262,6 +1262,7 @@ select { .rec-bottom { display: flex; align-items: center; + flex-wrap: wrap; gap: 12px; padding: 8px 12px; padding-bottom: calc(8px + var(--safe-area-bottom)); diff --git a/frontend/js/db.js b/frontend/js/db.js index f337150..7968408 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -851,9 +851,17 @@ async function getGpsSession(id) { if (!rows.length) return null; const s = rows[0]; if (typeof s.pauses === "string") { try { s.pauses = JSON.parse(s.pauses); } catch { s.pauses = []; } } + if (typeof s.wind_marks === "string") { try { s.wind_marks = JSON.parse(s.wind_marks); } catch { s.wind_marks = []; } } return s; } +// Save headwind labels for a session (JSON). Kept separate from finishGpsSession so +// recompute paths preserve marks (they never call this). Marks are ground truth for +// the wind estimate — they do not affect distance/kcal/power. +async function setSessionWindMarks(id, marks) { + await execute("UPDATE gps_sessions SET wind_marks=?1 WHERE id=?2", [JSON.stringify(marks || []), id]); +} + async function getGpsPoints(sessionId) { return select("SELECT * FROM gps_points WHERE session_id = ?1 ORDER BY COALESCE(seq, 999999), ts ASC", [sessionId]); } @@ -1196,6 +1204,7 @@ window.DB.createGpsSession = createGpsSession; window.DB.addGpsPoint = addGpsPoint; window.DB.addGpsPointsBulk = addGpsPointsBulk; window.DB.finishGpsSession = finishGpsSession; +window.DB.setSessionWindMarks = setSessionWindMarks; window.DB.saveSmoothedTrack = saveSmoothedTrack; window.DB.setSessionSmoothed = setSessionSmoothed; window.DB.getGpsSessions = getGpsSessions; @@ -1538,6 +1547,7 @@ async function runMigrations() { if (!gsCols.includes("smooth_window")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_window INTEGER"); if (!gsCols.includes("smooth_iters")) await execute("ALTER TABLE gps_sessions ADD COLUMN smooth_iters INTEGER"); if (!gsCols.includes("pauses")) await execute("ALTER TABLE gps_sessions ADD COLUMN pauses TEXT"); // JSON [{startTs,endTs,...}] manual pauses + if (!gsCols.includes("wind_marks")) await execute("ALTER TABLE gps_sessions ADD COLUMN wind_marks TEXT"); // JSON [{startTs,endTs}] headwind labels (ground truth for wind estimate) // Add tire_pressure_bar to bikes (for Crr correction by tire pressure) const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name); diff --git a/frontend/js/gps.js b/frontend/js/gps.js index dda7441..c1c1c90 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -962,6 +962,32 @@ GPS._processIncomingPoint = async function(p, fromNative) { if (GPS.recording.onPoint) GPS.recording.onPoint(p); }; +// Headwind labelling (variant 5 as VALIDATION, not measurement): the rider taps +// to open a "wind in the face" interval and taps again to close it. Marks are +// stored per session ([{startTs, endTs}] ms epoch) and later compared against the +// from-track wind estimate — they are ground truth, they do NOT feed kcal/power. +// Returns true if a headwind interval is now open (button should show active). +GPS.toggleWindMark = function() { + if (!GPS.recording.windMarks) GPS.recording.windMarks = []; + if (GPS.recording._currentWind) { + GPS.recording._currentWind.endTs = Date.now(); + GPS.recording._currentWind = null; + } else { + GPS.recording._currentWind = { startTs: Date.now(), endTs: null }; + GPS.recording.windMarks.push(GPS.recording._currentWind); + } + GPS._persistState(); + return GPS.recording._currentWind != null; +}; + +// Close an interval left open (rider forgot to tap off before Stop / recovery). +GPS._closeOpenWindMark = function() { + if (GPS.recording._currentWind && GPS.recording._currentWind.endTs == null) { + GPS.recording._currentWind.endTs = Date.now(); + } + GPS.recording._currentWind = null; +}; + // Persist recording state to localStorage for crash recovery GPS._persistState = function() { try { @@ -973,6 +999,7 @@ GPS._persistState = function() { totalPauseMs: GPS.recording.totalPauseMs, manualPaused: GPS.recording.manualPaused, pauses: GPS.recording.pauses || [], + windMarks: GPS.recording.windMarks || [], pointCount: GPS.recording.points.length, bikeId: GPS.recording.bikeId ?? null, bikeWeightKg: GPS.recording.bikeWeightKg ?? null, @@ -1071,6 +1098,11 @@ GPS.recoverSession = async function() { const stats = GPS.computeStats(pts, s.weightKg || 80, recoverOpts); await DB.finishGpsSession(s.sessionId, new Date().toTimeString().slice(0, 5), stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, s.pauses || []); + // Restore headwind marks captured before the crash (close a dangling open one) + if (Array.isArray(s.windMarks) && s.windMarks.length) { + for (const w of s.windMarks) if (w && w.endTs == null) w.endTs = new Date(pts[pts.length - 1].ts).getTime(); + try { await DB.setSessionWindMarks(s.sessionId, s.windMarks); } catch (e) { console.error("[GPS] wind marks restore failed:", e); } + } // Store bike_id if present if (s.bikeId) await DB.updateSessionBike(s.sessionId, s.bikeId); console.log("[GPS] Recovered session", s.sessionId, "with", pts.length, "points"); @@ -1104,6 +1136,8 @@ GPS.startRecording = async function(activityType, weightKg) { GPS.recording.manualPaused = false; GPS.recording.pauses = []; GPS.recording._currentPause = null; + GPS.recording.windMarks = []; // headwind labels: [{startTs, endTs}] ms epoch + GPS.recording._currentWind = null; // open headwind interval, if any GPS.recording.nativeService = false; GPS.recording._pollTimer = null; GPS.recording._lastProcessedTs = 0; @@ -1253,12 +1287,17 @@ GPS.stopRecording = async function() { const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, recOpts); const endTime = new Date().toTimeString().slice(0, 5); + // Close an open headwind interval and persist all marks with the session. + GPS._closeOpenWindMark(); + const recWindMarks = GPS.recording.windMarks || []; + try { await DB.finishGpsSession( GPS.recording.sessionId, endTime, stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, recPauses ); + try { await DB.setSessionWindMarks(GPS.recording.sessionId, recWindMarks); } catch (e) { console.error("[GPS] wind marks save failed:", e); } // DB save confirmed — now safe to delete the points file if (nativePointsFile) { try { await window.__TAURI__.core.invoke("delete_points_file", { filename: nativePointsFile }); } catch {} @@ -1287,6 +1326,8 @@ GPS.stopRecording = async function() { GPS.recording.cogTeeth = null; GPS.recording.wheelDiameterInch = null; GPS.recording.gearCount = null; + GPS.recording.windMarks = []; + GPS.recording._currentWind = null; GPS._clearPersistedState(); return { sessionId: sid, stats, pauses }; }; diff --git a/frontend/js/main.js b/frontend/js/main.js index 3574909..ecd2bb7 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -3841,6 +3841,7 @@ async function renderRecordingInline(container) {
${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}
+
@@ -4289,6 +4290,15 @@ async function renderRecordingInline(container) { fetchTemperature(); let weatherTimer = setInterval(fetchTemperature, 15 * 60 * 1000); + // Headwind label toggle: tap to open a "wind in the face" interval, tap to close. + // Marks are ground truth for validating the from-track wind estimate — not kcal. + let windBtn = container.querySelector("#rec-wind"); + if (windBtn) windBtn.onclick = () => { + let active = GPS.toggleWindMark(); + windBtn.style.background = active ? "#ffb74d" : ""; + windBtn.style.color = active ? "#1a1a1a" : ""; + }; + // Pause button container.querySelector("#rec-pause").onclick = () => { if (GPS.recording.manualPaused) {