- README, CLAUDE.md, docs: назначение и границы, архитектура, конвенции, план - docs/local-research.md — 36 находок по формату Health Auto Export, снятых на живых данных; документация приложения местами расходится с тем, что оно шлёт - Taskfile, .golangci.yml, самодокументируемый config.example.toml
1181 lines
76 KiB
Markdown
1181 lines
76 KiB
Markdown
# Разведка на живых данных
|
||
|
||
Журнал наблюдений за реальным потоком Health Auto Export. Документация
|
||
формата ([wiki](https://github.com/Lybron/health-auto-export/wiki/API-Export---JSON-Format))
|
||
тонкая и местами расходится с тем, что приложение шлёт на самом деле, поэтому
|
||
источником истины служит этот файл.
|
||
|
||
Пополняется по мере накопления доставок. Каждый вывод — с числами и командой,
|
||
которой он получен, чтобы его можно было перепроверить.
|
||
|
||
## Как снималось
|
||
|
||
Сервис запущен локально (`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 отличаются только порядком ключей**:
|
||
|
||
```json
|
||
{"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. Строковые значения локализованы
|
||
|
||
```json
|
||
"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` живёт по другим соглашениям
|
||
|
||
```json
|
||
{
|
||
"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 точки маршрута** — одна в секунду. Точка
|
||
несёт десять полей, а не пять, как обещала документация:
|
||
|
||
```json
|
||
{"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](https://help.healthyapps.dev/en/health-auto-export/automations/),
|
||
[Shortcuts](https://help.healthyapps.dev/en/health-auto-export/automations/schedule-automations-using-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: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`.
|