local-research: разобран формат экспорта Apple за 5.5 лет

- девять экспортов, версии 11→13→14; ни один тип записи не исчез, только
  добавлялись — разбор можно писать аддитивно
- Correlation (давление) появился только в 2026 и ломает парсер по одним Record;
  имя файла локализовано, DTD расходится с данными
- коды HealthKit переименовываются: те же записи сна приезжают как Asleep из
  экспорта 2021 и как AsleepUnspecified из свежего
This commit is contained in:
av
2026-08-01 14:20:54 +03:00
parent 7ed82af450
commit 7ee55057e0
4 changed files with 169 additions and 10 deletions
+29 -7
View File
@@ -9,14 +9,36 @@
Это же основание для устаревания нижнего слоя и единственный способ поднять
историю глубже недели: дыра старше недели проходами синхронизации не чинится.
Формат разобран на девяти экспортах за 5.5 лет (находки 42–45), гадать не
придётся:
- **версии 11 → 13 → 14**, на 14 стоит больше года; за всё время **ни один тип
записи не исчез**, только добавлялись. Значит незнакомый тип — новый тип, а
не сломанный парсер: падать на нём нельзя;
- **`Correlation`** — обёртка из двух записей, ею приезжает давление. Появилась
только в 2026 году. Парсер по одним `<Record>` разберёт давление как две
несвязанные метрики и потеряет их парность;
- **`WorkoutStatistics`** внутри тренировки — с 2024 года;
- **имя файла локализовано**: `экспорт.xml`, не `export.xml` — так во всех
девяти архивах;
- **DTD расходится с данными** (в v11 у `<Me>` на атрибут больше объявленного)
— валидировать документ его же DTD нельзя;
- объём: 1.6 ГБ XML и 3.6 млн записей в свежем экспорте — только потоковый
разбор, документ целиком в память не влезет.
Шаги:
- разбор `экспорт.xml` (HealthKit Export Version 14, `<Record>` с
`startDate`/`endDate`/`value`/`sourceName`/`device`) в слой `sample`;
- маршруты GPX и ЭКГ отдельными CSV — они не в XML;
- заливка кусками по годам: файл измеряется сотнями мегабайт.
- потоковый разбор `экспорт.xml` в слой `sample`, включая `Correlation`;
- `HeartRateVariabilityMetadataList` с `InstantaneousBeatsPerMinute` — это
тот же `heartbeatSeries`, что в HAE (находка 39), 1.25 млн ударов;
- маршруты GPX и ЭКГ отдельными файлами — их в XML нет;
- заливка кусками по годам, идемпотентно: повторный импорт того же архива не
должен ничего менять;
- `export_cda.xml` игнорируем — это клинический формат тех же данных.
Готово, когда история за несколько лет лежит в слое `sample`, а суммы по нему
сходятся с часовым слоем HAE на пересечении периодов.
Готово, когда история за несколько лет лежит в слое `sample`, повторный импорт
не меняет ничего, а суммы по слою сходятся с часовым слоем HAE на пересечении
периодов.
Есть готовый файл для проверки: `/home/av/MediaEverything/HealthData/apple_health/`.
Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук,
2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор.
+13 -3
View File
@@ -11,9 +11,19 @@
появятся сами. Задача — не пропустить момент: убедиться, что новые секции
разбираются, а не молча падают в `parse_status`.
Отдельный вопрос, на который ответят эти же данные: есть ли эти секции в родном
экспорте Apple. Если нет — экспорт им не источник истины, и устаревание
нижнего слоя к ним неприменимо, держим всегда.
Часть вопроса закрыта разбором экспортов (находка 42): в Health эти данные
**есть** и в экспорте они присутствуют — `BodyMass` (1127 записей),
`BloodPressureSystolic`/`Diastolic` (по 18), `BodyTemperature` (11), `Headache`
(36), `SexualActivity` (46), `Dietary*` (по 88). Значит вопрос не «есть ли
данные», а «доедут ли они через HAE и в какой форме».
Остаётся непроверенным `stateOfMind`: в экспорте его нет ни одним типом. Если
подтвердится, что Apple его не выгружает, то экспорт ему не источник истины —
устаревание нижнего слоя к нему неприменимо, держим всегда.
Давление приезжает обёрткой `Correlation` из двух записей (находка 44) — в
экспорте точно, а вот как его отдаёт HAE, неизвестно. Это первое, на что
смотреть, когда данные появятся.
Готово, когда каждая новая секция либо разобрана, либо явно описана в
`docs/local-research.md` как не пришедшая, и ни одна не числится в ошибках
@@ -15,6 +15,24 @@ HAE отдаёт перечислимые значения строками ло
код. Словарь ключуется парой `(локаль, строка)`, локаль берётся из
`Accept-Language`. Незнакомая строка → пустой код, а не догадка.
**Словарь фаз сна уже выведен** сопоставлением потока с экспортом за тот же
период (находка 43) — составлять руками не нужно:
```
Основная → AsleepCore Бодрствование → Awake БДГ → AsleepREM
Глубокий → AsleepDeep В кровати → InBed Во сне → AsleepUnspecified
```
Тем же способом добираются `heart_rate.context` и типы тренировок.
Осложнение, всплывшее на истории экспортов: **коды тоже не вечны.** Одни и те
же записи сна приезжают как `…Asleep` в экспорте 2021 года и как
`…AsleepUnspecified` в экспорте 2026-го: Apple переименовала значение и
переписывает историю при выгрузке (находка 43). Значит словарь должен
переживать переименование самих кодов, иначе после обновления iOS история
расколется вторично — уже на «стабильной» стороне. Простейшее решение: хранить код как есть, а
эквивалентность старых и новых имён держать отдельной таблицей синонимов.
Готово, когда фазы сна из потока и из экспорта Apple сравниваются напрямую, а
`/stats` показывает строки, для которых кода ещё нет.
+109
View File
@@ -1318,6 +1318,115 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51
более полная точка, а не последняя пришедшая, — иначе бедная доставка стирает
`start`/`end` у богатой.
## 42. Формат экспорта за 5.5 лет: типы только добавляются
Девять экспортов из `~/MediaEverything/HealthData/apple_health`, с декабря
2021 по август 2026. Версия формата растёт медленно и давно стоит на месте:
```
2021-12 Export Version 11
2024-06 Export Version 13
2025-06 Export Version 14
… 14 ← пять экспортов подряд, больше года без изменений
2026-08 Export Version 14
```
Раскладка архива одинакова во всех девяти: `экспорт.xml`, `export_cda.xml`
(клинический формат, нам не нужен), `workout-routes/` с GPX, `electrocardiograms/`
с CSV. Объём вырос вчетверо — 369 МБ XML и 944 887 записей в 2021 против
1610 МБ и 3 616 171 записи в 2026.
**Главное для импорта: ни один тип не исчез.** Сверка четырёх экспортов
(v11, v13, v14 первый, v14 последний) по всем типам записей не нашла ни одного
случая пропажи — только появление новых:
| появился | типы |
|---|---|
| к 2024 (v13) | `PhysicalEffort`, `TimeInDaylight`, `DistanceCycling`, `HeartRateRecoveryOneMinute`, `AudioExposureEvent`, `LowCardioFitnessEvent` |
| к 2025 (v14) | `AppleSleepingWristTemperature`, `BodyTemperature`, `SexualActivity` |
| к 2026 | `DietaryFiber`/`FatTotal`/`Protein`/`Carbohydrates`, `BloodPressureSystolic`/`Diastolic`, `HighHeartRateEvent` |
Значит разбор экспорта можно писать «аддитивно»: незнакомый тип — это новый
тип, а не сломанный парсер, и падать на нём нельзя.
Появлялись и **структурные** элементы, а это уже опаснее:
`WorkoutStatistics` внутри тренировки (с 2024) и `Correlation` (см. находку 44).
## 43. Коды HealthKit не вечны — Apple переписывает историю при экспорте
Те же самые записи сна, экспортированные с разницей в пять лет, несут **разные
коды**:
```
экспорт 2021-12 338 × HKCategoryValueSleepAnalysisAsleep
экспорт 2026-08 338 × HKCategoryValueSleepAnalysisAsleepUnspecified
62 × HKCategoryValueSleepAnalysisInBed ← в обоих одинаково
```
Совпадение счётчиков до единицы означает, что это одни и те же исторические
записи: `Asleep` переименован в `AsleepUnspecified`, и старые данные при
экспорте переписываются новым именем.
**Следствие:** код HealthKit устойчивее локализованной строки, но не абсолютен.
Словарь категориальных значений обязан переживать переименование самих кодов —
иначе после очередного обновления iOS история расколется вторично, теперь уже
на «стабильной» стороне.
### Словарь фаз сна выводится из данных
В свежих записях экспорта фазы полные, и они однозначно ложатся на локализованные
строки HAE из находки 37:
```
Основная 692 → HKCategoryValueSleepAnalysisAsleepCore
Бодрствование 568 → HKCategoryValueSleepAnalysisAwake
БДГ 206 → HKCategoryValueSleepAnalysisAsleepREM
Во сне 171 → HKCategoryValueSleepAnalysisAsleepUnspecified
Глубокий 94 → HKCategoryValueSleepAnalysisAsleepDeep
В кровати 38 → HKCategoryValueSleepAnalysisInBed
```
То есть первую и главную часть словаря не надо составлять вручную — она
выводится сопоставлением потока с экспортом за тот же период.
## 44. `Correlation` — структурный элемент, и он появился только что
Давление приезжает не записью, а обёрткой из двух записей:
```xml
<Correlation type="HKCorrelationTypeIdentifierBloodPressure" sourceName="PICOOC"
startDate="2026-01-01 09:41:49 +0300" endDate="2026-01-01 09:41:49 +0300">
<Record type="HKQuantityTypeIdentifierBloodPressureSystolic" />
<Record type="HKQuantityTypeIdentifierBloodPressureDiastolic" />
</Correlation>
```
Арифметика сходится: 9 элементов `Correlation` и по 18 записей систолического и
диастолического давления — ровно две записи на обёртку.
`Correlation` объявлен в DTD наравне с `Record` и `Workout`, но в данных до
2026 года не встречался ни разу. Парсер, написанный по одним лишь `Record`,
давление разберёт как две несвязанные метрики и потеряет то, что делает его
измерением — их **парность**.
## 45. DTD экспорта врёт, а имя файла локализовано
Две мелочи, каждая из которых ломает разбор на ровном месте.
**DTD расходится с данными.** В экспорте 2021 года (v11) `<!ATTLIST Me>`
объявляет четыре атрибута, а сам элемент `<Me>` несёт пять — лишний
`HKCharacteristicTypeIdentifierCardioFitnessMedicationsUse`. Валидировать
документ по его собственному DTD нельзя; разбираем то, что есть.
**Имя файла переведено.** Внутри архива лежит `экспорт.xml`, а не `export.xml`
— и так во всех девяти архивах начиная с 2021 года. Имя зависит от языка
телефона (`locale="ru_RU"` в корневом элементе). Захардкоженное `export.xml`
не найдёт ничего.
Там же, в `<Me>`, значение локализовано: `CardioFitnessMedicationsUse="Нет"`.
То есть правило «экспорт говорит кодами» верно для типов записей и категориальных
значений, но не для всего документа.
## Инструмент
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная