local-research: добавлены находки 37–41 о форме данных HAE
- категориальные значения приходят переведёнными на язык телефона, а родной экспорт Apple говорит кодами HealthKit — источники несопоставимы без словаря - sleep_analysis шлёт две несовместимые схемы под одним именем, HRV несёт внутри точки серию ударов на 93% своего объёма и третий формат времени - род агрегации из формы точки не выводится, но выводится сверкой слоёв
This commit is contained in:
@@ -1147,6 +1147,177 @@ iPad перестал числиться среди вкладчиков. Суд
|
|||||||
довод отказаться от посекундного слоя HAE в пользу `sample` из родного
|
довод отказаться от посекундного слоя 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, только стандартная
|
Разбор ведётся скриптом `tmp/research/hl.py` (Python 3, только стандартная
|
||||||
@@ -1178,3 +1349,13 @@ python3 tmp/research/hl.py workouts тренировки, ряд
|
|||||||
доставок.
|
доставок.
|
||||||
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
|
- **Секции, которых мы не видели живьём:** `symptoms`, `ecg`,
|
||||||
`heartRateNotifications`, `cycleTracking`, `medications`.
|
`heartRateNotifications`, `cycleTracking`, `medications`.
|
||||||
|
- **Что из этих секций вообще есть в родном экспорте.** ЭКГ выгружается
|
||||||
|
отдельными CSV, а не в XML. Если `stateOfMind`, симптомы или лекарства в
|
||||||
|
экспорте отсутствуют, то по ним экспорт не источник истины, и ретеншен
|
||||||
|
(находка 41) к ним неприменим — их придётся хранить вечно.
|
||||||
|
- **Полнота словаря переводов (находка 37).** Известны значения только русской
|
||||||
|
локали и только для трёх полей. Не проверено, переводятся ли `symptoms` и
|
||||||
|
`cycleTracking`, и совпадут ли строки после обновления iOS.
|
||||||
|
- **Хранить ли `heartbeatSeries` целиком.** 93% объёма HRV (находка 39) ради
|
||||||
|
данных, которых, вероятно, нет ни в одном из планируемых запросов. Решать
|
||||||
|
после того, как станет ясна цена хранения нижнего слоя за год.
|
||||||
|
|||||||
Reference in New Issue
Block a user