Files
healthlog/docs/local-research.md
T
av 5e2385ba6e добавлены документация проекта и каркас разработки
- README, CLAUDE.md, docs: назначение и границы, архитектура, конвенции, план
- docs/local-research.md — 36 находок по формату Health Auto Export, снятых на
  живых данных; документация приложения местами расходится с тем, что оно шлёт
- Taskfile, .golangci.yml, самодокументируемый config.example.toml
2026-08-01 12:37:03 +03:00

76 KiB
Raw Blame History

Разведка на живых данных

Журнал наблюдений за реальным потоком Health Auto Export. Документация формата (wiki) тонкая и местами расходится с тем, что приложение шлёт на самом деле, поэтому источником истины служит этот файл.

Пополняется по мере накопления доставок. Каждый вывод — с числами и командой, которой он получен, чтобы его можно было перепроверить.

Как снималось

Сервис запущен локально (task run), телефон шлёт по локальной сети на IP машины. Автоматизация — REST API, JSON, интервал 5 минут.

Накоплено к 2026-08-01: 42 доставки, 165 МБ тел, 6,6 МБ архива. Три автоматизации, режимы менялись по ходу разведки:

автоматизация что шлёт режимы, которые прошли
BC99C8A3 показатели здоровья суммирование посекундно → поминутно → выключено, период Today → Default → Since Last Sync
37A43AE1 тренировки (сперва ошибочно показатели) период Default
F4458FA4 состояние разума период Default

За это время снято: суммированные данные обеих гранулярностей, несуммированные, тренировка в помещении и уличная с геотреком, состояния разума, ночь целиком.

Разбор — командами вида:

gzip -dc raw/2026/07/31/<id>.json.gz | jq -r '...'

1. Поле source существует

Документация утверждает, что источника в точке метрики нет. Это неверноsource есть в каждой точке, в обоих режимах группировки.

Значения бывают составными, через |:

156386  Apple Watch Ultra 3|iPad (Anton)
133923  Apple Watch Ultra 3
 39903  Apple Watch Ultra 3|iPhone (Anton)
   294  iPhone (Anton)
    18  (пустая строка)
    10  AutoSleep

Составное значение означает, что несколько устройств писали одно и то же, а Health Auto Export слил их вклад в одну точку и перечислил всех участников. Пустая строка тоже встречается (18 точек apple_stand_hour) — поле необязательно.

Почему в источнике оказался неподвижный iPad

Наблюдение, которое сперва выглядело аномалией. Разбор:

  • iPad никогда не встречается в одиночку, только в паре с часами;
  • он появляется ровно в одной метрике — basal_energy_burned;
  • суммы за сутки правдоподобны (1892 и 2121 ккал базального обмена против 423–519 ккал активной энергии), то есть вклады не складываются дважды.

Базальный обмен — единственная метрика, не требующая движения: это расчёт от профиля на прошедшее время, и его пишет любое устройство с Health, включая стоящий на столе планшет. По той же причине iPhone (Anton) приклеивается к шагам и дистанции — телефон в кармане их честно считает.

Вывод: составной source — это метка «кто вложился», а не признак порчи данных. Значения корректны.

2. Порядок ключей в JSON нестабилен

Самая важная находка для реализации. Из 81 952 точек, встретившихся в двух доставках, 67 534 отличаются только порядком ключей:

{"qty":0.005001252398249198,"date":"2026-07-31 00:24:43 +0300","source":"Apple Watch Ultra 3"}
{"date":"2026-07-31 00:24:43 +0300","source":"Apple Watch Ultra 3","qty":0.005001252398249198}

Хеш по сырым байтам поймал бы только 18% повторов — витрина распухла бы в пять раз дублями одних и тех же точек.

Следствие для схемы: хеш содержимого считается по канонической форме с рекурсивной сортировкой ключей. Рекурсивной — потому что одиннадцать оставшихся «расхождений» оказались тем же самым беспорядком внутри вложенного массива heartbeatSeries.

3. Значения не меняются задним числом

После канонизации расхождений по значению — ноль на 81 941 общей точке.

Гипотеза, на которой строилась модель идентичности («сырые сэмплы HealthKit неизменяемы, задним числом пересчитываются только агрегаты»), подтвердилась для посекундного режима. Модель «повтор — no-op» верна.

Не проверено для минутного режима — см. «Открытые вопросы».

4. Формы точки

Единой формы значения нет, но их немного. При посекундной группировке — шесть на 330 тысяч точек:

328891  [date, qty, source]
   934  [date, start, end, qty, source]
   537  [date, start, end, Min, Avg, Max, context, source]        heart_rate
   107  [date, start, end, startDate, endDate, qty, value, source] sleep_analysis
    43  [date, start, end, Min, Avg, Max, source]
    22  [date, start, end, qty, heartbeatSeries, source]           HRV

При минутной — три:

6447  [date, qty, source]
 573  [Avg, Max, Min, date, source]
   2  [asleep, awake, core, date, deep, inBed, inBedEnd, inBedStart,
       rem, sleepEnd, sleepStart, source, totalSleep]              sleep_analysis

Обобщённая модель (payload JSON + нормализованное время) покрывает оба режима без выделения типов.

Даты — строкой с офсетом: 2026-07-31 12:00:00 +0300, не RFC 3339. Внутри heartbeatSeries время другое — числовой Unix timestamp с долями.

5. «Минимальная гранулярность» даёт синтетические данные

При группировке Default точки идут раз в секунду, непрерывно:

2026-07-31 00:00:00 +0300
2026-07-31 00:00:01 +0300
2026-07-31 00:00:02 +0300

86 400 точек в сутки на один только basal_energy_burned. Но посекундный базальный обмен никто не измеряет — это гладкая расчётная кривая, которую Health Auto Export нарезает на секундные ломтики по запрошенной группировке.

Проверка: суммы за завершившиеся сутки 30 июля совпадают до килокалории.

посекундно поминутно
basal_energy_burned 2181 ккал 2181 ккал
active_energy 519 ккал 519 ккал

Вывод: мелкая группировка ≠ сырые данные.

Уточнено находкой 20. Причина посекундной сетки — не группировка: выключение суммирования её не убрало. И это не выдумка на пустом месте, а нарезка настоящих сэмплов, которых под ней в 2,4 раза меньше. Читать находку 20 вместе с этой.

6. Что теряет минутная группировка

Не всё посекундное было мусором. При переходе на минуты исчезает:

  • heartbeatSeries — межударные интервалы, по полсотни ударов в каждой из 22 записей heart_rate_variability. Единственные по-настоящему измеренные сырые данные во всём наборе; остаётся одно число qty.
  • Детализация сна — было 107 эпизодов с фазами и границами, стало 2 суточных агрегата (totalSleep / core / deep / rem / awake).
  • context у пульса и start/end у интервальных метрик — схлопываются в одну date.

По объёму потерянное — около 130 точек в сутки на фоне 3300. То есть оно стоит копейки, но выброшено заодно с посекундной интерполяцией, которая стоила 150 тысяч точек.

Возможный гибрид: вторая автоматизация без группировки только для sleep_analysis и heart_rate_variability, первая — минутная для остального. Наборы метрик не пересекаются, поэтому коллизий по (метрика, время) между автоматизациями не возникнет и «поток-источник» в ключе не понадобится.

7. Объём

режим тело точек в архиве точек в сутки
посекундно 57,2 МБ 330 534 2,2 МБ ~155 000
поминутно 1,2 МБ 7 022 72 КБ ~3 300

Gzip жмёт такой JSON примерно в 25 раз — архив дешевле, чем закладывалось.

Пакеты сильно перекрываются: период Today покрывал двое суток, Default — трое. При интервале в 5 минут каждая доставка целиком переприсылает окно, и архив растёт впустую: посекундно это около 630 МБ в сутки, поминутно — около 20 МБ. Лечится сменой периода на «Since Last Sync».

8. Строковые значения локализованы

"context": "Не задано"     (heart_rate)
"value":   "Во сне"        (sleep_analysis)

Приходят на языке телефона. Смена языка iOS изменит их, и данные до и после смены перестанут сходиться. Хранить как есть обязаны — это сырьё; но в самоописании такие поля стоит помечать, а клиентам не завязываться на конкретные строки.

В минутном режиме оба поля исчезают, так что ловушка возникает только при мелкой группировке.

9. Секции

Приходит только то, что включено в автоматизации. В снятых доставках — одна секция metrics (26 метрик); workouts и остальные отсутствуют как ключи, а не приходят пустыми. Разбор обязан это переживать.

10. Минутные агрегаты досчитываются задним числом — да

Сравнение трёх подряд идущих минутных доставок (интервал 5 минут), ключ (метрика, дата, источник), рекурсивная канонизация:

17:44 → 17:49:  общих 7022, изменилось 0,  новых 3
17:49 → 17:54:  общих 7025, изменилось 2,  новых 36

Что именно изменилось:

heart_rate 20:36  Avg 69 → 70.25,  Max 69 → 71.51
basal_energy_burned 20:32   qty 5.5704 → 7.1345

Обе изменившиеся минуты — хвост, возраст 13 и 22 минуты на момент первой отправки. Минутное ведро уезжает неполным, пока сэмплы не досинхронизировались с часов, и в следующей доставке приезжает досчитанным. Дальше в прошлое значения не меняются.

Это отменяет вывод 3 для агрегированного режима: «повтор — no-op» верно для посекундных сэмплов, но не для минутных агрегатов.

11. Ключ (метрика, дата, источник) почти уникален, но не всегда

Проверка внутри одной доставки, сколько ключей имеют два разных значения:

посекундная (330k точек): 2
минутная    (7k точек):   0

Оба исключения — sleep_analysis от AutoSleep: эпизоды сна делят одну date (начало сна), а различаются полями start/end и фазой. То есть date там не идентифицирует запись.

Следствие: координаты против значений

Ни чистый хеш содержимого, ни чистая перезапись по ключу не покрывают оба режима:

  • хеш содержимого в минутном режиме накопит по нескольку версий одной минуты (находка 10), и читателю нечем выбрать актуальную;
  • перезапись по (метрика, дата, источник) в посекундном режиме потеряет эпизоды сна (эта находка).

Разделение полей точки на две группы закрывает оба случая:

  • координатыdate, start, end, startDate, endDate, source: отвечают на вопрос «какая это запись»;
  • значения — всё остальное (qty, Min/Avg/Max, value, фазы сна, heartbeatSeries): отвечают на вопрос «что измерено».

Ключ строки — метрика плюс координаты, запись — перезапись значений (last-write-wins). Тогда эпизоды сна расходятся по start/end, досчитанная минута перезаписывает неполную, а точный повтор не меняет ничего (сверяется по хешу значений).

Цена — минимальная интерпретация: список полей-координат фиксирован и не зависит от метрики, незнакомое поле считается значением. Риск в том, что новое координатное поле от Apple схлопнет две разные записи в одну; страхуемся тем, что перезапись с изменением хеша логируется — молчаливой потери не будет, а сырой архив хранит все версии.

12. Автоматизация опознаётся по automation-id, но не по имени

Из заголовков, которые шлёт Health Auto Export:

automation-id    BC99C8A3-8BE7-4519-B545-F3ED6212008E   стабилен во всех доставках
automation-name  (пусто)                                автоматизация не названа
session-id       уникален на каждую доставку

automation-id — стабильный UUID автоматизации, по нему доставки разных автоматизаций различимы на одном эндпоинте. automation-name приходит пустым, пока автоматизации не задано имя в приложении; для читаемости /stats имя стоит проставить.

session-id меняется каждую доставку — это идентификатор попытки отправки, а не потока. Годится для сшивания частей, разбитых Batch Requests.

13. Заголовок automation-aggregation не описывает реальную гранулярность

Две доставки с одинаковым значением заголовка дали разную гранулярность:

автоматизация заголовок шаг меток basal_energy_burned
BC99C8A3 (17:31) Default 00:00:00, 00:00:01, 00:00:02 — секунда
37A43AE1 (18:00) Default 00:00:00, 00:01:00, 00:02:00 — минута

Правдоподобное объяснение: Default означает «в автоматизации явно не задано», а действующая настройка живёт уровнем выше и была изменена между доставками. Проверить это изнутри данных нельзя.

Следствие: заголовок годится как метаданные доставки, но не как описание данных. Гранулярность определяется по самим меткам времени, а не по aggregation. Для самоописания (шаг 5) шаг метрики нужно выводить из данных — как и всё остальное.

14. Две автоматизации с одним набором метрик дают дубликат

37A43AE1 была заведена под тренировки, но приехала с теми же 26 показателями здоровья, что и BC99C8A3 (секция workouts в пакете отсутствует). Сравнение по ключу (метрика, дата, источник):

общих ключей 6490, значение совпало 6489, изменилось 1

Единственное расхождение — хвостовая минута 20:44, то есть обычный досчёт из находки 10, а не разница между автоматизациями.

Пока обе автоматизации шлют одну гранулярность, витрина схлопнет дубликат сама (тот же ключ, то же значение). Опасен другой случай: одинаковые метрики с разной гранулярностью — тогда по одному ключу приезжают разные значения, и перезапись начнёт их чередовать в зависимости от того, чья доставка пришла последней. Правило: наборы метрик между автоматизациями не пересекать.

15. Тренировка: разнородная структура, много избыточности

Первая живая тренировка (ходьба в помещении, 91 секунда, 6,5 КБ на пакет). Двадцать два поля верхнего уровня четырёх разных сортов:

id                          строка   UUID из HealthKit — стабильный ключ
name, location              строка   "В помещении Ходьба", "В помещении"  ← локализованы
start, end                  строка   дата с офсетом, как у метрик
duration                    число    91.746 — СЕКУНДЫ (21:07:25 → 21:08:56)
isIndoor                    булево
metadata                    объект   пустой
distance, totalEnergy,      объект   {qty, units}
  activeEnergyBurned,
  avgHeartRate, maxHeartRate,
  intensity, speed,
  temperature, humidity
heartRate                   объект   {avg, max, min}, каждый — {qty, units}
activeEnergy                массив   2 точки  [date, qty, units, source]
basalEnergy                 массив   2 точки  [date, qty, units, source]
walkingAndRunningDistance   массив   2 точки  [date, qty, units, source]
heartRateData               массив   2 точки  [date, Min, Avg, Max, units, source]
heartRateRecovery           массив  13 точек  [date, Min, Avg, Max, units, source]

Заметное: сводки дублируют ряды. activeEnergyBurned — это сумма activeEnergy, distance — сумма walkingAndRunningDistance, а avgHeartRate/maxHeartRate повторяют heartRate.avg/heartRate.max.

route отсутствует — тренировка в помещении. Как выглядит маршрут, живьём пока не видели.

Точки внутренних рядов по форме совпадают с точками метрик, но несут units в каждой точке, тогда как у метрик units живут на уровне метрики.

Вывод: решение хранить тренировку одной строкой с полным JSON подтверждается. Раскладывать такую структуру в таблицы означало бы принять десяток решений о том, что здесь главное, — а это уже интерпретация.

16. stateOfMind живёт по другим соглашениям

{
  "id": "C0E1AF76-1EA3-406B-9F90-BA537FBEB3AD",
  "kind": "momentary_emotion",
  "start": "2026-07-31T18:03:51Z",
  "end": "2026-07-31T18:03:51Z",
  "valence": 0.02508960573476693,
  "valenceClassification": "neutral",
  "labels": ["drained", "calm"],
  "associations": ["hobbies"]
}

Отличий от метрик три, и все существенные:

  1. Формат даты другой — RFC 3339 в UTC с Z, а не 2026-07-31 21:03:51 +0300. То есть разбор дат зависит от секции пакета, одного парсера мало.
  2. Перечисления по-английски и не локализованы: kind (momentary_emotion / daily_mood), valenceClassification (neutral / slightly_pleasant), labels, associations. В метриках и тренировках те же по смыслу поля приходят на языке телефона (находка 8) — единого правила у Health Auto Export нет.
  3. Поля source нет вовсе. Набор полей-координат (находка 11) обязан переживать его отсутствие.

Есть свой id (UUID), как у тренировок, — годится как естественный ключ.

17. Набор метрик не фиксирован

В одной и той же автоматизации метрик стало 28 вместо 26: как только появились данные, добавились mindful_minutes и walking_heart_rate_average.

Каталог метрик растёт по факту поступления данных. Это подтверждает выбор обобщённой модели хранения и выводимых схем (шаг 5): фиксированный список метрик в коде устарел бы в тот же день.

18. Пустой пакет не отправляется

Автоматизация без данных за период молчит совсем — доставки нет. Наблюдение: автоматизация тренировок не слала ничего 15 минут, пока в Health не появилась запись, тогда как автоматизация показателей за то же время доставила трижды.

Следствие для наблюдаемости: молчание разреженного типа (тренировки, осознанность, ЭКГ, цикл) — норма, а не сбой, и отличить его от сломавшейся автоматизации нечем. Порог тревоги «данных нет N часов» имеет смысл только для показателей здоровья: они идут всегда и годятся как пульс всей связки. По остальным типам в /stats осмысленно показывать лишь «когда приходило в последний раз», без тревоги.

19. Выключение суммирования: что вернулось и что не изменилось

Переключатель «Суммировать данные» выключён (группировка при этом в интерфейсе исчезает). Из пяти проверок, заявленных заранее, подтвердились три.

Вернулось то, что съедала минутная группировка (находка 6):

heart_rate_variability   heartbeatSeries на месте: 60, 51 и 48 ударов
sleep_analysis           снова эпизодами, с value/startDate/endDate
heart_rate               поле context вернулось

Не изменилось: посекундная сетка у кумулятивных метрик. basal_energy_burned по-прежнему выдаёт ровно 3600 точек в час, непрерывно, всю ночь.

Заголовок automation-aggregation остался Default и при суммировании, и без него. То есть он не различает ни группировку, ни сам факт суммирования — как источник сведений о данных бесполезен полностью. Находка 13 усилена: режим определяется только по самим данным.

20. Посекундная сетка — нарезка настоящих сэмплов, а не выдумка

Ключевое наблюдение: в пакете 18 014 точек basal_energy_burned, но всего 2057 различных значений, и подряд идущие совпадают до шестнадцатого знака:

21:54:04  qty=0.14020320410520137
21:54:05  qty=0.14020320410520137
21:54:06  qty=0.14020320410520137
21:54:08  qty=0.14020320410520135    ← дрожь последнего разряда от деления

Распределение длин серий одинаковых значений:

длина 1: 3709 серий    длина 4:  523
длина 2: 1786          длина 5:  241
длина 3: 1026          длина 6+: 361

Итого около 7600 серий — то есть под посекундной сеткой лежат настоящие сэмплы длительностью 1–12 секунд, каждый растянут на свою длину. Инфляция примерно 2,4×, и вместе с ней теряются границы сэмплов: у кумулятивных метрик start/end не приходят вовсе.

Суммы при этом корректны — нарезка сохраняет итог:

2026-08-01 00:00   334 кДж = 80 ккал   точек 3600
2026-08-01 01:00   340 кДж = 81 ккал   точек 3600
…

63–82 ккал в час, около 1900 ккал в сутки базального обмена — сходится с измеренным в суммированном режиме (находка 5).

Вывод: «настоящих» сэмплов от Health Auto Export получить нельзя ни в одном режиме. Выбор такой: посекундная сетка с деталями (эпизоды сна, HRV) и инфляцией 2,4×, либо минутная группировка без деталей. Первое дороже примерно в 20 раз, но при хранении часовыми сжатыми объектами это всё равно копейки.

21. Дискретные и кумулятивные метрики ведут себя по-разному

Поля start/end приходят только у части метрик:

heart_rate, physical_effort, environmental_audio_exposure,
blood_oxygen_saturation, sleep_analysis, heart_rate_variability,
apple_stand_hour                        ← интервал есть

basal_energy_burned, active_energy, step_count,
walking_running_distance, apple_stand_time …      ← только date

Деление проходит по границе «дискретное измерение» против «накопительная величина». Накопительные режутся на секунды и теряют интервал (находка 20), дискретные сохраняют свой.

Практически: у 24 261 точки из 24 368 в пакете вообще нет start/end. Поэтому вопрос «в какой час класть сэмпл, пересекающий границу» касается лишь сотни точек в пакете — и решается простым правилом «по date».

22. Маршрут тренировки

Уличная ходьба, 594 секунды, 593 точки маршрута — одна в секунду. Точка несёт десять полей, а не пять, как обещала документация:

{"latitude":44.778909627459036,"longitude":37.70132686458095,
 "altitude":70.61593273964799,"speed":1.181851863861084,
 "course":-1,"timestamp":"2026-08-01 10:04:31 +0300",
 "horizontalAccuracy":10.22251601695661,"verticalAccuracy":9.629558563232422,
 "speedAccuracy":1.0428272485733032,"courseAccuracy":-1}

Маршрут — 95% веса тренировки: 190 КБ из 199,6 КБ. Десятиминутная прогулка даёт 200 КБ, часовая пробежка дала бы порядка 1,2 МБ.

Набор полей тренировки зависит от её типа:

только у уличной:  route, avgSpeed, maxSpeed, elevationDown, flightsClimbed
только у домашней: temperature, humidity, intensity

Фиксированной схемы тренировки не существует — ещё один довод за хранение блобом и выводимые схемы.

23. Объём в несуммированном режиме

basal_energy_burned   3600 точек в час = 86 400 в сутки
всего                 ~135 000 точек в сутки

Тот же порядок, что и у посекундного суммированного режима (находка 7). Часовая грань объектов остаётся уместной: 3600 точек в объекте — это около 320 КБ JSON, порядка 13 КБ в сжатом виде.

Отдельно: автоматизация тренировок работает с периодом Default и потому переприсылает те же тренировки каждые 5 минут — 317 КБ на доставку, из которых 95% маршрут. Хеш по id тренировки сделает это бесплатным для хранилища, но не для архива. Ей тоже нужен Since Last Sync.

24. В именах устройств — неразрывные пробелы

Источник приходит не тем, чем выглядит:

"Apple Watch Ultra 3"

Между «Apple», «Watch» и «Ultra» стоят U+00A0, а не обычные пробелы (между «Ultra» и «3» — обычный). Обнаружено случайно: фильтр source == "Apple Watch Ultra 3", набранный руками, молча не находил ничего, хотя группировка по тому же полю работала.

Источник — сама Apple, не HAE: в родном экспорте sourceName содержит те же неразрывные пробелы (находка 34). То есть обойти это выбором источника нельзя.

Это ловушка на будущее: любой клиент, отбирающий данные по имени устройства, напишет обычный пробел и получит пустой ответ без всякой ошибки. То же касается составных значений: "Apple Watch Ultra 3|iPhone (Anton)".

Следствие: в самоописании (шаг 5) значения-примеры нужно отдавать так, чтобы невидимые символы были заметны, а в read API фильтр по источнику — либо не делать, либо нормализовать пробелы на входе и хранить оба варианта. Значение при этом храним дословно, как и всё остальное.

25. Разбор ночи: наша сторона чистая, вопросы к устройствам

Проверка ночи 31 июля → 1 августа. Из 118 экземпляров sleep_analysis во всех доставках после канонизации осталось 43 различных записи — дедупликация по содержимому отработала, повторов не осталось.

Ряд Apple Watch безупречен:

35 эпизодов, разрывов 0, пересечений 0
окно 02:02:16 → 07:57:39
сумма эпизодов 5.92 ч = длительность окна 5.92 ч

Конец каждого эпизода совпадает с началом следующего, сумма фаз сходится с окном до сотых. Это сильное свидетельство, что в приёме и дедупликации ничего не потерялось: дыра или задвоение сломали бы равенство.

Фазы: Основная 3,68 ч, БДГ 0,56 ч, Бодрствование 1,68 ч.

Вопросы — не к приёму:

  1. Часы не покрывают первые четыре часа сна. AutoSleep фиксирует укладывание в 22:04, владелец сообщает, что уснул около 22:30 и часы были на руке всю ночь, — а записи часов начинаются только с 02:02. Это аномалия, а не норма: см. находку 26.
  2. Фазы «Глубокий» этой ночью нет вовсе — прямое следствие пункта 1: глубокий сон приходится на первые циклы, то есть на пропущенный отрезок.
  3. AutoSleep переписывает ночь более длинной записью. «В кровати» 22:04–02:21 и «В кровати» 22:04–07:51 — первая вложена во вторую, и обе лежат в хранилище.

Третий пункт важен для контракта: наивная сумма даёт 14,07 ч в кровати за ночь длиной 9,8 ч. Хранить обе записи правильно (мы не знаем, какая «настоящая», и терять нельзя), но клиент обязан схлопывать вложенные интервалы сам. Это стоит сказать в самоописании.

Заодно это первый наблюдавшийся случай, когда несуммированные данные переписываются задним числом — не изменением значения по ключу, а выпуском более длинной записи с тем же началом. Идентичность по содержимому его не схлопнет, и это правильно.

26. «Since Last Sync» отслеживает время записи, а не дату сэмпла

Проверка возникла из аномалии находки 25. Сравнение трёх ночей по данным часов:

ночь        первая запись   последняя   эпизодов  фазы
2026-07-29      22:21         07:43        57     БДГ, Бодрствование, Глубокий, Основная
2026-07-30      22:34         05:22        40     БДГ, Бодрствование, Глубокий, Основная
2026-07-31      02:02         07:57        35     БДГ, Бодрствование, Основная

В обе предыдущие ночи часы начинали писать ровно в момент засыпания. Значит пробел третьей ночи — не обычное поведение устройства.

Отсюда вопрос: не потеряли ли данные мы? Если бы «Since Last Sync» отбирал сэмплы по их собственной дате, то запись, которую часы дописали в Health утром задним числом, уже не попала бы в выгрузку — метка синхронизации ушла вперёд. Это и есть тот сценарий дыры, ради которого задумывались широкие проходы.

Данные говорят, что нет. Записей, чья дата старше дня доставки, набралось 78, и часть приехала именно в доставках с периодом «Since Last Sync»:

сэмпл 2026-07-31 22:04 (В кровати, AutoSleep) → доставлен 2026-08-01T05:09:07Z

К моменту этой доставки метка синхронизации давно прошла 22:04 предыдущего дня — и запись всё равно приехала. Значит отбор идёт по времени добавления записи в Health, а не по дате самого сэмпла, и дописанные задним числом данные выгружаются штатно.

Следствия:

  • Риск дыр меньше, чем закладывалось: многоуровневая синхронизация остаётся нужной на случай простоя сервиса, но не для ловли поздних дописок.
  • Задержка данных часов велика: эпизоды 02:02–03:33 приехали в 09:18 по местному времени, то есть через семь часов. Порог тревоги по свежести это обязан учитывать.

ОПРОВЕРГНУТО находкой 29. Вывод «пропавшие 22:30–02:02 отсутствуют в Health» оказался неверным: широкая выгрузка их привезла. Наблюдение про доставку старых записей верное, но выводить из него сохранность нельзя — механизм отбора сложнее, чем «по времени добавления».

27. Смена режима автоматизации стоила 30 минут данных

Покрытие basal_energy_burned по минутам за всё время (метрика идёт непрерывно, поэтому годится как индикатор работы канала):

276 798 точек, 3432 минуты с данными, от 2026-07-30 00:04 до 2026-08-01 10:43
разрывов: 4
  2026-07-31 21:23 → 21:54   нет 30 мин   ← смена режима автоматизации
  2026-08-01 07:51 → 08:19   нет 27 мин
  2026-08-01 02:54 → 03:12   нет 17 мин
  2026-08-01 09:03 → 09:18   нет 14 мин

Первый разрыв приходится ровно на паузу между доставками: последняя суммированная пришла в 18:27Z (21:27 местного), первая несуммированная — в 23:58Z. Пока автоматизацию перенастраивали, метка синхронизации ушла вперёд, и полчаса данных не выгрузились никогда.

Это первая наблюдённая настоящая потеря, и она подтверждает необходимость широких проходов: средний проход (час, за сутки) закрыл бы её сам в течение часа. Их отсутствие и стало причиной — на момент сбоя был настроен только быстрый проход.

Но пробел в фазах сна (22:30–02:02) этим не объясняется. В том же окне basal_energy_burned идёт непрерывно, ровно по 3600 точек в час:

2026-07-31 22:00   3600 точек
2026-07-31 23:00   3600 точек
2026-08-01 00:00   3600 точек
2026-08-01 01:00   3600 точек

Канал работал, данные за это время доехали. Значит фазы сна за ранний период просто не появились в Health — вывод находки 26 остаётся в силе.

28. Расписание автоматизаций — пожелание, а не гарантия

Из документации Health Auto Export (Automations, Shortcuts):

  • Приложение можно не держать открытым, но в фоне автоматизации зависят от Background App Refresh, и iOS решает сама: «iOS also does not allow apps to run in the background at a specified time… automations are not guaranteed to run precisely at the specified time».
  • Пока приложение на переднем плане, автоматизации перезапускаются примерно раз в 60 секунд — отсюда ровный пятиминутный ритм в наших доставках.
  • Заблокированный телефон = экспорта нет вообще: «Apps are not allowed to access health data while iPhone is locked». Ночью автоматизации не работают в принципе, и данные за ночь приезжают утром — что мы и наблюдали (эпизоды сна 02:02–03:33 доставлены в 09:18, находка 26).
  • На зарядке ограничения слабее.
  • Триггер через Shortcuts («Run Automation») предсказуемее фонового расписания, но телефон всё равно должен быть разблокирован.

Следствие для наблюдаемости: ровного ритма доставок не бывает. Поток пачечный: тишина ночью, всплеск утром. Порог тревоги по молчанию должен быть не меньше суток, а осмысленный показатель свежести — возраст самой свежей точки, а не время последней доставки.

Следствие для стратегии: нельзя строить сохранность на том, что доставка случится вовремя. Либо период выборки перекрывает любой разумный простой (широкие окна идемпотентны по построению), либо метка «Since Last Sync» обязана переживать неудачные попытки — а это не проверено.

Что известно про непрерывность «Since Last Sync» из наших данных: окна последовательных доставок стыкуются без разрывов —

доставлено             окно данных
2026-07-31T23:58:11Z   21:44:44 → 02:56:25
2026-08-01T00:09:01Z   22:04:00 → 03:04:20   стык ок
2026-08-01T05:09:07Z   22:04:00 → 08:05:19   стык ок
2026-08-01T06:18:17Z   22:04:00 → 09:12:56   стык ок

Но все эти доставки успешные. Поведение при неудачной отправке (сервис недоступен) не наблюдалось ни разу — см. «Открытые вопросы».

29. «Since Last Sync» теряет данные — подтверждено экспериментом

Период основной автоматизации переключили на широкий (Default, окно 2026-07-30 21:48 → 2026-08-01 11:14, 42 МБ, 251 976 точек). Сравнение с тем, что за то же время отдал инкрементальный режим.

Окно, которое «Since Last Sync» уже покрывал (2026-07-31 21:44 → 2026-08-01 11:09), сравнение по ключу «метрика + дата + источник»:

через Since Last Sync:      72 428 точек
в широкой выгрузке:         85 692
ключей только у широкой:    13 961   ← потеряно инкрементальным режимом
ключей только у SLS:           696

Фазы сна за спорную ночь:

через Since Last Sync:  35 эпизодов, с 02:02, фазы: БДГ, Бодрствование, Основная
в широкой выгрузке:     59 эпизодов, с 22:43, фазы: БДГ, Бодрствование,
                                                    Глубокий, Основная

Пропавшие фазы были в Health и приехали, как только окно выборки перестало зависеть от метки синхронизации. Гипотеза владельца подтвердилась, вывод находки 26 отменён.

После широкой выгрузки покрытие basal_energy_burned стало непрерывным по минутам за всё время наблюдения — разрывов не осталось вовсе, включая получасовую дыру находки 27.

Вывод, меняющий стратегию: инкрементальный период нельзя использовать как единственный источник. Он годится для свежести, но сохранность обязаны обеспечивать широкие проходы с фиксированным окном.

30. Числа сериализуются нестабильно между выгрузками

При сравнении тех же точек всплыло неожиданное: из 71 730 общих ключей 45 507 различались значением — но вот как:

basal_energy_burned 22:24:51   0.09523182962471353  против  0.09523182962471352
active_energy       22:06:32   0.0074754192155406605 против 0.00747541921554066

Расхождение в последнем разряде — шум представления double, а не разные данные. Хеш по канонической форме этого не переживает: 63% повторно доставленных точек считались бы новыми, и каждый широкий проход дублировал бы витрину.

Лечится округлением перед хешированием:

нормализация      совпало   разошлось
без округления     26 223      45 507
%.16g              33 799      37 931
%.15g              61 347      10 383
%.14g              69 738       1 992
%.12g              70 184       1 546

Ложных схлопываний округление не даёт: на 251 976 точках одной выгрузки %.12g не склеил ни одной пары различных точек.

Следствие для схемы: хеш считается по канонической форме с округлением чисел (порядка 12–14 значащих цифр); значение при этом хранится дословно, как пришло. Округление — только для идентичности, не для данных.

Оставшиеся ~1 500 расхождений (2%) — настоящие: нарезка на секунды не детерминирована между выгрузками, границы и доли слегка разъезжаются. Это значит, что широкие проходы всё равно будут добавлять небольшой процент дубликатов при идентичности по содержимому. Ключ по координатам (метрика + date + source) с перезаписью значений эту проблему снимает и заодно сохраняет эпизоды сна (они различаются start/end) — стоит вернуться к развилке находки 11 при реализации шага 3.

31. Вся мета-информация о выгрузке — в заголовках, и она полуправдива

В теле метаданных нет. Верхний уровень всегда ровно один ключ data, внутри — только секции. Ни версии формата, ни окна выборки, ни настроек, ни времени экспорта.

В заголовках — пять полей, из которых опираться в коде можно на два:

заголовок пригодность
automation-id надёжен — стабильный UUID автоматизации
session-id надёжен — уникален на доставку
automation-name пустой, пока имя не задано в приложении
automation-aggregation Minutes | Default — см. ниже
automation-period Today | Default | Since Last Sync — см. ниже

Сопоставление заголовков с фактическим поведением по всем крупным доставкам:

aggregation  period           шаг меток   окно данных
Minutes      Default          минута      надёжно
Default      Today            секунда     2026-07-30 21:48 → 07-31 20:20  (двое суток!)
Default      Default          секунда     2026-07-29 22:11 → 07-31 20:20
Default      Default          минута      ← и так тоже бывает
Default      Since Last Sync  секунда     2026-07-31 22:04 → 08-01 10:45
  • aggregation: значение Minutes действительно означает минутную группировку. Значение Default не означает ничего — под ним прошли посекундная сетка при включённом суммировании, минутная у второй автоматизации и несуммированный режим. Одно значение, три поведения.
  • period: называет настройку, но не описывает охват. Today дал окно шире суток, потому что в него попали записи сна, начавшиеся накануне вечером: окно определяется датами сэмплов, а не календарём.

Вывод: режим и охват определяются по самим данным — шаг меток и min/max даты считаются за один проход при разборе. Заголовки годятся как подсказка человеку и как ключ источника, не более.

Поэтому с этого момента сохраняем весь набор заголовков в delivery.headers (JSON, без секретов): что HAE шлёт помимо задокументированных пяти, мы не знали, а именно из незадокументированного вышли поле source (находка 1) и неразрывные пробелы (находка 24).

32. Полный набор заголовков: три полезных поля сверх документации

После включения записи всех заголовков (15 доставок):

Accept                        */*
Accept-Encoding               gzip, deflate
Accept-Language               ru
Automation-Aggregation        Default
Automation-Id                 BC99C8A3-8BE7-4519-B545-F3ED6212008E
Automation-Name               (пусто)
Automation-Period             Today
Connection                    keep-alive
Content-Length                12882621
Content-Type                  application/json
Host                          192.168.2.60:8080
Session-Id                    77BEBA08-E372-42EC-B80F-101863AB4BB1
Upload-Complete               ?1
Upload-Draft-Interop-Version  6
User-Agent                    Auto%20Export/20260729.1 CFNetwork/3860.700.1 Darwin/25.6.0

Задокументированных полей пять, реально приходит пятнадцать. Три из незадокументированных имеют смысл для проекта.

User-Agent несёт версию приложения

Auto%20Export/20260729.1 — датированный номер сборки, плюс версия системы (Darwin/25.6.0). Это закрывает вопрос, который висел с самого начала: формат данных может измениться только с обновлением Health Auto Export, и теперь обновление видно в каждой доставке. Значит выведенные схемы (шаг 5) можно сверять по версии: изменилась версия — стоит перепроверить формы точек.

Версию стоит сохранять отдельной колонкой рядом с доставкой, а не только внутри headers, — по ней захочется группировать.

Accept-Language объясняет локализацию

ru — язык телефона приезжает в каждом запросе. Это делает решаемой ловушку находки 8: строки value («Во сне»), context («Не задано») и name тренировки («В помещении Ходьба») приходят на языке телефона, и теперь мы знаем, на каком именно. Язык можно хранить рядом с данными и помечать им локализованные поля в самоописании — вместо того чтобы клиенту гадать.

Upload-Complete — CFNetwork умеет докачиваемую загрузку

Upload-Complete: ?1 и Upload-Draft-Interop-Version: 6 — это черновик IETF Resumable Uploads, который реализует CFNetwork на стороне iOS. Пока во всех доставках ?1, то есть тело приезжает целиком.

Риск на будущее: тела уже достигают 42 МБ, а по мобильной сети клиент может захотеть слать их по частям. Частичная загрузка приедет с Upload-Complete: ?0, и наш сервис сочтёт обрезанное тело битым JSON и ответит 400. Пока мы не отвечаем 104 Upload Resumption Supported, клиент переходить на докачку не должен — но это стоит помнить и не включать поддержку случайно. Если ?0 когда-нибудь придёт, правильнее ответить явной ошибкой, а не молча трактовать тело как испорченное.

33. Слой надо выводить как режим доставки, а не как частоту метрики

Первая версия правила определяла слой по каждой метрике отдельно, по выравниванию её меток. На живых данных она сломалась, и сломалась именно на редких метриках.

Что пошло не так. Частота метрик различается на порядки: heart_rate идёт сотнями точек в час, vo2_max и six_minute_walking_test_distance — по одной точке за всё время наблюдения. Для редкой метрики выравнивание не значит ничего: единственная несуммированная точка попадает на ровную минуту примерно в 1,7% случаев, а на ровный час — реже, но регулярно, потому что многие такие метрики Apple и записывает на границе часа.

Результат классификации «по метрике» на наших данных:

apple_sleeping_wrist_temperature   hour (12 точек), raw (2)    ← одно измерение за ночь
apple_stand_hour                   hour (284),      raw (5)    ← почасовая по природе
walking_heart_rate_average         hour, minute, raw           ← одна точка в сутки
vo2_max                            minute, raw
six_minute_walking_test_distance   minute, raw

Одна и та же редкая метрика растащена по трём слоям — при том, что режим выгрузки был один. Клиент увидел бы vo2_max в двух слоях по одной точке и не понял бы, что это одно и то же измерение.

Дополнительно выяснилось, что и «по доставке целиком» неверно в лоб: одна доставка законно содержит метрики разной подробности — apple_stand_hour почасовой по природе, sleep_analysis в минутном режиме становится суточным агрегатом на 00:00:00, а heart_rate рядом идёт секундами. Правило «самый мелкий слой побеждает» переворачивалось от одной метрики.

Рабочее правило

Слой — это режим выгрузки, общий для доставки; редкая метрика его наследует, а не голосует.

  1. Голосуют только плотные метрики доставки — не меньше 10 точек. У десяти несуммированных точек шанс всем лечь на ровную минуту исчезающе мал.
  2. Режим — самый мелкий слой среди проголосовавших.
  3. Голосовать некому — режим наследуется от предыдущей доставки той же автоматизации; если её не было, берётся заголовок.
  4. Все метрики доставки, включая редкие, кладутся в слой этого режима.

Проверка на всей истории

39 доставок с метриками, три автоматизации, четыре смены настроек:

расхождений с надёжным заголовком (Minutes):  0
смены режима обнаружены:                      3  (ровно там, где настройки меняли)
наследование сработало:                       2  (доставки без плотных метрик)
редких метрик в доставке:                     0–12, ни одна не повлияла

Правило воспроизводит фактические настройки автоматизаций без единой ошибки.

34. Родной экспорт Apple Health — другой источник, и он точнее HAE

Сравнение выгрузки Health Auto Export с ручным экспортом из приложения Health за те же трое суток (apple_health_export/экспорт.xml, HealthKit Export Version 14).

Объём: меньше в девяносто раз

Health Auto Export, несуммированный:   ~135 000 точек в сутки
Родной экспорт Apple:                    ~1 500 записей в сутки

За окно наблюдения (30 июля – 1 августа) в родном экспорте 4 629 записей всех типов. У Health Auto Export за то же окно — сотни тысяч точек.

Причина: HAE режет сэмплы по секундам

У каждой записи Apple есть startDate и endDate — настоящий интервал измерения:

BasalEnergyBurned:   564 записи, интервал: медиана 10с, макс 21990с
                     суммарно покрыто 74.9 ч
                     → нарезка по секундам дала бы 269 693 точки

А у нас от HAE за это окно ровно столько и лежит — 3600 точек в час. Совпадение до цифры: инфляция 478× для базального обмена, около 90× по всему потоку.

Это окончательно закрывает находки 5 и 20: «несуммированный» режим HAE — не сырые данные, а посекундная развёртка настоящих сэмплов. Причём развёртка теряет информацию: границы интервала (startDate/endDate) в неё не попадают.

Отсюда неожиданный вывод: самое точное представление данных одновременно и самое компактное. Посекундный режим HAE — худший из вариантов: в 90 раз больше объёма, чем у правды, и меньше сведений.

Что ещё есть в родном экспорте и нет у HAE

  • startDate / endDate / creationDate — интервал измерения и момент записи в Health отдельно;
  • device — полное описание устройства (модель, версия прошивки, серийный идентификатор объекта), а не только имя;
  • sourceVersion — версия приложения-источника;
  • фазы сна английскими идентификаторами: HKCategoryValueSleepAnalysisAsleepCore, AsleepDeep, AsleepREM, Awake. Локализацию («Основная», «Во сне») делает именно HAE — в источнике значения независимы от языка телефона. Это снимает ловушку находки 8 для импортированных данных;
  • 236 GPX-треков тренировок и 11 CSV с ЭКГ отдельными файлами.

Перекрёстная проверка сна

Спорная ночь в родном экспорте:

часы: с 22:43 до 07:57, 59 эпизодов
фазы: AsleepCore 25, Awake 21, AsleepREM 10, AsleepDeep 3
первый: 22:4323:02 AsleepCore, глубокий сон в 23:32

Ровно то же, что привезла широкая выгрузка HAE (находка 29): 59 эпизодов, начало в 22:43. Два независимых источника сошлись — данные достоверны, а инкрементальный режим действительно их терял.

Цена

Формат совершенно другой: XML на 1,69 ГБ (плюс дублирующий export_cda.xml на 1,1 ГБ), архив целиком 104 МБ. Имя файла локализованоэкспорт.xml, а не export.xml: захардкодить нельзя.

Значит healthlog import из шага 6 — это не «те же JSON, только из файла», а отдельный парсер XML со своей моделью записи. Зато он даёт слой, которого HAE не отдаёт ни в каком режиме.

Следствие для стратегии

Напрашивается другая раскладка источников:

источник что даёт объём как часто
HAE, минутная группировка свежесть, непрерывный поток 3,3 тыс. точек/сутки каждые 5 минут
Родной экспорт Apple настоящие сэмплы с интервалами 1,5 тыс. записей/сутки вручную, раз в N недель

Посекундный режим HAE в этой раскладке не нужен вовсе: он дороже обоих и точнее ни одного.

35. Три разреза сходятся — и это проверка правила вывода слоя

Появилась третья автоматизация (8364E2C6, заголовок Hours), метрики стали приходить в трёх разрезах одновременно. Сверка сумм между ними и с родным экспортом Apple как эталоном:

час                sample (Apple)     minute (HAE)      hour (HAE)
2026-08-01 00           79.74             79.73            79.73
2026-08-01 01           81.24             81.24            81.24
2026-08-01 03           79.53             79.54            79.54
active_energy 09        22.47             22.47            22.47

Три независимых представления совпадают до сотых. Минутный и часовой разрезы HAE достоверны, эталон подтверждает.

По дороге нашлись две ошибки — обе в измерении, не в данных

Первая: наивный подсчёт эталона. Я приписывал каждую запись Apple часу её начала — а базальный обмен приходит записями с интервалом до шести часов (находка 34). Суммы скакали от 54 до 580 ккал в час. Лечится раскладкой значения по часам пропорционально перекрытию интервала.

Вторая, важнее: правило вывода слоя мис-филировало транзитные доставки. Правило находки 33 назначало слой доставке целиком по самой мелкой из плотных метрик. В доставке 494A0C76 от 08:55:57 heart_rate был ещё несуммированным, а остальные 29 метрик — уже минутными. Вся доставка ушла в слой raw, и минутные точки базального обмена сложились с посекундными: сумма ровно удвоилась.

Исправленное правило (проверено — суммы сошлись):

  • метрика с ≥ 10 точками классифицируется сама по себе;
  • метрика с < 10 точками наследует преобладающий слой доставки (самый мелкий среди плотных).

Так и смешанная доставка раскладывается верно, и редкая метрика не дробится по слоям — оба требования выполняются одновременно.

36. Поле source нестабильно — и это ломает идентичность по содержимому

Самая дорогая находка проверки. Одно и то же измерение — та же метрика, та же минута, то же значение — приезжает с разными строками источника:

minute 2026-07-31 03:15, basal_energy_burned, qty=5.671724507333192

доставки 31 июля:   source = "Apple Watch Ultra 3|iPad (Anton)"
доставки 1 августа: source = "Apple Watch Ultra 3"

Значение совпадает до последнего разряда, метка та же, а source изменился: iPad перестал числиться среди вкладчиков. Судя по всему, Health переосмыслил атрибуцию источников задним числом.

Последствие: идентичность по хешу содержимого сохраняет обе записи, и сумма за час удваивается. Это не редкий случай — на 31 июля минутный слой содержал 120 точек в час вместо 60, то есть задвоено всё.

Следствие для модели

Ключ обязан состоять из устойчивых координат, а source к ним не относится:

ключ:      метрика + слой + метка времени
значения:  qty / Min / Avg / Max / source / …   ← перезаписываются

Это окончательно решает развилку находок 11 и 30 в пользу координат: хеш содержимого хорош тем, что ничего не теряет, но он не переживает ни нестабильной сериализации чисел (находка 30), ни нестабильной атрибуции источника (эта находка). Хеш при этом остаётся полезен — как быстрая проверка «изменилось ли что-нибудь», чтобы не писать зря.

Остаточный случай: в посекундном слое HAE 65 меток из 3600 несут по два разных значения при одном источнике — там перезапись потеряет одно из двух. В минутном и часовом слоях такого нет вовсе: ровно одна точка на метку. Ещё один довод отказаться от посекундного слоя HAE в пользу sample из родного экспорта.

Инструмент

Разбор ведётся скриптом tmp/research/hl.py (Python 3, только стандартная библиотека, каталог под .gitignore):

python3 tmp/research/hl.py deliveries              что приехало
python3 tmp/research/hl.py metrics --period 'Since Last Sync'
python3 tmp/research/hl.py shapes                  формы точки
python3 tmp/research/hl.py sources                 источники, с показом невидимых символов
python3 tmp/research/hl.py points step_count       точки, инфляция серий
python3 tmp/research/hl.py sleep                   разбор ночи
python3 tmp/research/hl.py diff <id1> <id2>        что изменилось между доставками
python3 tmp/research/hl.py workouts                тренировки, ряды, маршрут

Он канонизирует JSON перед сравнением и показывает невидимые символы — те две грабли, на которых разбор оболочкой ломался молча.

Открытые вопросы

  • Переживает ли «Since Last Sync» неудачную отправку. Ключевой вопрос для стратегии (находка 28). Проверяется экспериментом: остановить сервис на полчаса при открытом приложении, поднять и посмотреть, приедет ли пропущенное окно. Если метка двигается независимо от исхода — на инкрементальный период полагаться нельзя вообще.
  • Дальность досчёта. Наблюдались правки хвоста возрастом до 22 минут. Меняется ли что-то на глубине часов и суток — покажет более длинный ряд доставок.
  • Секции, которых мы не видели живьём: symptoms, ecg, heartRateNotifications, cycleTracking, medications.