diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index b7dc486..862a2c3 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -112,7 +112,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 ручных пауз), wind_marks (JSON меток «ветер в лицо») | +| `gps_sessions` | GPS тренировки | date, activity_type (bike/walk/run/workout/vr), distance, kcal, speed, elevation, bike_id, smoothed, smooth_window, smooth_iters (де-джиттер), pauses (JSON ручных пауз), wind_marks (JSON меток «ветер в лицо»), hr_samples (ряд ЧСС для workout/vr) | | `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 | @@ -120,7 +120,7 @@ | `rdi_values` | Нормы нутриентов | standard_id, nutrient_key, value, norm_type (rdi/aup) | | `nutrient_halflife` | Полураспад нутриентов | nutrient_key, halflife_avg_days | | `nutrient_reserves` | Запасы нутриентов по дням | date, reserves (JSON) | -| `road_points` | Дедуплицированные точки всех треков | lat, lon, atype (bike/walk) | +| `road_points` | Дедуплицированные точки всех треков | lat, lon, atype (bike/walk), passes (число проездов ячейки, для толщины линии) | ### Seed DB (products_seed.db) @@ -185,13 +185,13 @@ | `export_db` | Бэкап всей БД: `VACUUM INTO` чистой копии в app-root -> команда `exportdb:` -> Kotlin SAF-сохранение (`saveDbLauncher`, стрим файла) | | `import_db` | Восстановление: валидирует `picked_import.db` (`quick_check` + таблицы BalDav), кладёт в `pending_restore.db`; Kotlin `onCreate` свопит на `baldav.db` до открытия БД | | `restart_app` | Команда `restartapp` -> Kotlin перезапускает процесс (применить восстановление) | -| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп; пеший point (`walk=1`) → atype 'walk') | +| `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп в памяти по сетке; пеший point (`walk=1`) → atype 'walk'; считает passes = входы в ячейку = число проездов) | | `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) | | `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` | ## Формула ккал (GPS) -### Велосипед — НЕТТО-модель (суточный баланс = intake − BMR*activityLevel − activity, поэтому активность считается СВЕРХ покоя) +### Велосипед — НЕТТО-модель (суточный баланс = intake − BMR*activityLevel − activity, поэтому активность считается СВЕРХ покоя; с опциональным TEF: база = BMR*activityLevel*(1−tefFrac), затем +tefFrac*intake, tefFrac=0.10 — сохраняет поддержку, масштабирует переваривание от реальной еды) ``` Power = v * (m*g*sin(gradient) + Crr*m*g*cos(gradient) + 0.5*CdA*rho*v^2) m = rider_kg + bike_kg; gradient из СГЛАЖЕННОЙ высоты diff --git a/README.md b/README.md index 8f49096..e226ee7 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ - **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50). - **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м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN). Онлайн-долечивание пустых тайлов: раз в 30с, когда OSM доступен, БалДав дофетчивает недостающие тайлы (текущий зум) по видимой области и вдоль проеханного маршрута, кэширует и перерисовывает - дырки, оставшиеся от плохого сигнала в поездке, закрываются сами, когда связь есть (бережно к OSM: троттлинг, лимит на цикл; в режиме «офлайн карты» не фетчит). - **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. Подключение **только вручную** (Настройки -> «Подключить»), в т.ч. во время записи - пульс пишется в трек с момента подключения; автопоиска при старте записи нет (экономия батареи). BT выключен -> кнопка просит включить (системный диалог). Браслет не найден в скане -> сервис не висит в коннекте. -- **Зарядка (пульс)** — режим записи БЕЗ GPS для домашней тренировки: геолокация не включается и не пишется, только пульс с датчика + таймер. Экран без карты (крупный пульс, время, живые ккал, средний/макс). Нагрузка (ккал) считается по пульсу формулой Keytel (нетто, как и остальная активность) и идёт в дневной баланс. Деталь - без карты, график пульса по HR-зонам. Хранится в `gps_sessions` (`activity_type="workout"`, без точек, ряд пульса в `hr_samples`). -- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. Вес водителя для ккал берётся из **последнего взвешивания** (настройки - запас); каждая новая поездка **хранит вес на момент записи** (`gps_sessions.rider_weight_kg`), поэтому пересчёт старого трека считает по тогдашнему весу, а не текущему (старые треки без сохранённого веса - по последнему взвешиванию). Поле веса в настройках авто-синкается с последним взвешиванием. Баланс ккал вверху, тренд «Среднее за 7 дн.» и графики (вес/баланс, окна 30/90) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». +- **Зарядка (пульс)** — режим записи БЕЗ GPS для домашней тренировки: геолокация не включается и не пишется, только пульс с датчика + таймер. Экран без карты (крупный пульс, время, живые ккал, средний/макс). Нагрузка (ккал) считается по пульсу формулой Keytel (нетто, как и остальная активность) и идёт в дневной баланс. Деталь - без карты, график пульса по HR-зонам. Хранится в `gps_sessions` (`activity_type="workout"`, без точек, ряд пульса в `hr_samples`). Режим **«Руки/VR (пульс)»** (`activity_type="vr"`) - то же самое для махательных/танцевальных/VR-тренировок, трекается отдельно от зарядки. +- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. Вес водителя для ккал берётся из **последнего взвешивания** (настройки - запас); каждая новая поездка **хранит вес на момент записи** (`gps_sessions.rider_weight_kg`), поэтому пересчёт старого трека считает по тогдашнему весу, а не текущему (старые треки без сохранённого веса - по последнему взвешиванию). Поле веса в настройках авто-синкается с последним взвешиванием. Баланс ккал вверху, тренд «Среднее за 7 дн.» и графики (вес/баланс, окна 30/90) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня». Опциональный **TEF** (термический эффект пищи, галочка в профиле, 10% прихода): вводится с сохранением поддержки - база = BMR*actLevel*(1-0.1), затем +0.1*приход, так что на поддержке баланс не меняется, а в дефиците расход чуть ниже (ел меньше - переварил меньше). В сводке дня строка «расход: база + переваривание + активность». - **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу. - **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу. - **Нутриенты** — 37 микронутриентов (витамины, минералы, жирные кислоты, женьшень, ресвератрол, CoQ10). Нормы РСП/АУП (РФ/ЕС/США). Фармакокинетика — запасы в организме по полураспаду. diff --git a/docs/session-logs/2026-10-22_tef-vr-roads-poi.md b/docs/session-logs/2026-10-22_tef-vr-roads-poi.md new file mode 100644 index 0000000..e586989 --- /dev/null +++ b/docs/session-logs/2026-10-22_tef-vr-roads-poi.md @@ -0,0 +1,42 @@ +# 2026-10-22 - TEF, вид Руки/VR, толщина дорог по проездам, офлайн-POI в brouter + +## Контекст +Работа по нескольким запросам подряд (без утверждённого заранее плана, правки по ходу). + +## Сделано + +### brouter-web (вне git приложения, staging + /var/www/brouter-web) +- Офлайн-POI: Overpass недоступен из сети, поэтому взят статический экстракт Geofabrik + south-fed-district, osmium вырезал POI в радиусе 150 км от НЭВЗ -> poi-nevz.geojson + (26.6k точек). Конвейер tools/poi/make_poi.sh + poi_reduce.py. +- POI подключены в brouter-web ИКОНКАМИ (emoji) и в РОДНОЙ список слоёв (addOverlay в + пойманный L.Control.Layers), а не отдельными кнопками. Покрытие и Высоты - тоже в родной + список; бегунки высот всплывают только при включённом слое. Ползунок ребра высот ограничен + 1000 (в hm_mesh.py MAX_EDGE=1000, выше бесполезно - дыры это промежутки >1км без проездов). + +### Приложение (этот коммит) +- Толщина дорог по проездам: rebuild_roads считает passes (входы в 2м-ячейку), толщина + линии = база × min(3, 1+0.2·(проезды−1)). Опционально (настройка, по умолчанию вкл). +- Вид «Руки/VR (пульс)» (activity_type="vr") - HR-only как зарядка, трекается отдельно. + GPS.isHrOnly заменил жёсткие проверки "workout". +- TEF (термический эффект пищи): галочка в профиле, 10% прихода, с сохранением поддержки + (база = BMR*actLevel*(1−tef), +tef*приход). Строка «расход: база + переваривание + + активность» в сводке, 7-дневный тренд тоже с TEF. + +## Проверка +- brouter-web: CDP + скриншоты на https://192.168.1.18/brouter-web/ - слои в родном списке, + POI иконками, ползунок 1000, 4777 точек по дефолтным категориям. +- Приложение: сборка 0.1.513, CDP в WebView - tefFrac=0.1, isHrOnly('vr')=true, опция + "vr:Руки/VR (пульс)" в списке, сводка «расход: база 1807 + переваривание 194 + активность + 624» = 2625 (математика сходится). + +## Разбор +- Сегодняшняя VR-сессия (id 1792607289599, 66 мин): ЧСС средняя 93, макс 113, резерв ~33% - + лёгко-умеренная зона без пиков. По ЧСС адекватно судить ЗОНУ; абсолютные ккал Keytel для + рук/VR завышает (верхняя граница), для этого и завели отдельный вид + множитель 0.65. +- Эта сессия переведена workout -> vr по просьбе (UPDATE через собственное DB-соединение + приложения по CDP). + +## Дальше (не делалось) +- TEF по составу БЖУ (сейчас фиксированные 10%). +- MET-строка для VR как перекрёстная сверка (основным оставлен ЧСС). diff --git a/frontend/js/gps.js b/frontend/js/gps.js index dbf8ea6..6c4d35b 100644 --- a/frontend/js/gps.js +++ b/frontend/js/gps.js @@ -15,6 +15,8 @@ GPS.config = { airDensity: 1.225, // плотность воздуха (кг/м3). Уровень моря=1.225, горы=1.0 pedalEfficiency: 25, // КПД педалирования (%). Человек ~25% hrCalibFactor: 0.65, // грубый персональный множитель для справочной "ЧСС откалибр." (Keytel x этого). Пульс завышает ~в 1.5x vs физика/вес; 0.65 мирит по 2 точкам (поездка 90км + сидячий день). НЕ истина, для справки. + tefFrac: 0.10, // термический эффект пищи (TEF): доля прихода, уходящая на переваривание. 0 = выкл. Вводится С СОХРАНЕНИЕМ поддержки: база = BMR*actLevel*(1-tefFrac), затем +tefFrac*приход, так что на поддержке итог не меняется, но TEF масштабируется от реальной еды. actLevel 1.2 уже включал TEF — поэтому не прибавляем сверху, а перераспределяем. + altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS) walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч @@ -43,6 +45,11 @@ GPS.saveConfig = function() { localStorage.setItem("baldav_gps_config", JSON.stringify(GPS.config)); }; +// HR-only session kinds (no GPS, no track — just heart rate + timer): "workout" (зарядка) +// and "vr" (руки/VR/танцы). Both estimate kcal from HR (Keytel). Kept as a set so new +// HR-only kinds get the same no-GPS recording/detail path without touching every check. +GPS.isHrOnly = function(t) { return t === "workout" || t === "vr"; }; + // One-time migration: raw GPS_PROVIDER doesn't deliver on this device, so default // tracking to fused (like Yandex/Google Maps). Runs once, then respects the toggle. try { @@ -859,7 +866,7 @@ GPS._startHrMonitor = async function() { GPS.recording.currentHr = reading.hr; GPS.recording.currentBleData = reading; // HR-only workout: the poll IS the recorder — append [relSec, bpm] each reading. - if (GPS.recording.activityType === "workout" && GPS.recording.startTime) { + if (GPS.isHrOnly(GPS.recording.activityType) && GPS.recording.startTime) { GPS.recording.hrSamples.push([Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000), reading.hr]); } if (GPS.recording.onHrUpdate) GPS.recording.onHrUpdate(reading); @@ -1455,16 +1462,16 @@ GPS.startRecording = async function(activityType, weightKg, profile) { GPS.recording.startTime = null; GPS.recording.weightKg = parseFloat(weightKg) || 80; - // HR-only "workout" (зарядка): no GPS service, no location, no track points — just the + // HR-only "workout"/"vr": no GPS service, no location, no track points — just the // heart-rate poll + a timer. kcal comes from HR (Keytel) at finish. - if (GPS.recording.activityType === "workout") { + if (GPS.isHrOnly(GPS.recording.activityType)) { GPS.recording.state = "recording"; GPS.recording.startTime = new Date(); GPS.recording.nativeService = false; GPS.recording.sessionId = null; try { GPS.recording.sessionId = await DB.createGpsSession( - new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), "workout"); + new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), GPS.recording.activityType); } catch (e) { console.error("[workout] createGpsSession failed:", e); } // Auto-connect the paired strap on start (workout = you want HR). Absent strap -> stops clean. try { @@ -1576,8 +1583,8 @@ GPS.stopRecording = async function() { if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer); - // HR-only workout: no GPS to stop. Compute net kcal from HR, save samples, finalise. - if (GPS.recording.activityType === "workout") { + // HR-only workout/vr: no GPS to stop. Compute net kcal from HR, save samples, finalise. + if (GPS.isHrOnly(GPS.recording.activityType)) { // Prefer the native HR log (complete through screen-off) over the JS poll's in-memory // array (frozen in the background). Fall back to the poll array if the log is empty. let samples = GPS.recording.hrSamples || []; diff --git a/frontend/js/main.js b/frontend/js/main.js index b012414..d6591b6 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -355,10 +355,15 @@ async function renderToday(container) { let recentWeightsForBmr = await DB.getRecentWeights(7, state.date); let bmr = calcBmr(recentWeightsForBmr); let actLevel = parseFloat(state.profile.activity_level) || 1.2; - let dailyBase = bmr ? Math.round(bmr * actLevel) : null; let intake = Math.round(foodTotals.kcal ?? 0); let actKcal = Math.round(actTotals.kcal ?? 0); - let expend = dailyBase != null ? dailyBase + actKcal : actKcal > 0 ? actKcal : null; + // TEF (термический эффект пищи): доля прихода на переваривание. Вводим с сохранением + // поддержки — базу урезаем на tefFrac, а TEF добавляем от реального прихода, поэтому на + // поддержке итог тот же, но в дефицит (ел меньше — переварил меньше) расход чуть ниже. + let tefFrac = Math.max(0, Math.min(0.3, GPS.config.tefFrac ?? 0)); + let dailyBase = bmr ? Math.round(bmr * actLevel * (1 - tefFrac)) : null; + let tefKcal = Math.round(tefFrac * intake); + let expend = dailyBase != null ? dailyBase + tefKcal + actKcal : (actKcal > 0 ? actKcal + tefKcal : null); let balance = expend != null ? intake - expend : null; container.innerHTML = `
@@ -382,6 +387,10 @@ async function renderToday(container) {
+ ${expend != null ? `
+ расход: база ${dailyBase ?? "—"}${tefKcal > 0 ? ` + переваривание ${tefKcal}` : ""}${actKcal > 0 ? ` + активность ${actKcal}` : ""} +
` : ""} +
@@ -560,7 +569,7 @@ async function renderToday(container) { for (let d of food7) { let dayIntake = foodMap[d.date] ?? 0; let dayAct = actMap[d.date] ?? 0; - let dayBalance = dayIntake - (dailyBase + dayAct); + let dayBalance = dayIntake - ((dailyBase ?? 0) + Math.round(tefFrac * dayIntake) + dayAct); balances.push(dayBalance); } if (balances.length === 0) return; @@ -2814,6 +2823,7 @@ async function renderGpsScreen(container) { +