diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index e198b9e..e0883a2 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 (де-джиттер) | +| `gps_sessions` | GPS тренировки | date, activity_type, distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (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 | @@ -198,6 +198,13 @@ segMaxMs = maxSpeedKmh/3.6 (вело) или walkMaxSpeedKmh/3.6 (пеш); gapTh норма (иначе) → segDist = хорда (haversine) distance += segDist; высота/maxSpeed — только на «норме»; ккал в разрыве/estimated = walk-MET по времени Зависит только от ts+speed (не от флага estimated) → live == «Стоп» == пересчёт. gapThreshold настраивается, применяется при пересчёте. + +Ручная пауза (opts.pauses = [{startTs,endTs}] мс): сегмент, пересекающий паузу (prevMsstartTs), + исключается ЦЕЛИКОМ — ни дистанции, ни высоты, ни ккал, ни времени (pausedSec вычитается из duration), elevRef=null + (перепад, набранный пешком за паузу, не банкуется на первом сегменте после резюма). Авто-GPS-гэп (без паузы) — считается как раньше. +Глюк-гард высоты: соседний сглаженный перепад с грейдом > maxRealGradePct (1.0 = 100% = 45°) не банкуется и не двигает elevRef + (скачок/ступенька, в т.ч. Android no-fix 0.0). Плавный приход к любому значению (реальный 0 у реки) проходит. + Kotlin пишет "alt":null без фикса высоты (hasAltitude()), честный 0 с фиксом сохраняется. Паузы: колонка gps_sessions.pauses (JSON). ``` ### Ходьба/бег — MET-таблица diff --git a/docs/plans/2026-09-07-manual-pauses-altitude-glitches.md b/docs/plans/2026-09-07-manual-pauses-altitude-glitches.md new file mode 100644 index 0000000..b5e7356 --- /dev/null +++ b/docs/plans/2026-09-07-manual-pauses-altitude-glitches.md @@ -0,0 +1,56 @@ +# План: ручные паузы и глюки высоты в расчёте статистики трека + +## Context (зачем) +Две ошибки в `GPS.computeStats` (frontend/js/gps.js): +1. **Ручная пауза считается как езда.** Пользователь внизу подъёма ставит паузу руками, + идёт пешком, наверху снимает. `computeStats` через гэп всё равно считает дистанцию + по позиции, а `elevRef` переживает гэп → первый сегмент после резюма сравнивается с + высотой ДО паузы → перепад подъёма утекает в elevGain. Нужно: **ручную паузу + исключать** (ни дистанции, ни перепада, ни времени, ни ккал), а **автоматический + GPS-гэп в движении — считать как сейчас** (интерполяция). +2. **Глюки высоты, включая честный 0.** Android при отсутствии фикса высоты пишет + `location.altitude=0.0` → в данных 146 точек `altitude_gps_m=0`, которые как «реальная + высота» дают ложные перепады. НО плавный приход к 0 у реки — реальная высота + (пользователь часто у рек). Правило: отбрасывать **нереальный уклон** (скачок / + грейд >45° = >100%), а плавные значения (в т.ч. 0) — оставлять. + +Решения (по ответам): **только для НОВЫХ треков** (ручного «разрезания» старых не делаем). +Паузы отслеживаются в `GPS.recording.pauses` (`[{startTs,endTs,startLat,...}]`, мс epoch), +но в БД НЕ сохранялись — начали сохранять. Данные разбираются офлайн через `tools/track/`. + +## Изменения (финал, как реализовано) +1. **Persist пауз (БД).** Колонка `pauses TEXT` в `gps_sessions` тем же JS-путём ADD COLUMN + под `PRAGMA table_info(gps_sessions)`-гардом (db.js ~1540). `finishGpsSession(...pauses)` + получил аргумент `pauses`: `hasPauses = pauses !== undefined` → пишет `JSON.stringify(pauses||[])`, + `undefined` оставляет колонку как есть. `getGpsSession` парсит `pauses` из JSON. +2. **`computeStats(points, wKg, opts)` — исключение ручных пауз.** `opts.pauses` (мс epoch) → + `inManualPause(aMs,bMs) = pauses.some(z => aMsz.s)`. В сегментном цикле: + `if (inManualPause(prevMs,pMs)) { pausedSec += dt; elevRef = null; continue; }` — целиком + исключает сегмент (дистанция/скорость/высота/ккал), сбрасывает elevRef. `duration -= pausedSec`. +3. **Фильтр высоты по уклону.** В банковании перепада: `glitch = prevAlt!=null && segDist>0 && + |smoothedAlt[i]-prevAlt|/segDist > MAX_GRADE` (`MAX_GRADE = config.maxRealGradePct ?? 1.0` + = 100% = 45°). При glitch — не банковать и не двигать elevRef. Плавные изменения (0 у реки) проходят. + Существующие фильтры (выброс >4м, «застревание», сглаживание) оставлены. +4. **Запись высоты (Kotlin `GpsRecorderService.kt` ~465).** `altStr = if (location.hasAltitude()) + location.altitude.toString() else "null"` → `"alt":${altStr}`. Нет фикса → null (не 0). Идёт вперёд. +5. **Проброс пауз через вызовы `computeStats`/`finishGpsSession`:** live-стоп (recPauses), + `recalculateSession` recorded-ветка (sessPauses), recovery (`s.pauses||[]`), + `recomputeAllStats` bulk (bulkPauses из session.pauses), `showSessionDetail` (main.js: + `{pauses: session.pauses||[]}` + вычет pauseMs из duration). + Ручная-маршрут ветка recalculateSession паузы не передаёт (у нарисованных маршрутов пауз нет). + +## Файлы +- `frontend/js/gps.js` — computeStats (паузы + фильтр уклона), проброс в finish/recalculate/recovery/stop/bulk. +- `frontend/js/db.js` — миграция `pauses`, finishGpsSession, getGpsSession. +- `frontend/js/main.js` — showSessionDetail: передать `session.pauses`, вычесть pauseMs. +- `src-tauri/gen/android/app/src/main/java/dev/baldav/app/GpsRecorderService.kt` — alt=null без фикса. + +## Верификация +1. `node --check` на трёх JS — чисто. +2. `./build.sh -d -i android` → короткая поездка с ручной паузой на склоне → в деталях трека + участок паузы не учтён (нет дистанции/перепада), в БД `pauses` = JSON; треки с 0-глюками не раздуты, + плавный 0 у реки — профиль цел. + +## Не трогаем +- Ручной инструмент «выделить/исключить участок» — отложено. +- Старые треки ретроактивно не правим (паузы не были сохранены). Seed/продукты не трогаем. diff --git a/docs/session-logs/2026-09-07_manual-pauses-altitude-glitches.md b/docs/session-logs/2026-09-07_manual-pauses-altitude-glitches.md new file mode 100644 index 0000000..5184ecd --- /dev/null +++ b/docs/session-logs/2026-09-07_manual-pauses-altitude-glitches.md @@ -0,0 +1,28 @@ +# Сессия 2026-09-07: ручные паузы и глюки высоты в статистике трека + +## Контекст +На последнем ~56км-треке подъём пройден пешком: внизу пауза руками, наверху снята. BalDav +мостил GPS-гэп (дистанция + перепад высоты через elevRef, переживший гэп) как езду. Плюс +Android пишет `altitude=0.0` без фикса высоты (146 точек в данных), что как «реальная высота» +раздувает набор высоты. Пользователь часто у рек, где 0 может быть честным — нельзя тупо +выкидывать 0. Правило: исключать нереальный уклон (>45°/>100%), плавные значения (в т.ч. 0) хранить. + +## Действия (по плану docs/plans/2026-09-07-manual-pauses-altitude-glitches.md) +Работали по утверждённому плану. Реализовано: +- db.js: колонка `gps_sessions.pauses TEXT` (JS ADD COLUMN миграция), `finishGpsSession(...,pauses)` + с `hasPauses`-гардом (undefined = не трогать колонку), `getGpsSession` парсит JSON. +- gps.js `computeStats`: `opts.pauses` → `inManualPause`; сегмент, пересекающий паузу, + исключается целиком (дистанция/скорость/высота/ккал), `elevRef=null`, `pausedSec` вычитается + из duration. Глюк-гард высоты: соседний перепад с грейдом > `maxRealGradePct` (1.0=45°) не + банкуется и не двигает elevRef. Проброс пауз во все вызовы: live-стоп, recalculateSession + (recorded), recovery, recomputeAllStats (bulk), showSessionDetail. +- main.js showSessionDetail: передаёт `session.pauses`, вычитает pauseMs из длительности. +- Kotlin GpsRecorderService: `alt=null` без `hasAltitude()`, честный 0 с фиксом сохраняется. + +## Результат +Только для НОВЫХ треков (старые паузы не сохранялись — ретро-фикса нет; UI ручного разрезания +отложен). Все 3 JS проходят `node --check`. PROJECT_MAP обновлён (computeStats-раздел + колонка pauses). + +## Следующие шаги +- `./build.sh -d -i android`, записать короткую поездку с ручной паузой на склоне, проверить: + участок паузы не в статистике, `pauses` в БД = JSON, треки с 0-глюками не раздуты. diff --git a/frontend/js/db.js b/frontend/js/db.js index 8fbce5d..f337150 100644 --- a/frontend/js/db.js +++ b/frontend/js/db.js @@ -779,14 +779,19 @@ async function addGpsPointsBulk(sessionId, points, startSeq) { return inserted; } -async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss) { +async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, pauses) { // Guard against NaN — SQLite may reject or corrupt NaN values const safe = v => (v != null && !isNaN(v)) ? v : null; + // pauses: array of manual-pause intervals; store as JSON. undefined = leave column as-is. + const hasPauses = pauses !== undefined; + const pausesJson = hasPauses ? JSON.stringify(pauses || []) : null; await execute( `UPDATE gps_sessions SET end_time=?1, total_distance_m=?2, total_kcal=?3, - avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7 + avg_speed_kmh=?4, max_speed_kmh=?5, elevation_gain_m=?6, elevation_loss_m=?7${hasPauses ? ", pauses=?9" : ""} WHERE id=?8`, - [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id] + hasPauses + ? [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id, pausesJson] + : [endTime, safe(distM), safe(kcal), safe(avgSpd), safe(maxSpd), safe(elevGain), safe(elevLoss), id] ); } @@ -843,7 +848,10 @@ async function getGpsSessionsForDate(date) { async function getGpsSession(id) { const rows = await select("SELECT *, CAST(id AS TEXT) AS id_str FROM gps_sessions WHERE id = ?1", [id]); - return rows.length ? rows[0] : null; + if (!rows.length) return null; + const s = rows[0]; + if (typeof s.pauses === "string") { try { s.pauses = JSON.parse(s.pauses); } catch { s.pauses = []; } } + return s; } async function getGpsPoints(sessionId) { @@ -1529,6 +1537,7 @@ async function runMigrations() { if (!gsCols.includes("smoothed")) await execute("ALTER TABLE gps_sessions ADD COLUMN smoothed INTEGER DEFAULT 0"); 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 // 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 e21d4bd..aeed8f6 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -111,6 +111,16 @@ GPS.computeStats = function(points, weightKg, opts) { let elevRef = null; // running reference for cumulative elevation hysteresis const speeds = []; // m/s per segment + // Manual pauses (ms epoch): the rider hand-paused, so that stretch must NOT count + // (walked, not ridden) — unlike an automatic GPS gap in motion, which we keep. + // A segment that touches a pause interval is dropped and elevRef is reset (so the + // elevation delta across the pause is not banked as a climb). + const manualPauses = (opts && opts.pauses ? opts.pauses : []) + .map(z => ({ s: z.startTs, e: z.endTs })).filter(z => z.s != null && z.e != null); + const inManualPause = (aMs, bMs) => manualPauses.some(z => aMs < z.e && bMs > z.s); + const MAX_GRADE = GPS.config.maxRealGradePct ?? 1.0; // 1.0 = 100% = 45°; steeper adjacent alt step = glitch + let pausedSec = 0; + // 5-point moving average on altitude to kill GPS jitter (typically 5-15m noise) // Step 1: Extract raw altitudes and filter outliers. // An outlier is a point that jumps far from its neighbors and returns back — @@ -203,6 +213,11 @@ GPS.computeStats = function(points, weightKg, opts) { let dt = (new Date(p.ts) - new Date(prev.ts)) / 1000; let v = dt > 0 ? d / dt : 0; // m/s + // Manual pause: exclude entirely (walked, not ridden). Reset elevRef so the + // elevation gained during the pause isn't banked on the next real segment. + let prevMs = new Date(prev.ts).getTime(), pMs = new Date(p.ts).getTime(); + if (inManualPause(prevMs, pMs)) { pausedSec += Math.max(0, dt); elevRef = null; continue; } + let isEstimated = (sorted[i].accuracy >= 999) || sorted[i].estimated; let segMaxMs = isBike ? (GPS.config.maxSpeedKmh / 3.6) : (GPS.config.walkMaxSpeedKmh / 3.6); let gapThr = GPS.config.gapThresholdSec ?? 30; @@ -239,10 +254,17 @@ GPS.computeStats = function(points, weightKg, opts) { // climbs. Accumulate the rise/fall from the reference and only bank it (and // advance the reference) once it clears the 1 m dead zone. if (smoothedAlt[i] != null) { - if (elevRef == null) elevRef = smoothedAlt[i]; - let rise = smoothedAlt[i] - elevRef; - if (rise > 1) { elevGain += rise; elevRef = smoothedAlt[i]; } - else if (rise < -1) { elevLoss += -rise; elevRef = smoothedAlt[i]; } + // Reject a physically impossible adjacent altitude step (glitch — incl. an + // Android no-fix 0.0): grade over the segment steeper than MAX_GRADE (45°). + // A smooth change to any value (e.g. a real 0 near a river) passes. + let prevAlt = smoothedAlt[i - 1]; + let glitch = prevAlt != null && segDist > 0 && Math.abs(smoothedAlt[i] - prevAlt) / segDist > MAX_GRADE; + if (!glitch) { + if (elevRef == null) elevRef = smoothedAlt[i]; + let rise = smoothedAlt[i] - elevRef; + if (rise > 1) { elevGain += rise; elevRef = smoothedAlt[i]; } + else if (rise < -1) { elevLoss += -rise; elevRef = smoothedAlt[i]; } + } } } @@ -353,7 +375,7 @@ GPS.computeStats = function(points, weightKg, opts) { const first = new Date(sorted[0].ts); const last = new Date(sorted[sorted.length - 1].ts); - const duration = (last - first) / 1000; + const duration = Math.max(0, (last - first) / 1000 - pausedSec); // exclude manual-pause time const avgSpeed = duration > 0 ? (distance / duration) : 0; const avgSpeedKmh = avgSpeed * 3.6; const maxSpeedKmh = maxSpeed * 3.6; @@ -991,10 +1013,11 @@ GPS.recoverSession = async function() { chainringTeeth: s.chainringTeeth, cogTeeth: s.cogTeeth, wheelDiameterInch: s.wheelDiameterInch, + pauses: s.pauses || [], }; 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); + stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, s.pauses || []); // 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"); @@ -1171,6 +1194,8 @@ GPS.stopRecording = async function() { cogTeeth: GPS.recording.cogTeeth, wheelDiameterInch: GPS.recording.wheelDiameterInch, }; + const recPauses = GPS.recording.pauses || []; + recOpts.pauses = recPauses; const stats = GPS.computeStats(GPS.recording.points, GPS.recording.weightKg, recOpts); const endTime = new Date().toTimeString().slice(0, 5); @@ -1178,7 +1203,7 @@ GPS.stopRecording = async function() { await DB.finishGpsSession( GPS.recording.sessionId, endTime, stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, - stats.elevGain, stats.elevLoss + stats.elevGain, stats.elevLoss, recPauses ); // DB save confirmed — now safe to delete the points file if (nativePointsFile) { @@ -1710,6 +1735,8 @@ GPS.recalculateSession = async function(sessionId, opts) { const CRR_MAP = { asphalt: 0.004, mixed: 0.006, gravel: 0.008 }; // Use cleaned sorted array, not original points + const sessPauses = Array.isArray(session.pauses) ? session.pauses + : (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []); const stats = GPS.computeStats(sorted, weightKg, { bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", @@ -1718,13 +1745,14 @@ GPS.recalculateSession = async function(sessionId, opts) { chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth, wheelDiameterInch: bike?.wheel_diameter_inch, + pauses: sessPauses, }); - // Update session in DB + // Update session in DB (preserve pauses) await DB.finishGpsSession(sessionId, session.end_time ?? session.start_time, stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, - stats.elevGain, stats.elevLoss + stats.elevGain, stats.elevLoss, sessPauses ); return stats; @@ -1760,6 +1788,8 @@ GPS.recomputeAllStats = async function(onProgress) { const points = await DB.getGpsPoints(sid); if (points.length < 2) { skipped++; continue; } const bike = session.bike_id ? await DB.getBike(session.bike_id) : await DB.getActiveBike(); + const bulkPauses = Array.isArray(session.pauses) ? session.pauses + : (typeof session.pauses === "string" ? (() => { try { return JSON.parse(session.pauses); } catch { return []; } })() : []); const stats = GPS.computeStats(points, weightKg, { bikeWeightKg: bike?.weight_kg ?? 0, activityType: session.activity_type ?? "bike", @@ -1768,9 +1798,10 @@ GPS.recomputeAllStats = async function(onProgress) { chainringTeeth: bike?.chainring_teeth, cogTeeth: bike?.cog_teeth, wheelDiameterInch: bike?.wheel_diameter_inch, + pauses: bulkPauses, }); await DB.finishGpsSession(sid, session.end_time ?? session.start_time, - stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss); + stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, bulkPauses); updated++; } catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; } } diff --git a/frontend/js/main.js b/frontend/js/main.js index 0b6fdb3..42e5ace 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -4541,14 +4541,16 @@ async function showSessionDetail(sessionId) { elevGain: Math.round(session.elevation_gain_m || 0), elevLoss: Math.round(session.elevation_loss_m || 0), duration: 0, - } : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80); - // Compute duration from points regardless + } : GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); + // Manual-pause total (ms) to exclude from displayed duration + let pauseMs = (session.pauses || []).reduce((a, z) => a + Math.max(0, (z.endTs || 0) - (z.startTs || 0)), 0); + // Compute duration from points regardless (minus manual pauses) if (points.length >= 2) { - stats.duration = Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000); + stats.duration = Math.max(0, Math.round((new Date(points[points.length-1].ts) - new Date(points[0].ts)) / 1000 - pauseMs / 1000)); } // Always compute segments from points (needed for the Segments section) if (!stats.segments && points.length >= 3) { - let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80); + let fullStats = GPS.computeStats(points, state.latestWeight?.weight_kg ?? state.profile?.weight_kg ?? 80, { pauses: session.pauses || [] }); stats.segments = fullStats.segments; } @@ -4600,6 +4602,7 @@ async function showSessionDetail(sessionId) { showModal(`

${actName}

+
ID: ${session.id}
Время
@@ -5158,6 +5161,10 @@ async function showSessionDetail(sessionId) { let exp = document.querySelector("#sd-export"); if (exp) exp.onclick = () => GPS.exportGPX(sessionId); + // Track ID — click to copy to clipboard + let idEl = document.querySelector("#sd-track-id"); + if (idEl) idEl.onclick = () => copyToClipboard(String(sessionId), "ID скопирован: " + sessionId); + // Chart fullscreen in modal — push to winStack, back collapses document.querySelectorAll(".chart-wrap").forEach(wrap => { wrap.onclick = () => { @@ -7561,6 +7568,23 @@ function showToast(msg, duration) { setTimeout(hide, duration || 6000); } +// Copy text to clipboard (navigator.clipboard with a hidden-textarea fallback for +// WebViews where the async API is unavailable). Optional toast on success. +function copyToClipboard(text, toastMsg) { + let ok = () => { if (toastMsg) showToast(toastMsg); }; + let fallback = () => { + try { + let ta = document.createElement("textarea"); + ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; + document.body.appendChild(ta); ta.focus(); ta.select(); + document.execCommand("copy"); ta.remove(); ok(); + } catch (e) { showToast("Не удалось скопировать"); } + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(ok).catch(fallback); + } else fallback(); +} + function esc(str) { return String(str ?? "") .replace(/&/g, "&").replace(/ write null, not 0.0: Android returns 0.0 when hasAltitude() + // is false, and a fake 0 corrupts elevation stats. A real fixed 0 is kept. + val altStr = if (location.hasAltitude()) location.altitude.toString() else "null" + val line = """{"lat":${location.latitude},"lon":${location.longitude},"alt":${altStr},"speed":${location.speed},"acc":${location.accuracy},"bearing":${bearing},"ts":${now}${sensorData}}""" synchronized(bufferLock) { pointBuffer.add(line) }