Панель здоровья, часть 1: оценка, которой я могу доверять
Часть 1 моего журнала разработки панели здоровья: как простой приемник Apple Health стал полезным только после того, как важными стали приём данных, сырые данные, пересборка кэша и проверки качества данных, а не сам результат.
Это первая часть серии о создании моего self-hosted Health Dashboard. Первая версия не была методологическим проектом. Это была труба: взять данные из Apple Health, отправить их POST-запросом на мой сервер и утром показать оценку.
Звучит чисто — пока в Postgres не приземляется первая плохая ночь. Оценка здоровья надёжна ровно настолько, насколько надёжен путь приёма данных под ней. Ранняя версия системы вбивала мне этот урок через ошибки.
Один из первых режимов сбоя выглядел так:
insert: insert point heart_rate/2026-04-19 08:47:39 +0200: timeout: context deadline exceeded
POST /health/vitals 32.057s
С точки зрения телефона синхронизация не удалась. С точки зрения сервера часть payload-а уже могла быть записана. С точки зрения панели следующий показатель готовности мог строиться на дне, который был загружен только наполовину. Это именно тот тип бага, из-за которого красивый график становится не просто бесполезным, а активно вредным.
На первом этапе я всё ещё коммитил прямо в проект. Pull request-ы появились позже, когда у системы стало достаточно поверхности, чтобы code review и явные контрольные точки начали окупать дополнительную церемонию.
Первая настоящая граница
Архитектура, которая выжила, намеренно скучна:
POST /health -> InsertRaw -> health_records -> 200 to client
|
v
InsertPoints
|
v
metric_points -> hourly_metrics -> daily_scores
Ключевое решение: health_records — источник истины. Сырой JSON payload сохраняется до парсинга, агрегации, расчёта оценок, AI-сводок и уведомлений. Всё, что находится ниже по цепочке, производное и может быть пересобрано.
Одна эта граница изменила систему. Упавший парсер перестал быть инцидентом потери данных. Ошибка в формуле оценки стала проблемой инвалидации кэша. Подозрительное значение на панели можно было проследить назад до конкретного сырого payload-а, который его породил.
Она же сделала контракт API понятнее. Первая долговечная обязанность сервера — не «посчитать готовность». Она звучит проще: «принять наблюдение и не потерять его».
Текущий главный блок панели — то, что пайплайн в итоге отображает. Оценка слева — это readiness number, который эта серия из восьми частей пытается сделать честным.
Оценки появились позже
Первая оценка готовности была намеренно простой:
readiness = HRV_score * 0.40 + RHR_score * 0.30 + Sleep_score * 0.30
Каждый компонент сравнивал свежие значения с личным baseline. Этого хватало, чтобы панель стала полезной, но не хватало, чтобы назвать её научно сильной. Это была эвристика, а не валидированная прогнозная модель.
Разница важная. Продукты для носимых устройств часто прячут эту часть за уверенным числом. Я хотел обратного: у каждого модуля должна быть своя метка доверия. Какие-то части — экспертно настроенные эвристики. Какие-то — экспериментальные. Какие-то могут стать кандидатами только после того, как обгонят наивный baseline на отложенных данных.
Сама оценка стала менее интересной, чем вопрос вокруг неё: какие доказательства заставят меня поверить этому числу завтра утром?
Объяснения появились раньше ИИ
Первые объяснения генерировались не моделью. Это были rule-based bullets и карточки «Как это работает»: HRV1, пульс в покое2, стадии сна, SpO23, VO2 max4, частота дыхания, активность и готовность — у каждого блока был явный текст, привязанный к правилам оценки и заметкам по литературе.
Это было важно, потому что система должна оставаться проверяемой даже при выключенном Gemini. AI появился позже как второй слой: более подробный текст под rule-based секциями, затем генерация по отдельным блокам — Sleep, Yesterday, Recovery и Recommendation — с кэшированием по хэшу входных данных, чтобы поздний HRV-сэмпл инвалидировал только те блоки, которые от него зависели.
Правило продукта осталось тем же: AI может объяснять и давать контекст, но не заменяет детерминированное объяснение. Если Gemini скажет что-то странное, числовые пункты и методические заметки всё равно останутся на месте — и с ними можно будет спорить.
Кэш — не истина
Система читает кэш в первую очередь, потому что панель не может при каждом запросе сканировать сырые time-series данные:
metric_pointsхранит распаршенные наблюдения.hourly_metricsагрегирует их по часам и источникам.daily_scoresсворачивает их в показатели сна, HRV, пульса в покое, активности, кислорода, дыхания и готовности.
Но кэшу нельзя позволять становиться истиной. После поступления новых health-данных сервер пересобирает затронутые даты напрямую из metric_points. Отложенный debounced backfill работает как страховочная сетка. Полная пересборка может очистить производные таблицы и пересчитать их заново из сырых наблюдений.
Звучит как деталь реализации, но это разница между отладкой health-продукта и отладкой spreadsheet-а. Если оценка выглядит неправильно, мне нужен путь назад: к наблюдению, источнику, дневному бакету и версии формулы.
Инвалидация кэша стала частью продукта
История с кэшированием прошла свою мини-эволюцию. Сначала пересборка казалась большим молотком: очистить производные таблицы и пересчитать весь мир. Это работает, пока база маленькая. Но перестаёт быть разумной операционной практикой, когда в продакшен-таблице 4 695 057 строк metric_points, а панель обслуживает несколько tenants.
Решением стало сделать ремонт кэша пропорциональным изменению. Если миграция затрагивает только небольшой набор строк сна, она должна пересобирать только эти даты через тот же путь, что и свежий ingest /health. Именно это targeted-путь UpsertRecentCache в итоге сделал для миграции sleep_unspecified: пересобрал затронутые даты через ingestion path вместо полного запуска make backfill --force.
Под одним названием скрывались три разные проблемы инвалидации кэша:
- Свежие данные: после
POST /healthсразу пересобрать затронутые почасовые и дневные строки, а затем дать debounced 48-часовому backfill подобрать хвосты. - Изменения формул: поднять
ScoreVersion, чтобы кэшированные строки readiness считались устаревшими после изменения правил скоринга. - Изменения представления: когда метрика меняет смысл, например при выделении грубого сна в
sleep_unspecified, обновить исходные строки и пересобрать только те даты, которые могли быть затронуты.
Более поздний баг в readiness API подчеркнул ту же мысль на другом уровне. Сервер отдавал стабильные readiness bands для briefing response, но iOS-спарклайн всё ещё читал /api/readiness-history и держал собственные пороги. Решение было не «обновить константы в приложении». Решение — сделать сервер единственным источником истины для семантики диапазонов в обоих endpoints.
Это стало повторяющимся правилом: если производное значение может пережить код, который его создал, ему нужна история инвалидации. Если два клиента могут показывать один и тот же концепт, у этого концепта должен быть один владелец.
Исторические данные пришли из экспорта Apple Health
Live ingestion был только половиной истории. Health-система, которая стартует сегодня, почти бесполезна для baseline-ов, трендов и калибровки. Мне нужна была история.
Здесь появился импортёр экспорта Apple Health. iOS умеет выгружать большой export.zip, внутри которого лежит apple_health_export/export.xml. Один репрезентативный экспорт, который я проверял, занимал 149 МБ в сжатом виде и разворачивался в export.xml на 2,4 ГБ. На сервере есть standalone-импортёр cmd/import и web upload flow в Settings. Оба используют один и тот же парсер internal/applehealth: streaming XML decoder, который способен обрабатывать многогигабайтные export.xml без загрузки всего файла в память.
Упомянутый выше путь Apple Health Auto Export — это стороннее iOS-приложение5, которое работает по расписанию и POST-ит данные HealthKit6 на настраиваемый HTTP endpoint. Именно так сервер впервые начал получать данные, ещё до появления моего собственного iOS-кода.
Импортёр мапит типы записей HealthKit в те же внутренние имена метрик, которые используются при live sync, нормализует Apple-проценты на основе долей в шкалу 0–100 для панели, пишет точки батчами и поддерживает --dry-run для парсинга без обращения к базе. CLI-версия в конце пересобирает агрегированные кэши и оценки. Web-версия отслеживает прогресс, запускает только один импорт на tenant и инвалидирует импортированный диапазон дат.
Самым тонким местом оказалось разрешение конфликтов. Health Auto Export и экспорт Apple Health могут покрывать одни и те же даты, но они не одинаково авторитетны. Для исторического ремонта полный экспорт Apple Health стал ground truth: при импорте пересекающегося диапазона система удаляет строки Auto Export за эти даты и пересобирает производные кэши. Это не даёт заполнению пропусков превратиться в заполнение дублями.
Такое разделение стало ещё одной важной границей: экспорт Apple Health — способ bootstrap-нуть или починить историю; live sync — способ поддерживать систему актуальной.
Потребительские health-данные требуют согласования
Следующий урок: «данные Apple Health» — это не один датасет. Это договорённость между Apple Watch, iPhone, RingConn, импортёрами, сводками, фрагментами и иногда устаревшими устройствами.
Сон был самым понятным примером. Общее число сна может выглядеть правильно, а разбивка по стадиям — нет. Грубые asleep-записи могут пересекаться с более точными стадиями Apple Watch. Полуночные summary могут дублировать реальные фрагменты. Один источник может прислать sleep_total со стадиями, другой — только неопределённый блок сна.
Решением стала не одна хитрая формула, а набор маленьких правил:
- хранить
sleep_unspecifiedотдельно от сна со стадиями; - предпочитать Apple Watch перед iPhone и другими источниками, когда это физиологически оправдано;
- дедуплицировать графики не так, как дневной скоринг;
- сохранять предыдущую строку
daily_scores, а не позволять кривому источнику с одной метрикой стереть ночь с нормальной разбивкой по стадиям.
Последнее правило отражает весь проект: когда качество данных неоднозначно, лучше выбрать явную неопределённость, чем уверенно испортить результат.
Сначала качество данных, потом умная модель
В итоге слой приёма данных получил три защитных механизма:
- Отбрасывать физиологически невозможные значения до записи.
- Добавить флаг
qualityвmetric_points:ok,impossibleилиsuspect. - Запускать мягкий z-score7 аудит автономных метрик относительно скользящего личного baseline.
В текущей продакшен-таблице из 4 695 057 строк 4 695 042 имеют статус ok, а 15 — suspect. Примеры: respiratory_rate=34.5 за 2026-05-02 и heart_rate_variability=186.22012 за 2026-05-10. Они достаточно правдоподобны, чтобы слепо удалять их было неправильно, и достаточно необычны, чтобы без проверки кормить ими baseline тоже было неправильно.
Чтение baseline-ов теперь фильтрует данные по quality='ok'. Это потребовало поднять версию readiness-кэша, потому что старый кэш был посчитан по другим правилам доверия.
Это негламурная часть создания личной health-модели. Можно неделю проектировать более умную оценку, но если сенсорный выброс или дублированный сегмент сна попадёт в baseline, оценка будет просто красиво отформатированной ложью.
Почему я перерос Health Auto Export
Health Auto Export был правильной отправной точкой. Он дал мне JSON payload-ы, экспорты по расписанию и достаточно покрытия, чтобы понять, во что должен превратиться сервер.
Но когда сервер начал учитывать идентичность источника, время синхронизации, неполные дни, edge cases сна и cross-checking, универсальный экспортёр стал неподходящим control plane. Этот сдвиг — тема части 2: переход от «принимать всё, что присылает Health Auto Export» к собственному ingestion-слою с нативным iOS-приложением.
Настоящий урок
Панель началась с оценки, но оценка не была продуктом. Продуктом было доверие.
Первая полезная версия была не той, где формула готовности лучше всего выглядит на бумаге. Она была той, где я мог быстро ответить на скучные вопросы:
- Телефон отправил данные?
- Сервер сохранил сырой payload?
- Какой источник породил эту метрику?
- Это значение было отфильтровано, помечено или использовано в baseline?
- Могу ли я пересобрать производные таблицы, не потеряв исходное наблюдение?
Вариабельность сердечного ритма. Колебания интервалов между последовательными сердечными сокращениями на уровне миллисекунд. Строго говоря, чем больше вариабельность, тем лучше: стабильный метрономный ритм сердца указывает на доминирование симпатической нервной системы (стресс, болезнь, усталость), в то время как сердце, свободно меняющее темп, свидетельствует о восстановлении парасимпатической системы. Вариабельность сердечного ритма — канонический показатель автономного состояния в современных исследованиях носимых устройств. ↩︎
Пульс в состоянии покоя. Частота сердечных сокращений, измеренная в состоянии покоя, желательно в последние часы ночного сна. Постепенное повышение за дни — классический сигнал болезни или перегрузки; снижение за недели — сигнал улучшения сердечно-сосудистой формы. ↩︎
Насыщение крови кислородом. Процент гемоглобина, переносящего кислород, измеряемый оптическим методом через кожу. Здоровые показатели в состоянии покоя находятся в диапазоне 95–100%; устойчивое снижение ниже 92% во время сна может указывать на апноэ или респираторные проблемы. ↩︎
Максимальная скорость потребления кислорода во время пиковых нагрузок, в мЛ/(кг·мин). Apple Watch оценивает это по траекториям сердечного ритма во время ходьбы и бега, а не напрямую измеряет. Является сильным показателем сердечно-сосудистой подготовки и риска общей смертности в продольных исследованиях. ↩︎
Health Auto Export. стороннее приложение для iOS (не созданное мной), которое периодически считывает данные HealthKit и отправляет их в виде JSON на настроенный пользователем HTTP-эндпоинт. Это самый ранний способ интеграции для этого проекта; часть 2 объясняет, почему и как был добавлен нативный клиент для iOS наряду с ним. ↩︎
Рамочная система Apple для хранения и запроса данных о здоровье и фитнесе на iOS. Все показания датчиков с Apple Watch, сторонних трекеров сна, таких как RingConn, и ручные записи проходят через HealthKit на устройстве, прежде чем любые данные будут экспортированы на сервер. ↩︎
Количество стандартных отклонений, на которое значение отклоняется от базового среднего. Z-оценка 2 означает «в два раза дальше от среднего, чем типичное дневное отклонение». Позволяет системе сравнивать отклонения по различным метрикам с очень разными единицами измерения (например, отклонение HRV +2σ и отклонение RHR +2σ считаются одинаково удивительными, несмотря на то, что шкалы в ударах в минуту и миллисекундах не связаны). ↩︎