local-research: добавлены находки 37–41 о форме данных HAE
- категориальные значения приходят переведёнными на язык телефона, а родной экспорт Apple говорит кодами HealthKit — источники несопоставимы без словаря - sleep_analysis шлёт две несовместимые схемы под одним именем, HRV несёт внутри точки серию ударов на 93% своего объёма и третий формат времени - род агрегации из формы точки не выводится, но выводится сверкой слоёв
This commit is contained in:
@@ -1147,6 +1147,177 @@ iPad перестал числиться среди вкладчиков. Суд
|
||||
довод отказаться от посекундного слоя HAE в пользу `sample` из родного
|
||||
экспорта.
|
||||
|
||||
## 37. Категориальные значения переведены на язык телефона
|
||||
|
||||
HAE отдаёт перечислимые значения не кодами, а строками из локали iOS. По всему
|
||||
потоку:
|
||||
|
||||
```
|
||||
sleep_analysis.value Основная 692 Бодрствование 568 БДГ 206
|
||||
Во сне 171 Глубокий 94 В кровати 38
|
||||
heart_rate.context Не задано 3226 Сидячий образ жизни 2964 Активен 2565
|
||||
workouts.name В помещении Ходьба 22 На улице Ходьба 13
|
||||
```
|
||||
|
||||
Что это перевод, а не собственный словарь HAE, видно по порядку слов:
|
||||
«В помещении Ходьба» — машинная калька с `Indoor Walk`.
|
||||
|
||||
**Контрпример в том же пакете.** Секция `stateOfMind` устроена правильно и
|
||||
кодов не переводит:
|
||||
|
||||
```
|
||||
kind momentary_emotion, daily_mood
|
||||
valenceClassification neutral, slightly_pleasant
|
||||
labels ["drained", "calm"], ["relieved", "content"]
|
||||
associations ["hobbies"], ["work"]
|
||||
```
|
||||
|
||||
То есть HAE умеет отдавать стабильные коды HealthKit — просто для старых
|
||||
секций тянет строки из UI. Локаль известна: она приезжает в `Accept-Language`
|
||||
(находка 32).
|
||||
|
||||
### Следствие для модели
|
||||
|
||||
Три удара, и третий — по уже принятому решению:
|
||||
|
||||
1. Клиент не может опереться на «БДГ» — ему пришлось бы угадывать словарь.
|
||||
2. Смена языка телефона молча расколет историю: та же фаза сна станет другим
|
||||
значением, и по координатному ключу (находка 36) это выглядит как
|
||||
изменение данных, а не как переименование.
|
||||
3. **Родной экспорт Apple говорит на другом языке.** В XML лежит
|
||||
`HKCategoryValueSleepAnalysisAsleepREM`, а не «БДГ». Экспорт объявлен
|
||||
источником истины (находка 34), и на нём же держится план помечать старые
|
||||
данные HAE устаревшими — но сверить покрытие по этим полям нечем.
|
||||
|
||||
Решение: хранить дословно и **рядом** класть выведенный стабильный код по
|
||||
словарю `(локаль, строка) → код HealthKit`. Дословность не нарушена — код
|
||||
добавляется, а не подменяет строку. Для незнакомой строки код пустой, а не
|
||||
угаданный.
|
||||
|
||||
## 38. `sleep_analysis` — две несовместимые схемы под одним именем
|
||||
|
||||
Под одним именем метрики приезжают два разных объекта. Поэпизодный (1769 точек):
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-07-30 21:48:00 +0300",
|
||||
"start": "2026-07-30 21:48:00 +0300", "end": "2026-07-30 22:21:00 +0300",
|
||||
"startDate": "2026-07-30 21:48:00 +0300", "endDate": "2026-07-30 22:21:00 +0300",
|
||||
"qty": 0.55, "value": "Во сне", "source": "AutoSleep"
|
||||
}
|
||||
```
|
||||
|
||||
И суточная сводка (34 точки), где `date` — местная полночь:
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-07-30 00:00:00 +0300",
|
||||
"sleepStart": "2026-07-29 22:21:20 +0300", "sleepEnd": "2026-07-30 07:43:09 +0300",
|
||||
"inBedStart": "2026-07-29 22:21:20 +0300", "inBedEnd": "2026-07-30 07:43:09 +0300",
|
||||
"totalSleep": 7.430783703658317,
|
||||
"core": 5.448145056333808, "rem": 1.366182650923729,
|
||||
"deep": 0.6164559964007801, "awake": 1.9326458292537263,
|
||||
"asleep": 0, "inBed": 0,
|
||||
"source": "Apple Watch Ultra 3"
|
||||
}
|
||||
```
|
||||
|
||||
Общих полей, кроме `date` и `source`, нет вовсе. `asleep` и `inBed` в сводке
|
||||
занулены — похоже, наследие старой модели сна, реальные числа в
|
||||
`core`/`rem`/`deep`/`awake`.
|
||||
|
||||
Правило вывода слоя по выравниванию меток (находка 33) на сводке даст `hour`,
|
||||
потому что полночь выровнена по часу, — но это не часовой разрез, а суточный
|
||||
итог. Ещё и источники разные: эпизоды от `AutoSleep`, сводка от часов.
|
||||
|
||||
**Следствие:** в каталоге это два разных имени с двумя схемами. Хранение
|
||||
остаётся дословным, разводятся только имена — иначе самоописание вынуждено
|
||||
отдавать две схемы под одним ключом, и клиент обязан гадать, какая пришла.
|
||||
|
||||
## 39. У HRV внутри точки — своя серия ударов и третий формат времени
|
||||
|
||||
`heart_rate_variability` в нижнем слое несёт межударные интервалы:
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-07-31 00:16:36 +0300",
|
||||
"start": "2026-07-31 00:16:36 +0300", "end": "2026-07-31 00:17:35 +0300",
|
||||
"qty": 57.6404462528801, "source": "Apple Watch Ultra 3",
|
||||
"heartbeatSeries": [
|
||||
{"timeSinceStart": 0.359375, "date": 1785446196.4132624, "precededByGap": true},
|
||||
{"timeSinceStart": 1.5533214807510376, "date": 1785446197.6072087, "precededByGap": false}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
261 точка с серией, 13 392 удара, длина серии 35 / 51 / 70 (мин / сред / макс).
|
||||
**Серия занимает 93% объёма метрики** — 1178 КБ из 1267 КБ.
|
||||
|
||||
Внутри серии время — **Unix-эпоха дробным числом**. Это третий формат в потоке
|
||||
сверх двух известных:
|
||||
|
||||
```
|
||||
локальное со смещением 34 359 data.metrics[].data[].end = 2026-07-31 08:00:00 +0300
|
||||
число (эпоха) 2 940 …heartbeatSeries[].date = 1785446196.4132624
|
||||
RFC3339 Z 20 data.stateOfMind[].end = 2026-07-31T18:03:51Z
|
||||
```
|
||||
|
||||
Перепись по 25 доставкам; иных форматов не встретилось.
|
||||
|
||||
## 40. Род агрегации из формы точки не выводится — но выводится из слоёв
|
||||
|
||||
Проверялась гипотеза, которая избавила бы от ручной разметки: HAE при
|
||||
группировке сам показывает род метрики — накопительная сворачивается в `qty`,
|
||||
мгновенная в `Avg`/`Min`/`Max`. **Гипотеза опровергнута.** Перепись форм по
|
||||
всему потоку: `Avg`/`Min`/`Max` есть только у `heart_rate`. Заведомо
|
||||
мгновенные `walking_speed`, `respiratory_rate`, `blood_oxygen_saturation`
|
||||
приходят в `qty` ровно так же, как шаги.
|
||||
|
||||
Единицы дают процентов девяносто — `count/min`, `%`, `ms`, `m/s`, `km/hr`,
|
||||
`degC`, `dBASPL` мгновенные, `kJ`, `min`, `km`, `count`, `hr` накопительные, —
|
||||
но ломаются на краях: `six_minute_walking_test_distance` в метрах это
|
||||
результат теста, два теста не складывают, а `walking_running_distance` в
|
||||
километрах — складывают.
|
||||
|
||||
**Род выводится измерением, а не разметкой.** Одна метрика лежит в минутном и
|
||||
часовом разрезе одновременно; если часовое значение сходится с суммой
|
||||
минутных — накопительная, если со средним — мгновенная. Это та же проверка,
|
||||
что делает стенд сходимости (находка 35), только по всем метрикам и с записью
|
||||
результата в каталог. Где данных не хватает (`vo2_max` — восемь точек), род
|
||||
остаётся неизвестным, и агрегация по такой метрике не предлагается вовсе:
|
||||
отдаём значения как есть.
|
||||
|
||||
## 41. Чистить есть смысл только нижний слой
|
||||
|
||||
89 доставок, 16 МБ архива. Координат (`метрика + слой + метка`) по слоям:
|
||||
|
||||
| слой | метрик | координат | в сутки | в год |
|
||||
|--------|-------:|----------:|---------:|------:|
|
||||
| raw | 30 | 433 397 | ~100 000 | ~36 млн |
|
||||
| minute | 29 | 9 253 | ~3 700 | ~1.4 млн |
|
||||
| hour | 30 | 398 | ~100 | ~36 тыс |
|
||||
|
||||
Разница между слоями — три порядка. Удаление часового и минутного слоёв не
|
||||
экономит ничего, но ломает ответы на исторические запросы; всё давление по
|
||||
объёму создаёт нижний слой. Ровно там родной экспорт Apple и оказывается
|
||||
настоящим надмножеством (находка 34), так что ретеншен имеет смысл только для
|
||||
него.
|
||||
|
||||
**Столкновения на координатном ключе:** из 443 048 координат 2 905 (0.66%)
|
||||
несут под одним ключом разные значения. Разбор выборки показывает, что почти
|
||||
всё это — не расхождение чисел, а разный набор полей:
|
||||
|
||||
```
|
||||
ключ: apple_stand_hour, hour, 2026-07-31 07:00:00 +0300
|
||||
{"date": "…07:00:00 +0300", "qty": 1, "start": "…07:00:00", "end": "…08:00:00"}
|
||||
{"date": "…07:00:00 +0300", "qty": 1}
|
||||
```
|
||||
|
||||
По метрикам: `heart_rate` 983, `walking_running_distance` 560, `step_count`
|
||||
507, `active_energy` 406, `basal_energy_burned` 381. При слиянии выигрывает
|
||||
более полная точка, а не последняя пришедшая, — иначе бедная доставка стирает
|
||||
`start`/`end` у богатой.
|
||||
|
||||
## Инструмент
|
||||
|
||||
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
||||
@@ -1178,3 +1349,13 @@ python3 tmp/research/hl.py workouts тренировки, ряд
|
||||
доставок.
|
||||
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
|
||||
`heartRateNotifications`, `cycleTracking`, `medications`.
|
||||
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
|
||||
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
|
||||
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
|
||||
(находка 41) к ним неприменим — их придётся хранить вечно.
|
||||
- **Полнота словаря переводов (находка 37).** Известны значения только русской
|
||||
локали и только для трёх полей. Не проверено, переводятся ли `symptoms` и
|
||||
`cycleTracking`, и совпадут ли строки после обновления iOS.
|
||||
- **Хранить ли `heartbeatSeries` целиком.** 93% объёма HRV (находка 39) ради
|
||||
данных, которых, вероятно, нет ни в одном из планируемых запросов. Решать
|
||||
после того, как станет ясна цена хранения нижнего слоя за год.
|
||||
|
||||
Reference in New Issue
Block a user