local-research: разобран формат экспорта Apple за 5.5 лет
- девять экспортов, версии 11→13→14; ни один тип записи не исчез, только добавлялись — разбор можно писать аддитивно - Correlation (давление) появился только в 2026 и ломает парсер по одним Record; имя файла локализовано, DTD расходится с данными - коды HealthKit переименовываются: те же записи сна приезжают как Asleep из экспорта 2021 и как AsleepUnspecified из свежего
This commit is contained in:
@@ -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>` с
|
- потоковый разбор `экспорт.xml` в слой `sample`, включая `Correlation`;
|
||||||
`startDate`/`endDate`/`value`/`sourceName`/`device`) в слой `sample`;
|
- `HeartRateVariabilityMetadataList` с `InstantaneousBeatsPerMinute` — это
|
||||||
- маршруты GPX и ЭКГ отдельными CSV — они не в XML;
|
тот же `heartbeatSeries`, что в HAE (находка 39), 1.25 млн ударов;
|
||||||
- заливка кусками по годам: файл измеряется сотнями мегабайт.
|
- маршруты GPX и ЭКГ отдельными файлами — их в XML нет;
|
||||||
|
- заливка кусками по годам, идемпотентно: повторный импорт того же архива не
|
||||||
|
должен ничего менять;
|
||||||
|
- `export_cda.xml` игнорируем — это клинический формат тех же данных.
|
||||||
|
|
||||||
Готово, когда история за несколько лет лежит в слое `sample`, а суммы по нему
|
Готово, когда история за несколько лет лежит в слое `sample`, повторный импорт
|
||||||
сходятся с часовым слоем HAE на пересечении периодов.
|
не меняет ничего, а суммы по слою сходятся с часовым слоем HAE на пересечении
|
||||||
|
периодов.
|
||||||
|
|
||||||
Есть готовый файл для проверки: `/home/av/MediaEverything/HealthData/apple_health/`.
|
Архивы: `/home/av/MediaEverything/HealthData/apple_health/` — девять штук,
|
||||||
|
2021-12 … 2026-08. Старые версии формата годятся как регрессионный набор.
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,19 @@
|
|||||||
появятся сами. Задача — не пропустить момент: убедиться, что новые секции
|
появятся сами. Задача — не пропустить момент: убедиться, что новые секции
|
||||||
разбираются, а не молча падают в `parse_status`.
|
разбираются, а не молча падают в `parse_status`.
|
||||||
|
|
||||||
Отдельный вопрос, на который ответят эти же данные: есть ли эти секции в родном
|
Часть вопроса закрыта разбором экспортов (находка 42): в Health эти данные
|
||||||
экспорте Apple. Если нет — экспорт им не источник истины, и устаревание
|
**есть** и в экспорте они присутствуют — `BodyMass` (1127 записей),
|
||||||
нижнего слоя к ним неприменимо, держим всегда.
|
`BloodPressureSystolic`/`Diastolic` (по 18), `BodyTemperature` (11), `Headache`
|
||||||
|
(36), `SexualActivity` (46), `Dietary*` (по 88). Значит вопрос не «есть ли
|
||||||
|
данные», а «доедут ли они через HAE и в какой форме».
|
||||||
|
|
||||||
|
Остаётся непроверенным `stateOfMind`: в экспорте его нет ни одним типом. Если
|
||||||
|
подтвердится, что Apple его не выгружает, то экспорт ему не источник истины —
|
||||||
|
устаревание нижнего слоя к нему неприменимо, держим всегда.
|
||||||
|
|
||||||
|
Давление приезжает обёрткой `Correlation` из двух записей (находка 44) — в
|
||||||
|
экспорте точно, а вот как его отдаёт HAE, неизвестно. Это первое, на что
|
||||||
|
смотреть, когда данные появятся.
|
||||||
|
|
||||||
Готово, когда каждая новая секция либо разобрана, либо явно описана в
|
Готово, когда каждая новая секция либо разобрана, либо явно описана в
|
||||||
`docs/local-research.md` как не пришедшая, и ни одна не числится в ошибках
|
`docs/local-research.md` как не пришедшая, и ни одна не числится в ошибках
|
||||||
|
|||||||
@@ -15,6 +15,24 @@ HAE отдаёт перечислимые значения строками ло
|
|||||||
код. Словарь ключуется парой `(локаль, строка)`, локаль берётся из
|
код. Словарь ключуется парой `(локаль, строка)`, локаль берётся из
|
||||||
`Accept-Language`. Незнакомая строка → пустой код, а не догадка.
|
`Accept-Language`. Незнакомая строка → пустой код, а не догадка.
|
||||||
|
|
||||||
|
**Словарь фаз сна уже выведен** сопоставлением потока с экспортом за тот же
|
||||||
|
период (находка 43) — составлять руками не нужно:
|
||||||
|
|
||||||
|
```
|
||||||
|
Основная → AsleepCore Бодрствование → Awake БДГ → AsleepREM
|
||||||
|
Глубокий → AsleepDeep В кровати → InBed Во сне → AsleepUnspecified
|
||||||
|
```
|
||||||
|
|
||||||
|
Тем же способом добираются `heart_rate.context` и типы тренировок.
|
||||||
|
|
||||||
|
Осложнение, всплывшее на истории экспортов: **коды тоже не вечны.** Одни и те
|
||||||
|
же записи сна приезжают как `…Asleep` в экспорте 2021 года и как
|
||||||
|
`…AsleepUnspecified` в экспорте 2026-го: Apple переименовала значение и
|
||||||
|
переписывает историю при выгрузке (находка 43). Значит словарь должен
|
||||||
|
переживать переименование самих кодов, иначе после обновления iOS история
|
||||||
|
расколется вторично — уже на «стабильной» стороне. Простейшее решение: хранить код как есть, а
|
||||||
|
эквивалентность старых и новых имён держать отдельной таблицей синонимов.
|
||||||
|
|
||||||
Готово, когда фазы сна из потока и из экспорта Apple сравниваются напрямую, а
|
Готово, когда фазы сна из потока и из экспорта Apple сравниваются напрямую, а
|
||||||
`/stats` показывает строки, для которых кода ещё нет.
|
`/stats` показывает строки, для которых кода ещё нет.
|
||||||
|
|
||||||
|
|||||||
@@ -1318,6 +1318,115 @@ RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51
|
|||||||
более полная точка, а не последняя пришедшая, — иначе бедная доставка стирает
|
более полная точка, а не последняя пришедшая, — иначе бедная доставка стирает
|
||||||
`start`/`end` у богатой.
|
`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, только стандартная
|
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
||||||
|
|||||||
Reference in New Issue
Block a user