From cd975b56402c42f9570a1804f089343addc81ae2 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Fri, 31 Jul 2026 12:58:34 +0300 Subject: [PATCH] GPS distance: estimate blind-GPS gaps by speed*time; savable gap setting On stretches where the fix rate drops (dt > gapThresholdSec, incl. dead- reckoning) the straight chord mis-measures the path, so computeStats now estimates gap distance by avg(speed)*dt (capped at max speed; a tiny chord over a long gap means the rider was stopped and the reported speed is stale -> trust the position). A fast jump WITHOUT a time gap is a teleport glitch and is dropped. Distance now depends only on ts + speed, not on the `estimated` flag (which live points carry but DB rows don't), so the live counter, Stop and recompute finally agree. Verified on a real ride: at gapThreshold=30 -> 35.7 km / 1024 kcal (map-measured ~35 km); clean tracks without gaps unchanged. Settings: read GPS config on input+change (reliable on mobile), add a "Save GPS settings" button and a "saved" toast so it's clear the value persisted; changing gapThreshold + recompute now visibly applies. --- PROJECT_MAP.md | 10 +++++++++ README.md | 1 + frontend/js/gps.js | 40 ++++++++++++++++++++++++---------- frontend/js/main.js | 46 ++++++++++++++++++++++++--------------- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 7 files changed, 72 insertions(+), 31 deletions(-) diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 04b19fb..eef219a 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -186,6 +186,16 @@ Power = v * (m*g*sin(gradient) + Crr*m*g*cos(gradient) + 0.5*CdA*rho*v^2) Набор высоты: кумулятивный гистерезис (elevRef, порог 1м) — на плотных треках per-point разница <1м даёт 0 ``` +### Дистанция по сегментам (computeStats, gps.js ~189-235) +``` +segMaxMs = maxSpeedKmh/3.6 (вело) или walkMaxSpeedKmh/3.6 (пеш); gapThr = config.gapThresholdSec (30) +шип (dt<=gapThr И v>segMaxMs) → сегмент не считать (телепорт без разрыва по времени) +разрыв(dt>gapThr) → segDist = min(avg(speed)*dt, segMaxMs*dt); если хорда<20м → берём хорду (стоял, скорость залипла) +норма (иначе) → segDist = хорда (haversine) +distance += segDist; высота/maxSpeed — только на «норме»; ккал в разрыве/estimated = walk-MET по времени +Зависит только от ts+speed (не от флага estimated) → live == «Стоп» == пересчёт. gapThreshold настраивается, применяется при пересчёте. +``` + ### Ходьба/бег — MET-таблица ``` <4 км/ч = 2.5 MET, 4-5.5 = 3.5, 5.5-7 = 5.0, 7-9 = 8.0, 9-12 = 10.0, 12-16 = 12.5, 16+ = 15.0 diff --git a/README.md b/README.md index 31c4ca2..f16690b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points__s.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Импорт тайлов по регионам (SQLite, роутер LAN). - **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. - **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м). Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. +- **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. - **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду. - **Трекинг жидкости** — water_pct в продуктах, прогресс-бар, настраиваемая норма. diff --git a/frontend/js/gps.js b/frontend/js/gps.js index 983e113..a591d24 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -194,14 +194,33 @@ GPS.computeStats = function(points, weightKg, opts) { let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated; let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6); - // Count a REAL segment: not a dead-reckoning guess, not a teleport glitch. We do NOT - // drop long-gap segments here — if GPS was lost briefly, the straight line is a fair - // lower-bound of real travel (implied speed v<=segMaxMs already rejects teleports). - // DR fakes are excluded via !isEstimated, so no gap-time gate is needed. - let validSeg = !isEstimated && dt > 0 && v <= segMaxMs; + let gapThr = GPS.config.gapThresholdSec ?? 30; - if (validSeg) { - distance += d; + // Per-segment distance. Across a GPS gap (dt > gapThreshold — the fix rate + // dropped, or dead-reckoning) the straight chord under/over-counts, so estimate + // the path by reported speed x time, capped at the max speed. A tiny chord over + // a long gap means the rider was stopped and the reported speed is stale — trust + // the position. A fast jump WITHOUT a time gap (dt small, v > max) is a teleport + // glitch → drop it. The estimate depends on ts + speed only, so a track computes + // the same whether from live points (which carry an `estimated` flag) or DB rows + // (which don't) — live, Stop and recompute now agree. + let isSpike = dt > 0 && dt <= gapThr && v > segMaxMs; + let isGap = dt > gapThr; + let segDist; + if (isSpike) { + segDist = 0; + } else if (isGap) { + let avgV = ((sorted[i].speed_ms ?? 0) + (sorted[i - 1].speed_ms ?? 0)) / 2; + segDist = (d < 20) ? d : Math.min(avgV * dt, segMaxMs * dt); + } else { + segDist = d; + } + // A normal moving segment (continuous fix within the speed limit) drives the + // altitude and max-speed stats; gap and spike segments do not. + let normalMove = !isGap && !isSpike && dt > 0; + + if (segDist > 0) distance += segDist; + if (normalMove) { speeds.push(v); // Elevation: cumulative hysteresis against a running reference — NOT the diff // between adjacent points. On dense tracks (e.g. 5000 pts / 16 km) the per-point @@ -217,14 +236,13 @@ GPS.computeStats = function(points, weightKg, opts) { } // Kcal calculation per segment - if (isEstimated) { - // Estimated point (GPS dead, accel confirmed movement): use walking MET. - // Time-based only — no distance (dead-reckoning drift is not real travel). + if (isSpike) continue; + if (isGap || isEstimated) { + // GPS gap / dead-reckoning: time-based walking MET (no reliable physics here). let walkMet = 3.5; // moderate walking ~4-5 km/h totalKcal += (dt / 60) * (walkMet * 3.5 * wKg) / 200; continue; } - if (!validSeg) continue; if (v < 0.3) { // Standing still: only basal metabolic rate diff --git a/frontend/js/main.js b/frontend/js/main.js index 77d2fd8..c6b57aa 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4969,6 +4969,9 @@ async function renderSettings(container) { +
+
Сохранить настройки GPS
+
@@ -5198,27 +5201,36 @@ async function renderSettings(container) { // Roads map container.querySelector("#s-roads-map").onclick = () => showRoadsMap(); - // GPS config inputs + // GPS config inputs. Read every field into GPS.config and persist. On mobile a + // bare `onchange` (fires on blur) is easy to miss and gives no feedback, so we + // also bind `input`, and add an explicit "Save" button — both show a toast so + // it's obvious the value was saved. computeStats reads GPS.config live, so a + // recompute right after applies the new value. + let readGpsConfig = () => { + GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6; + GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value); + GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value); + GPS.config.maxSpeedKmh = parseFloat(container.querySelector("#s-max-speed").value) || 60; + GPS.config.gapThresholdSec = parseFloat(container.querySelector("#s-gap-threshold").value) || 30; + GPS.config.airDrag = parseFloat(container.querySelector("#s-air-drag").value) || 0.6; + GPS.config.airDensity = parseFloat(container.querySelector("#s-air-density").value) || 1.225; + GPS.config.pedalEfficiency = parseFloat(container.querySelector("#s-pedal-eff").value) || 25; + GPS.config.altSmoothHalf = parseInt(container.querySelector("#s-alt-smooth").value) || 5; + GPS.config.walkMaxSpeedKmh = parseFloat(container.querySelector("#s-walk-max-speed").value) || 12; + GPS.config.walkMinSpeedMs = parseFloat(container.querySelector("#s-walk-min-speed").value) / 3.6; + let fusedEl = container.querySelector("#s-use-fused"); + if (fusedEl) GPS.config.useFusedLocation = fusedEl.checked; + GPS.saveConfig(); + }; let gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed"]; for (let id of gpsInputs) { let el = container.querySelector("#" + id); - if (el) el.onchange = () => { - GPS.config.minSpeedMs = parseFloat(container.querySelector("#s-min-speed").value) / 3.6; - GPS.config.minAccuracyM = parseFloat(container.querySelector("#s-min-acc").value); - GPS.config.minDistanceM = parseFloat(container.querySelector("#s-min-dist").value); - GPS.config.maxSpeedKmh = parseFloat(container.querySelector("#s-max-speed").value) || 60; - GPS.config.gapThresholdSec = parseFloat(container.querySelector("#s-gap-threshold").value) || 30; - GPS.config.airDrag = parseFloat(container.querySelector("#s-air-drag").value) || 0.6; - GPS.config.airDensity = parseFloat(container.querySelector("#s-air-density").value) || 1.225; - GPS.config.pedalEfficiency = parseFloat(container.querySelector("#s-pedal-eff").value) || 25; - GPS.config.altSmoothHalf = parseInt(container.querySelector("#s-alt-smooth").value) || 5; - GPS.config.walkMaxSpeedKmh = parseFloat(container.querySelector("#s-walk-max-speed").value) || 12; - GPS.config.walkMinSpeedMs = parseFloat(container.querySelector("#s-walk-min-speed").value) / 3.6; - let fusedEl = container.querySelector("#s-use-fused"); - if (fusedEl) GPS.config.useFusedLocation = fusedEl.checked; - GPS.saveConfig(); - }; + if (el) { el.onchange = readGpsConfig; el.oninput = readGpsConfig; } } + let fusedEl = container.querySelector("#s-use-fused"); + if (fusedEl) fusedEl.onchange = () => { readGpsConfig(); showToast("Настройки GPS сохранены"); }; + let gpsSaveBtn = container.querySelector("#s-gps-save"); + if (gpsSaveBtn) gpsSaveBtn.onclick = () => { readGpsConfig(); showToast("Настройки GPS сохранены"); }; // HR/BP norms — save on change for (let [id, key] of [["s-hr-rest-min","hr_rest_min"],["s-hr-rest-max","hr_rest_max"],["s-hr-max","hr_max"],["s-bp-sys","bp_sys_norm"],["s-bp-dia","bp_dia_norm"]]) { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cfb9884..af1789f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "baldav" -version = "0.1.383" +version = "0.1.384" dependencies = [ "base64 0.22.1", "btleplug", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0e34770..0c1592c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "baldav" -version = "0.1.383" +version = "0.1.384" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 93403ac..f22883c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json", "productName": "BalDav", - "version": "0.1.383", + "version": "0.1.384", "identifier": "dev.baldav.app", "build": { "frontendDist": "../frontend"