local-research: добавлены находки 37–41 о форме данных HAE

- категориальные значения приходят переведёнными на язык телефона, а родной
  экспорт Apple говорит кодами HealthKit — источники несопоставимы без словаря
- sleep_analysis шлёт две несовместимые схемы под одним именем, HRV несёт
  внутри точки серию ударов на 93% своего объёма и третий формат времени
- род агрегации из формы точки не выводится, но выводится сверкой слоёв
This commit is contained in:
av
2026-08-01 13:46:14 +03:00
parent 41f2f05f2f
commit 18d76d9610
+181
View File
@@ -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) ради
данных, которых, вероятно, нет ни в одном из планируемых запросов. Решать
после того, как станет ясна цена хранения нижнего слоя за год.