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.
This commit is contained in:
4aiman 2026-07-31 12:58:34 +03:00
commit cd975b5640
7 changed files with 72 additions and 31 deletions

View file

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

View file

@ -8,6 +8,7 @@
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.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 в продуктах, прогресс-бар, настраиваемая норма.

View file

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

View file

@ -4969,6 +4969,9 @@ async function renderSettings(container) {
</div>
<input id="s-use-fused" type="checkbox" ${GPS.config.useFusedLocation ? "checked" : ""} style="width:20px;height:20px;">
</div>
<div class="settings-item" id="s-gps-save" style="cursor:pointer;justify-content:center">
<div class="settings-item-title" style="color:var(--primary)">Сохранить настройки GPS</div>
</div>
</div>
<div class="settings-section">
@ -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"]]) {

2
src-tauri/Cargo.lock generated
View file

@ -139,7 +139,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.383"
version = "0.1.384"
dependencies = [
"base64 0.22.1",
"btleplug",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.383"
version = "0.1.384"
edition = "2021"
[lib]

View file

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