From 18d76d9610cf7f3b462f38424e480fb344b03227 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 1 Aug 2026 13:46:14 +0300 Subject: [PATCH] =?UTF-8?q?local-research:=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=B8=2037=E2=80=9341=20=D0=BE=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20HAE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - категориальные значения приходят переведёнными на язык телефона, а родной экспорт Apple говорит кодами HealthKit — источники несопоставимы без словаря - sleep_analysis шлёт две несовместимые схемы под одним именем, HRV несёт внутри точки серию ударов на 93% своего объёма и третий формат времени - род агрегации из формы точки не выводится, но выводится сверкой слоёв --- docs/local-research.md | 181 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/docs/local-research.md b/docs/local-research.md index d075b5f..4904e0c 100644 --- a/docs/local-research.md +++ b/docs/local-research.md @@ -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) ради + данных, которых, вероятно, нет ни в одном из планируемых запросов. Решать + после того, как станет ясна цена хранения нижнего слоя за год.