- README, CLAUDE.md, docs: назначение и границы, архитектура, конвенции, план - docs/local-research.md — 36 находок по формату Health Auto Export, снятых на живых данных; документация приложения местами расходится с тем, что оно шлёт - Taskfile, .golangci.yml, самодокументируемый config.example.toml
76 KiB
Разведка на живых данных
Журнал наблюдений за реальным потоком 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"]
}
Отличий от метрик три, и все существенные:
- Формат даты другой — RFC 3339 в UTC с
Z, а не2026-07-31 21:03:51 +0300. То есть разбор дат зависит от секции пакета, одного парсера мало. - Перечисления по-английски и не локализованы:
kind(momentary_emotion/daily_mood),valenceClassification(neutral/slightly_pleasant),labels,associations. В метриках и тренировках те же по смыслу поля приходят на языке телефона (находка 8) — единого правила у Health Auto Export нет. - Поля
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 ч.
Вопросы — не к приёму:
- Часы не покрывают первые четыре часа сна. AutoSleep фиксирует укладывание в 22:04, владелец сообщает, что уснул около 22:30 и часы были на руке всю ночь, — а записи часов начинаются только с 02:02. Это аномалия, а не норма: см. находку 26.
- Фазы «Глубокий» этой ночью нет вовсе — прямое следствие пункта 1: глубокий сон приходится на первые циклы, то есть на пропущенный отрезок.
- 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 рядом идёт секундами. Правило «самый
мелкий слой побеждает» переворачивалось от одной метрики.
Рабочее правило
Слой — это режим выгрузки, общий для доставки; редкая метрика его наследует, а не голосует.
- Голосуют только плотные метрики доставки — не меньше 10 точек. У десяти несуммированных точек шанс всем лечь на ровную минуту исчезающе мал.
- Режим — самый мелкий слой среди проголосовавших.
- Голосовать некому — режим наследуется от предыдущей доставки той же автоматизации; если её не было, берётся заголовок.
- Все метрики доставки, включая редкие, кладутся в слой этого режима.
Проверка на всей истории
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:43–23: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.