Wind: "headwind in the face" label button on the recording screen

Variant 5 from the findings doc, as GROUND TRUTH for validating the
from-track wind estimate — it does NOT feed kcal/power. Chosen scope
(per user): a toggle button now, binary "in the face" only, voice later.

- rec screen button "Ветер в лицо": tap opens a headwind interval, tap
  closes it; the open state is highlighted and survives a screen re-render
- GPS.toggleWindMark()/_closeOpenWindMark(); recording.windMarks =
  [{startTs,endTs}] ms, persisted to localStorage, written to the DB on
  finish and restored on crash recovery (a dangling interval auto-closes)
- gps_sessions.wind_marks (JSON) + DB.setSessionWindMarks (separate from
  finishGpsSession so recompute paths preserve marks); parsed in getGpsSession
- rec-bottom wraps now so the third button never overflows a narrow screen

Analysis (marks vs estimate) stays in tools/track once enough km accumulate.
This commit is contained in:
4aiman 2026-09-07 17:10:16 +03:00
commit 1b10aa67de
7 changed files with 83 additions and 9 deletions

View file

@ -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-таблица

View file

@ -19,6 +19,7 @@
- **Высоты маршрута (рельеф до поездки)** — экран предпросмотра маршрута (тап по названию): профиль высоты (крутые участки красным), длина/набор/сброс/макс. уклон, тап по точке — высота и уклон, разворот карты на весь экран. Топографическая обводка линии с тогглом **уклон/высота**: уклон дискретными полосами по 1% (зелёный→жёлтый→красный→коричневый→чёрный, ≥10% чёрный) или гипсометрия по высоте (зелёный→жёлтый→коричневый). Обводка и тоггл доступны и в оверлее на карте записанного трека и во время записи; маршрут рисуется ПОД треком. Высоты (`<ele>` из GPX или онлайн) тянутся по цепочке: **GPXZ** (Copernicus GLO-30, точнее для уклонов; ключ в GPS-настройках, хранится только на устройстве) → open-elevation → open-meteo → OpenTopoData (без ключа). Калории пересчитываются по сглаженным точкам; для уклона высоты сглаживаются (гасит вертикальный шум SRTM).
- **Редактор маршрутов** — создание («Новый маршрут») и правка сохранённого маршрута прямо в приложении: тап по карте — добавить точку в конец, тап по линии — вставить точку в это место, тащить точку — двигать, тап по точке — удалить. Высоты подтягиваются онлайн после отрисовки (из предпросмотра).
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
- **Метки ветра** — кнопка «Ветер в лицо» на экране записи: тап открывает интервал встречного ветра, тап закрывает. Метки хранятся с треком (`wind_marks`) как эталон для проверки оценки ветра по треку; на расчёт калорий/мощности не влияют. Голосовые метки — на будущее.
- **Экспорт/импорт** — свои продукты, рецепты и добавки одним файлом (JSON).
- **Вес, сон, добавки** — логирование с историей.

View file

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

View file

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

View file

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

View file

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

View file

@ -3841,6 +3841,7 @@ async function renderRecordingInline(container) {
</div>
<div class="rec-bottom">
<div class="rec-status" id="rec-status">${GPS.recording.state === "waiting_gps" ? "Поиск GPS..." : GPS.recording.state === "ready" ? "Начните движение..." : ""}</div>
<button id="rec-wind" class="btn-secondary"${GPS.recording._currentWind ? ' style="background:#ffb74d;color:#1a1a1a"' : ""}>Ветер в лицо</button>
<button id="rec-pause" class="btn-secondary">${GPS.recording.manualPaused ? "Продолжить" : "Пауза"}</button>
<button id="rec-stop" class="btn-primary gps-rec recording">Стоп</button>
</div>
@ -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) {