пересмотрена архитектура под слои, свёртку в ответе и MCP
- агрегация появилась в ответе на запрос: род метрики измеряется сверкой слоёв, нижний слой HAE не суммируется никогда - переведённые строки хранятся дословно с приписанным кодом HealthKit; снято правило «настройки данных у всех проходов одинаковы» — слой в ключе - добавлены устаревание нижнего слоя по проверенному экспорту и MCP поверх read API; план вырос до 11 шагов
This commit is contained in:
@@ -6,10 +6,12 @@
|
|||||||
|
|
||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export,
|
Коллектор данных Apple Health. Принимает выгрузки из Health Auto Export и
|
||||||
хранит их и отдаёт другим моим проектам через HTTP API. Это **хранилище, а не
|
родного экспорта Apple, хранит их и отдаёт другим моим проектам — через HTTP
|
||||||
аналитика**: принять, дедуплицировать, сохранить, отдать. Не считать агрегаты,
|
API и через MCP. Это **хранилище, а не аналитика**: принять, дедуплицировать,
|
||||||
не переименовывать поля Apple, не интерпретировать значения.
|
сохранить, отдать. Не переименовывать поля Apple, не интерпретировать
|
||||||
|
значения. Агрегат считается только в ответе на запрос и только там, где род
|
||||||
|
метрики измерен.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
@@ -27,18 +29,26 @@ Module path — `git.vakhrushev.me/av/healthlog`.
|
|||||||
отбрасывать внутри точки — срок хранения архива станет сроком жизни данных.
|
отбрасывать внутри точки — срок хранения архива станет сроком жизни данных.
|
||||||
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
|
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор:
|
||||||
битый JSON — 400, непонятое содержимое — 200.
|
битый JSON — 400, непонятое содержимое — 200.
|
||||||
- **Ничего не теряем молча.** Идентичность — хеш **канонизированного**
|
- **Ничего не теряем молча.** Идентичность — координаты
|
||||||
(рекурсивно отсортированного) содержимого: повтор не меняет ничего,
|
(`метрика + слой + метка`); `source` в ключ не входит, он нестабилен. Хеш
|
||||||
различие сохраняется. Изменение запечатанного часа — `WARN`, но данные
|
канонизированного содержимого остался детектором изменений. При
|
||||||
всё равно пишутся.
|
столкновении выигрывает **более полная** точка, а не последняя. Изменение
|
||||||
- **Дыры закрываются сами.** Три прохода синхронизации разной глубины
|
запечатанного часа — `WARN`, но данные всё равно пишутся.
|
||||||
(5 минут / час / сутки), настройки данных у всех одинаковы.
|
- **Дыры закрываются сами.** Три прохода разной глубины (5 минут / сутки /
|
||||||
|
неделя). Настройки данных у проходов теперь **разные** — намеренно, они
|
||||||
|
наполняют разные слои; это безопасно ровно потому, что слой входит в ключ.
|
||||||
- **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано
|
- **Форма Apple не транслируется.** Значения отдаём как пришли, нормализовано
|
||||||
только время (`ts_utc` + офсет исходной зоны).
|
только время (`ts_utc` + офсет исходной зоны). Единственное добавление —
|
||||||
- **Своей агрегации нет — есть слои.** Метрика хранится в той подробности, в
|
стабильный код рядом с переведённой строкой: HAE отдаёт «БДГ» и «Сидячий
|
||||||
какой пришла (`raw`/`minute`/`hour`); слой выводится из выравнивания меток,
|
образ жизни» на языке телефона, а родной экспорт — коды HealthKit, и без
|
||||||
а не из заголовка HAE — тот врёт. Клиенту показываем каталог разрезов, выбор
|
словаря эти два источника не сойтись.
|
||||||
за ним.
|
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в той
|
||||||
|
подробности, в какой пришла (`sample`/`raw`/`minute`/`hour`); слой выводится
|
||||||
|
из выравнивания меток, а не из заголовка HAE — тот врёт.
|
||||||
|
- **Агрегация в ответе — только измеренная.** Род свёртки выводится сверкой
|
||||||
|
слоёв между собой (часовое = сумма минутных → накопительная, = среднее →
|
||||||
|
мгновенная), а не размечается руками. Род неизвестен — свёртки нет. И
|
||||||
|
никогда не суммируем нижний слой HAE: это интерполяция, а не сэмплы.
|
||||||
- **Секреты не в логах** — токены приёма и чтения. Данные о здоровье
|
- **Секреты не в логах** — токены приёма и чтения. Данные о здоровье
|
||||||
чувствительны: тела запросов только на `DEBUG` и с обрезкой.
|
чувствительны: тела запросов только на `DEBUG` и с обрезкой.
|
||||||
|
|
||||||
|
|||||||
@@ -17,41 +17,57 @@ healthlog делает это один раз. Телефон шлёт данн
|
|||||||
## Границы
|
## Границы
|
||||||
|
|
||||||
Это **хранилище**, а не аналитика. healthlog принимает, дедуплицирует,
|
Это **хранилище**, а не аналитика. healthlog принимает, дедуплицирует,
|
||||||
хранит и отдаёт. Он не считает агрегаты, не переименовывает поля Apple и не
|
хранит и отдаёт. Он не переименовывает поля Apple и не интерпретирует
|
||||||
интерпретирует значения — этим занимается тот, кто данные читает.
|
значения — этим занимается тот, кто данные читает.
|
||||||
|
|
||||||
Единственный источник — Health Auto Export (куплен, пожизненный премиум).
|
Одну уступку хранилище всё же делает: оно умеет свести метрику к запрошенной
|
||||||
Другие источники не поддерживаем.
|
сетке («шаги по дням»). Иначе каждый из клиентов повторял бы одну и ту же
|
||||||
|
логику выбора слоя, а ошибиться в ней легко — просуммировать не тот разрез и
|
||||||
|
получить завышение втрое. Но род свёртки не проставлен вручную, а **измерен**
|
||||||
|
сверкой слоёв между собой; где измерить не вышло, свёртка не предлагается
|
||||||
|
вовсе.
|
||||||
|
|
||||||
|
Источников два: Health Auto Export (куплен, пожизненный премиум) — ежедневный
|
||||||
|
поток, и родной экспорт Apple Health раз в 2–3 месяца — источник истины для
|
||||||
|
нижнего слоя.
|
||||||
|
|
||||||
## Как устроено
|
## Как устроено
|
||||||
|
|
||||||
```
|
```
|
||||||
iPhone ──HTTPS POST──► healthlog ──► сырой архив (файлы, .json.gz)
|
iPhone ──HTTPS POST──► healthlog ──► сырой архив (.json.gz, 14 дней)
|
||||||
│ │
|
│ │
|
||||||
│ └── источник истины, не трогаем
|
│ └── страховка разбора, не склад
|
||||||
▼
|
▼
|
||||||
SQLite-витрина ──► HTTP read API ──► мои приложения
|
SQLite ──┬──► HTTP read API ──► мои приложения
|
||||||
(пересобирается из архива)
|
(точки по │
|
||||||
|
слоям) └──► MCP ───────────► агенты
|
||||||
|
▲
|
||||||
|
экспорт Apple ──────────────┘ нижний слой, раз в 2–3 месяца
|
||||||
```
|
```
|
||||||
|
|
||||||
Приём сначала кладёт тело запроса на диск как есть и только потом разбирает.
|
Приём сначала кладёт тело запроса на диск как есть и только потом разбирает.
|
||||||
Значит, ошибка в нашем разборе не может привести к потере данных: витрина
|
Значит, ошибка в нашем разборе не может привести к потере данных: хранилище
|
||||||
пересобирается из архива командой `healthlog reindex`.
|
пересобирается из архива командой `healthlog reindex`. Архив при этом
|
||||||
|
недолговечен — дальше истина в самих точках, и потому точки хранятся
|
||||||
|
дословно.
|
||||||
|
|
||||||
Подробности — [docs/architecture.md](docs/architecture.md).
|
Подробности — [docs/architecture.md](docs/architecture.md).
|
||||||
|
|
||||||
## Состояние
|
## Состояние
|
||||||
|
|
||||||
В разработке. Готовы шаги 1–2 из 8: сервис принимает пакеты и складывает их в
|
В разработке. Готовы шаги 1–2 из 11: сервис принимает пакеты и складывает их в
|
||||||
сырой архив. Разбора, витрины и read API ещё нет — план в
|
сырой архив. Разбора, хранилища и read API ещё нет — план в
|
||||||
[docs/plan.md](docs/plan.md).
|
[docs/plan.md](docs/plan.md).
|
||||||
|
|
||||||
|
Разведка формата закончена: 41 находка на живом потоке, половина расходится с
|
||||||
|
документацией Health Auto Export — [docs/local-research.md](docs/local-research.md).
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
```
|
```
|
||||||
healthlog serve приём + read API
|
healthlog serve приём + read API + MCP
|
||||||
healthlog import заливка файлов в архив и витрину (шаг 6)
|
healthlog import родной экспорт Apple Health (шаг 8)
|
||||||
healthlog reindex пересборка витрины из сырого архива (шаг 3)
|
healthlog reindex пересборка хранилища из архива (шаг 3)
|
||||||
healthlog healthcheck проверка живости для docker HEALTHCHECK
|
healthlog healthcheck проверка живости для docker HEALTHCHECK
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+254
-31
@@ -3,8 +3,10 @@
|
|||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
healthlog принимает выгрузки Apple Health из приложения Health Auto Export
|
healthlog принимает выгрузки Apple Health из приложения Health Auto Export
|
||||||
(далее HAE), хранит их и отдаёт другим приложениям. Он не обрабатывает данные:
|
(далее HAE) и родного экспорта Apple Health, хранит их и отдаёт другим
|
||||||
не считает агрегаты, не переименовывает поля, не интерпретирует значения.
|
приложениям — в том числе агентам, через MCP. Он не переименовывает поля и не
|
||||||
|
интерпретирует значения; агрегаты считает только в ответе на запрос и только
|
||||||
|
там, где род метрики измерен, а не угадан.
|
||||||
|
|
||||||
## Принципы
|
## Принципы
|
||||||
|
|
||||||
@@ -20,18 +22,25 @@ healthlog принимает выгрузки Apple Health из приложен
|
|||||||
объекты — прямое следствие пункта выше, см. «Хранилище».
|
объекты — прямое следствие пункта выше, см. «Хранилище».
|
||||||
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
|
- **Сохранили — значит приняли.** Код ответа отражает доставку, а не разбор
|
||||||
(см. «Приём»).
|
(см. «Приём»).
|
||||||
- **Ничего не теряем молча.** Идентичность — хеш канонизированного
|
- **Ничего не теряем молча.** Идентичность — устойчивые координаты
|
||||||
содержимого: повтор не меняет ничего, различие сохраняется. Схлопывания «на
|
(`метрика + слой + метка времени`); `source` в ключ не входит, он
|
||||||
всякий случай» нет.
|
нестабилен. Хеш канонизированного содержимого остаётся детектором изменений,
|
||||||
|
чтобы не писать зря. При столкновении выигрывает более полная точка, а не
|
||||||
|
последняя пришедшая: бедная доставка не должна стирать поля у богатой.
|
||||||
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
|
- **Дыры закрываются сами.** Данные приходят несколькими проходами разной
|
||||||
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
|
глубины, поэтому пропущенная доставка не оставляет постоянного пробела —
|
||||||
см. «Модель синхронизации».
|
см. «Модель синхронизации».
|
||||||
- **Форма Apple не транслируется.** Значения отдаются такими, какими пришли;
|
- **Форма Apple не транслируется.** Значения отдаются такими, какими пришли;
|
||||||
нормализовано только время.
|
нормализовано только время. Единственное добавление — стабильный код рядом
|
||||||
- **Своей агрегации нет — есть разрезы.** Метрика хранится в тех слоях
|
с переведённой строкой (см. «Категориальные значения»): он приписывается, а
|
||||||
подробности, в которых пришла (`raw`/`minute`/`hour`); сводить их к одному
|
не подменяет.
|
||||||
или досчитывать свои значило бы принимать предметные решения, которых
|
- **Своей агрегации в хранении нет — есть слои.** Метрика лежит в тех
|
||||||
хранилище принять не может.
|
разрезах подробности, в которых пришла (`sample`/`raw`/`minute`/`hour`);
|
||||||
|
переагрегирования при записи не происходит никогда.
|
||||||
|
- **Агрегация в ответе — только измеренная.** Read API умеет свести метрику к
|
||||||
|
запрошенной сетке, но род свёртки (сумма или среднее) выведен сверкой слоёв
|
||||||
|
между собой, а не проставлен вручную. Где род неизвестен, агрегация не
|
||||||
|
предлагается: отдаются значения как есть.
|
||||||
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
|
- **Минимум компонентов** — один процесс, SQLite, файлы. Без очередей и
|
||||||
внешних зависимостей.
|
внешних зависимостей.
|
||||||
|
|
||||||
@@ -99,8 +108,11 @@ HRV); у накопительных — только `date`. Поэтому то
|
|||||||
|
|
||||||
## Модель синхронизации
|
## Модель синхронизации
|
||||||
|
|
||||||
Данные приходят **тремя проходами разной глубины** — три автоматизации HAE с
|
У модели два независимых измерения: **глубина окна** (как далеко назад
|
||||||
одинаковыми настройками данных, различающиеся только расписанием и периодом:
|
переспрашиваем) и **подробность** (в какой слой попадут данные). Проходы
|
||||||
|
задаются их сочетанием.
|
||||||
|
|
||||||
|
По глубине — три прохода, догоняющие друг друга:
|
||||||
|
|
||||||
| проход | расписание | период | зачем |
|
| проход | расписание | период | зачем |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -108,6 +120,25 @@ HRV); у накопительных — только `date`. Поэтому то
|
|||||||
| средний | 3–4 раза в день | **Today** | чинит пропуски за сутки |
|
| средний | 3–4 раза в день | **Today** | чинит пропуски за сутки |
|
||||||
| глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное |
|
| глубокий | раз в сутки | **Previous 7 Days** | чинит всё остальное |
|
||||||
|
|
||||||
|
По подробности — что в какой слой:
|
||||||
|
|
||||||
|
| подробность | набор метрик | слой |
|
||||||
|
|---|---|---|
|
||||||
|
| без группировки | только несуммируемые: сон, пульс, HRV | `raw` |
|
||||||
|
| минутная | все метрики здоровья | `minute` |
|
||||||
|
| часовая | все метрики здоровья | `hour` |
|
||||||
|
| ручной экспорт | всё, раз в 2–3 месяца | `sample` |
|
||||||
|
|
||||||
|
Набор нижнего слоя определяется не важностью метрики, а тем, **можно ли её
|
||||||
|
складывать**. Для пульса и HRV посекундная подробность несёт форму сигнала,
|
||||||
|
которой в минутном разрезе нет. Для шагов и энергии нижний слой HAE — это
|
||||||
|
интерполяция, которая не сходится в сверке (находка 34); держать её значило бы
|
||||||
|
хранить втрое больший объём ради худших чисел.
|
||||||
|
|
||||||
|
Секции без группировки в интерфейсе HAE (`stateOfMind`, `symptoms`, `ecg`,
|
||||||
|
`heartRateNotifications`, `cycleTracking`, `medications`) идут как есть — у них
|
||||||
|
подробности нет, есть только глубина окна.
|
||||||
|
|
||||||
Средний и глубокий проходы используют **фиксированные окна, а не метку
|
Средний и глубокий проходы используют **фиксированные окна, а не метку
|
||||||
синхронизации** — это принципиально. Инкрементальный режим проверен и
|
синхронизации** — это принципиально. Инкрементальный режим проверен и
|
||||||
**теряет данные**: в окне, которое он якобы покрыл, широкая выгрузка нашла
|
**теряет данные**: в окне, которое он якобы покрыл, широкая выгрузка нашла
|
||||||
@@ -133,10 +164,35 @@ HRV); у накопительных — только `date`. Поэтому то
|
|||||||
переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша,
|
переприсылает неделю, но это 26 метрик × 168 часов ≈ 4400 сравнений хеша,
|
||||||
почти все из которых сойдутся, и записи не будет.
|
почти все из которых сойдутся, и записи не будет.
|
||||||
|
|
||||||
**Обязательное правило: настройки данных у всех трёх проходов одинаковы** —
|
**Прежнее правило «настройки данных у всех проходов одинаковы» снято.** Оно
|
||||||
тот же набор метрик, то же суммирование, та же группировка. Иначе одна метрика
|
существовало потому, что метрика, приехавшая с разной группировкой,
|
||||||
приезжает из разных источников с разной гранулярностью, значения по одному
|
перетирала сама себя по одному ключу (находка 14). С тех пор слой вошёл в
|
||||||
ключу расходятся и проходы начинают перетирать друг друга (находка 14).
|
ключ, и минутная точка с часовой больше не сталкиваются — они в разных рядах.
|
||||||
|
Именно это и позволяет наполнять слои разными автоматизациями намеренно.
|
||||||
|
|
||||||
|
Условие, при котором это безопасно: слой выводится **из выравнивания меток, а
|
||||||
|
не из настройки автоматизации**. Перенастроил автоматизацию — данные просто
|
||||||
|
пойдут в другой слой, без порчи уже накопленного.
|
||||||
|
|
||||||
|
### Досчёт задним числом
|
||||||
|
|
||||||
|
Метрики правятся после факта, и глубина правки резко разная по классам:
|
||||||
|
|
||||||
|
- **Количественные** (пульс, шаги, энергия) человек руками не правит; они
|
||||||
|
опаздывают на часы. Наблюдались правки хвоста возрастом до 22 минут.
|
||||||
|
Недельного глубокого прохода достаточно.
|
||||||
|
- **Ручные записи** (`symptoms`, `medications`, `stateOfMind`,
|
||||||
|
`cycleTracking`) заводятся задним числом на недели и месяцы — симптом или
|
||||||
|
приём лекарства можно отметить за прошлую дату.
|
||||||
|
|
||||||
|
Поэтому окно досчёта **не единое**. Растягивать глубокий проход на месяц по
|
||||||
|
всем метрикам в минутном разрезе нельзя: тела запросов и так доходили до
|
||||||
|
42 МБ (находка 23), а месяц минутных данных — это десятки мегабайт на каждую
|
||||||
|
доставку. Вместо этого редкий широкий проход **только по ручным секциям**: их
|
||||||
|
единицы записей, и месячное окно там почти ничего не стоит.
|
||||||
|
|
||||||
|
Последние пришедшие данные всегда актуализируют картину — правило слияния
|
||||||
|
одинаково для всех проходов, порядок прихода значения не имеет.
|
||||||
|
|
||||||
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
|
Автоматизации различимы по заголовку `automation-id`; имена стоит задать,
|
||||||
иначе `automation-name` приходит пустым (находка 12).
|
иначе `automation-name` приходит пустым (находка 12).
|
||||||
@@ -191,6 +247,36 @@ HRV); у накопительных — только `date`. Поэтому то
|
|||||||
хватает. Вечно хранить его незачем — часовые объекты держат те же точки
|
хватает. Вечно хранить его незачем — часовые объекты держат те же точки
|
||||||
дословно, так что архив дублировал бы данные, а не страховал их.
|
дословно, так что архив дублировал бы данные, а не страховал их.
|
||||||
|
|
||||||
|
### Устаревание нижнего слоя
|
||||||
|
|
||||||
|
Родной экспорт Apple Health точнее HAE (находка 34) и делается раз в 2–3
|
||||||
|
месяца. Данные HAE в нижнем слое старше последнего экспорта избыточны: тот же
|
||||||
|
период лежит в слое `sample` подробнее и честнее.
|
||||||
|
|
||||||
|
Два ограничения, без которых правило опасно:
|
||||||
|
|
||||||
|
**Пометка вешается по загруженному экспорту, а не по сделанному.** Условие —
|
||||||
|
экспорт разобран, и проверено, что он **покрывает период**: непрерывность по
|
||||||
|
дням и сходимость сумм с часовым слоем. Иначе срок жизни данных повисает на
|
||||||
|
ручной операции, которую можно забыть или сделать наполовину, — а этот
|
||||||
|
механизм уже протекал: «Since Last Sync» молча потерял 13 961 точку, включая
|
||||||
|
ночь сна целиком (находка 29).
|
||||||
|
|
||||||
|
**Чистится только нижний слой.** Разница между слоями — три порядка: `hour`
|
||||||
|
это ~100 координат в сутки, `minute` ~3 700, `raw` ~100 000 (находка 41).
|
||||||
|
Удаление верхних слоёв не экономит ничего, но ломает ответы на исторические
|
||||||
|
запросы. Всё давление по объёму создаёт нижний слой, и ровно там экспорт —
|
||||||
|
настоящее надмножество.
|
||||||
|
|
||||||
|
Пометка «устарело» **не равна удалению**: сперва данные помечаются и остаются
|
||||||
|
доступными, удаление — отдельный шаг с собственным сроком. Пока восстановление
|
||||||
|
из экспорта не проверено на живых данных хотя бы раз, удаление не включается
|
||||||
|
вовсе.
|
||||||
|
|
||||||
|
Оговорка: для секций, которых в экспорте нет (ЭКГ выгружается отдельными CSV,
|
||||||
|
судьба `stateOfMind` и лекарств не проверена), экспорт источником истины не
|
||||||
|
является и правило к ним неприменимо — их держим всегда.
|
||||||
|
|
||||||
**Это осознанная смена источника истины.** Пока тело в архиве, истина — оно;
|
**Это осознанная смена источника истины.** Пока тело в архиве, истина — оно;
|
||||||
после удаления истиной остаются часовые объекты. Инвариант, который держит
|
после удаления истиной остаются часовые объекты. Инвариант, который держит
|
||||||
конструкцию: **объект хранит точки дословно**. Если разбор начнёт что-то
|
конструкцию: **объект хранит точки дословно**. Если разбор начнёт что-то
|
||||||
@@ -245,10 +331,24 @@ minute метки выровнены на минуту heart_rate 00:02:
|
|||||||
hour метки выровнены на час heart_rate 00:00:00
|
hour метки выровнены на час heart_rate 00:00:00
|
||||||
```
|
```
|
||||||
|
|
||||||
Почему не своя агрегация: сложить точки в часовые средние нетрудно, но
|
Почему не переагрегируем **при записи**: правильный способ свёртки зависит от
|
||||||
правильный способ зависит от метрики (сумма для энергии, среднее для пульса,
|
метрики (сумма для энергии, среднее для пульса), и ошибка здесь необратима —
|
||||||
максимум для чего-то ещё), и это уже предметное знание, которого у хранилища
|
исходные точки уже не вернуть. При записи слои остаются раздельными всегда.
|
||||||
нет. Разрезы — честнее.
|
|
||||||
|
Свести их **в ответе** можно, и Read API это делает, — но род свёртки не
|
||||||
|
проставляется вручную, а **измеряется**: одна метрика лежит в минутном и
|
||||||
|
часовом разрезе одновременно, и если часовое значение сходится с суммой
|
||||||
|
минутных, метрика накопительная; если со средним — мгновенная. Форма точки
|
||||||
|
рода не выдаёт (`Avg`/`Min`/`Max` есть только у `heart_rate`), единицы дают
|
||||||
|
процентов девяносто и ломаются на краях — `six_minute_walking_test_distance`
|
||||||
|
в метрах складывать нельзя, а `walking_running_distance` в километрах можно
|
||||||
|
(находка 40). Где данных на сверку не хватило, род остаётся неизвестным и
|
||||||
|
агрегация по метрике не предлагается вовсе.
|
||||||
|
|
||||||
|
Отдельно: **нижний слой HAE не суммируется никогда.** Он не сэмплы, а
|
||||||
|
посекундная развёртка (находка 34) и в сверке не сходится — сумма по нему
|
||||||
|
даёт завышение. Накопительные метрики агрегируются только из `minute`,
|
||||||
|
`hour` или `sample`.
|
||||||
|
|
||||||
**Слой — это режим выгрузки, которым пришли данные**, а не измеренное
|
**Слой — это режим выгрузки, которым пришли данные**, а не измеренное
|
||||||
разрешение каждой метрики. Различие принципиально: частота метрик разная —
|
разрешение каждой метрики. Различие принципиально: частота метрик разная —
|
||||||
@@ -285,6 +385,16 @@ hour метки выровнены на час heart_rate 00:00:00
|
|||||||
точностью. Классификация каждой по отдельности растащила бы одну выгрузку по
|
точностью. Классификация каждой по отдельности растащила бы одну выгрузку по
|
||||||
трём слоям.
|
трём слоям.
|
||||||
|
|
||||||
|
**Исключение — `sleep_analysis`.** Под этим именем HAE шлёт две несовместимые
|
||||||
|
схемы: поэпизодную (`start`/`end`/`value`/`qty`) и суточную сводку
|
||||||
|
(`totalSleep`/`core`/`rem`/`deep`/`awake`, метка на местной полуночи). Общих
|
||||||
|
полей, кроме `date` и `source`, у них нет, источники тоже разные — эпизоды от
|
||||||
|
стороннего приложения, сводка от часов (находка 38). Правило выравнивания на
|
||||||
|
сводке даст `hour`, хотя это суточный итог, а не часовой разрез. Поэтому в
|
||||||
|
каталоге они разводятся на два имени — `sleep_analysis` и
|
||||||
|
`sleep_analysis_summary`, — и слой у сводки не выводится, а фиксирован как
|
||||||
|
`day`. Хранение остаётся дословным: разводятся имена, а не содержимое.
|
||||||
|
|
||||||
Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на
|
Пересчёт при `reindex` идёт по всей истории сразу и потому точнее, чем на
|
||||||
приёме: это ещё одна причина держать сырой архив.
|
приёме: это ещё одна причина держать сырой архив.
|
||||||
|
|
||||||
@@ -335,6 +445,39 @@ hour метки выровнены на час heart_rate 00:00:00
|
|||||||
`WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из
|
`WARN` и всё равно сохраняем. Так мы узнаём реальную глубину досчёта из
|
||||||
эксплуатации, а не из предположений.
|
эксплуатации, а не из предположений.
|
||||||
|
|
||||||
|
### Категориальные значения
|
||||||
|
|
||||||
|
HAE отдаёт перечислимые значения строками из локали телефона, а не кодами:
|
||||||
|
фаза сна приезжает как «БДГ», контекст пульса — как «Сидячий образ жизни», тип
|
||||||
|
тренировки — как «В помещении Ходьба» (машинная калька с `Indoor Walk`). При
|
||||||
|
этом `stateOfMind` в том же пакете шлёт честные коды HealthKit
|
||||||
|
(`momentary_emotion`, `slightly_pleasant`, `drained`) — значит дело не в
|
||||||
|
приложении, а в том, что старые секции тянут строки из UI (находка 37).
|
||||||
|
|
||||||
|
Оставить как есть нельзя по трём причинам, и третья решающая:
|
||||||
|
|
||||||
|
1. Клиент вынужден угадывать словарь вместо того, чтобы сравнивать с кодом.
|
||||||
|
2. Смена языка телефона молча расколет историю: та же фаза сна станет другим
|
||||||
|
значением, и по координатному ключу это неотличимо от изменения данных.
|
||||||
|
3. **Родной экспорт Apple говорит кодами** (`HKCategoryValueSleepAnalysisAsleepREM`).
|
||||||
|
Он объявлен источником истины, и на нём держится ретеншен нижнего слоя —
|
||||||
|
но сверить покрытие по этим полям было бы нечем.
|
||||||
|
|
||||||
|
Поэтому строка **хранится дословно, а рядом кладётся выведенный код**:
|
||||||
|
|
||||||
|
```
|
||||||
|
value "БДГ" ← как прислал HAE
|
||||||
|
value_code "HKCategoryValueSleepAnalysisAsleepREM" ← выведено по словарю
|
||||||
|
```
|
||||||
|
|
||||||
|
Словарь ключуется парой `(локаль, строка)`; локаль берётся из
|
||||||
|
`Accept-Language`, который мы уже сохраняем (находка 32). Для незнакомой
|
||||||
|
строки код пустой — пустота честнее догадки, и она же видна в `/stats` как
|
||||||
|
список того, что пора добавить в словарь.
|
||||||
|
|
||||||
|
Дословность инварианта не нарушена: код **приписывается**, а не подменяет
|
||||||
|
строку. Обратное преобразование всегда возможно.
|
||||||
|
|
||||||
### Тренировки и прочие секции
|
### Тренировки и прочие секции
|
||||||
|
|
||||||
Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она
|
Тренировка адресуется своим `id` из HealthKit и **перезаписывается**: она
|
||||||
@@ -369,8 +512,8 @@ hour метки выровнены на час heart_rate 00:00:00
|
|||||||
## Read API
|
## Read API
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/metrics каталог: имя, units, слои с диапазонами
|
GET /api/v1/metrics каталог: имя, units, род, слои с диапазонами
|
||||||
GET /api/v1/metrics/{name}?layer&from&to&cursor точки метрики
|
GET /api/v1/metrics/{name}?from&to&bucket&layer точки метрики, при желании свёрнутые
|
||||||
GET /api/v1/workouts?from&to заголовки тренировок
|
GET /api/v1/workouts?from&to заголовки тренировок
|
||||||
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
|
GET /api/v1/workouts/{id} тренировка целиком, с маршрутом
|
||||||
GET /api/v1/records/{kind}?from&to прочие секции
|
GET /api/v1/records/{kind}?from&to прочие секции
|
||||||
@@ -388,30 +531,89 @@ GET /healthz
|
|||||||
за какой период:
|
за какой период:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"metric": "heart_rate",
|
{"metric": "heart_rate", "units": "count/min", "aggregation": "instant",
|
||||||
"layers": [
|
"layers": [
|
||||||
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078},
|
{"layer": "raw", "from": "2026-07-30", "to": "2026-08-01", "points": 2078},
|
||||||
{"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203}
|
{"layer": "minute", "from": "2026-07-25", "to": "2026-08-01", "points": 14203}
|
||||||
]}
|
]}
|
||||||
```
|
```
|
||||||
|
|
||||||
Параметр `layer` выбирает разрез. Если он не указан — отдаём **самый мелкий
|
`aggregation` — измеренный род (`cumulative` / `instant` / `unknown`), от него
|
||||||
слой, покрывающий весь запрошенный диапазон**, и в ответе всегда называем,
|
зависит, что вообще можно спросить.
|
||||||
какой слой отдан. Молча переключать слой на границе периода нельзя: ряд поедет
|
|
||||||
незаметно для клиента.
|
|
||||||
|
|
||||||
Форма точки в ответе — нормализованная оболочка, сырое содержимое:
|
Параметр `layer` выбирает разрез. Если он не указан — берём **самый мелкий
|
||||||
|
слой, покрывающий весь запрошенный диапазон**. Молча переключать слой на
|
||||||
|
границе периода нельзя: ряд поедет незаметно для клиента.
|
||||||
|
|
||||||
|
### Свёртка и размер ответа
|
||||||
|
|
||||||
|
Запросов к метрике ровно два, и это один запрос с необязательным параметром:
|
||||||
|
|
||||||
|
```
|
||||||
|
?from&to все значения за период вес, лекарства, симптомы
|
||||||
|
?from&to&bucket=day значения с разбивкой шаги, энергия
|
||||||
|
```
|
||||||
|
|
||||||
|
Главный потребитель — агент, у которого ограничен контекст. «Пульс за неделю»
|
||||||
|
без разбивки — это десятки тысяч точек в минутном слое и сотни тысяч в
|
||||||
|
нижнем. Правило:
|
||||||
|
|
||||||
|
- **Разбивка не задана, ответ не влезает** — сервер сам берёт сетку погрубее,
|
||||||
|
чтобы влезло, и называет её в ответе. Агент всегда получает ответ и может
|
||||||
|
переспросить уже.
|
||||||
|
- **Разбивка задана явно, ответ не влезает** — это ошибка, а не тихая
|
||||||
|
подмена. В теле ошибки — число точек по каждой доступной сетке, чтобы
|
||||||
|
второй запрос был заведомо успешным.
|
||||||
|
|
||||||
|
Различие существенно: «указали уровень» работает как информация в первом
|
||||||
|
случае и как защита во втором. Иначе агент, попросивший минутную сетку,
|
||||||
|
получил бы суточные суммы и заметил бы это, только прочитав поле, которое
|
||||||
|
вполне может не прочитать.
|
||||||
|
|
||||||
|
Свёртка применяет род из каталога: `cumulative` — сумма, `instant` —
|
||||||
|
среднее с `min`/`max` рядом. При `unknown` свёртка не выполняется, а параметр
|
||||||
|
`bucket` отвергается ошибкой. Накопительные метрики никогда не сворачиваются
|
||||||
|
из нижнего слоя HAE — только из `minute`, `hour` или `sample`.
|
||||||
|
|
||||||
|
### Форма ответа
|
||||||
|
|
||||||
|
Нормализованная оболочка, сырое содержимое:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
|
{"layer": "minute", "bucket": "hour", "aggregation": "sum",
|
||||||
"values": {"qty": 8}}
|
"points": [
|
||||||
|
{"ts": "2026-07-31T09:00:00Z", "tz_offset": 10800, "units": "count",
|
||||||
|
"values": {"qty": 812}}
|
||||||
|
]}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`layer`, `bucket` и `aggregation` присутствуют всегда, даже когда свёртки не
|
||||||
|
было (`"bucket": null`): клиент не должен выводить их наличием или
|
||||||
|
отсутствием поля.
|
||||||
|
|
||||||
Время приведено к единому виду, значения отданы как пришли: ни
|
Время приведено к единому виду, значения отданы как пришли: ни
|
||||||
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
|
переименований, ни пересчёта единиц. Метрик у Apple много и они разные —
|
||||||
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
|
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
|
||||||
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
|
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
|
||||||
|
|
||||||
|
### MCP
|
||||||
|
|
||||||
|
Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного
|
||||||
|
кода. Инструментов ровно два, по числу форм запроса выше, плюс каталог.
|
||||||
|
Собственной логики в адаптере нет: он переводит вызовы в те же обработчики.
|
||||||
|
|
||||||
|
**Транспорт — HTTP** (Streamable HTTP), не stdio: сервис живёт на VPS, и агент
|
||||||
|
ходит к нему по сети. Отсюда следствия:
|
||||||
|
|
||||||
|
- MCP — это **эндпоинт того же процесса**, а не отдельная подкоманда: тот же
|
||||||
|
бинарь, тот же порт, тот же Caddy впереди с TLS.
|
||||||
|
- Аутентификация — **тот же токен чтения** в `Authorization: Bearer`, что и у
|
||||||
|
Read API. Отдельного контура доступа не заводим: MCP не даёт ничего, чего
|
||||||
|
не даёт HTTP, и права у них обязаны совпадать.
|
||||||
|
- Правило размера ответа (см. выше) здесь не украшение, а необходимость:
|
||||||
|
сетевой агент не имеет возможности «посмотреть поближе» иначе, чем
|
||||||
|
повторным вызовом.
|
||||||
|
|
||||||
## Самоописание
|
## Самоописание
|
||||||
|
|
||||||
Сервис описывает свои данные сам: клиент (в том числе AI-агент) не должен
|
Сервис описывает свои данные сам: клиент (в том числе AI-агент) не должен
|
||||||
@@ -454,7 +656,11 @@ GET /healthz
|
|||||||
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
|
токенов — в конфиге. HAE умеет слать произвольные заголовки, этого достаточно.
|
||||||
|
|
||||||
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
|
Токены **раздельные**: на запись (приём) и на чтение. Клиент, читающий
|
||||||
данные, не может писать.
|
данные, не может писать. MCP пользуется токеном чтения — отдельного контура
|
||||||
|
у него нет, см. «MCP».
|
||||||
|
|
||||||
|
Наружу открыты два контура: приём (телефон) и чтение вместе с MCP (агенты и
|
||||||
|
приложения). Оба через Caddy с TLS, оба с разными токенами.
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
@@ -472,3 +678,20 @@ VPS **rivendell** (Timeweb), доступен всегда. Перед серв
|
|||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
|
|
||||||
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
|
- Механизм доставки образа и запуска на rivendell (compose руками / плейбук).
|
||||||
|
- **Предел размера ответа** — в точках или в оценке байт. Точки считать
|
||||||
|
проще, но у `heart_rate_variability` с `heartbeatSeries` точка на два
|
||||||
|
порядка тяжелее, чем у `step_count` (находка 39).
|
||||||
|
- **Хранить ли `heartbeatSeries` целиком.** 93% объёма HRV ради данных,
|
||||||
|
которых нет ни в одном из планируемых запросов.
|
||||||
|
- **Есть ли `stateOfMind`, симптомы и лекарства в родном экспорте.** От этого
|
||||||
|
зависит, применимо ли к ним устаревание нижнего слоя.
|
||||||
|
- **Как проверять покрытие экспортом** — до какой строгости. Непрерывности по
|
||||||
|
дням и сходимости сумм, вероятно, хватит, но порог не выбран.
|
||||||
|
- **Хранилище под аналитику.** Сейчас SQLite: часовые объекты дают ~260 тыс.
|
||||||
|
строк на слой в год независимо от плотности точек, а плоская таблица по
|
||||||
|
грубым слоям (`hour` ~36 тыс. строк в год, `minute` ~1.4 млн) делает
|
||||||
|
`GROUP BY` дешёвым без разжатия блобов. DuckDB рассматривался и отложен:
|
||||||
|
чистого Go-драйвера нет, любой требует cgo, что стоит нам
|
||||||
|
`CGO_ENABLED=0` и одного статического бинаря. Дверь при этом открыта —
|
||||||
|
DuckDB читает и parquet, и файл SQLite напрямую, так что выгрузка в parquet
|
||||||
|
остаётся отдельной командой на случай тяжёлой аналитики снаружи.
|
||||||
|
|||||||
+53
-34
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
## Ближайшая цель
|
## Ближайшая цель
|
||||||
|
|
||||||
Минимальное ядро, которое принимает и сохраняет пакет. Проверяем его
|
Приём работает, поток настоящих пакетов копится в сыром архиве. Дальше —
|
||||||
**локально**: сервис поднят на рабочей машине, телефон шлёт на её IP по
|
разбор и хранилище (шаг 3): всё, что нужно, чтобы данные перестали быть
|
||||||
локальной сети. Ни деплоя, ни домена, ни TLS для этого не нужно — они
|
недифференцированной кучей тел запросов.
|
||||||
понадобятся, когда сервис поедет на rivendell (шаг 8).
|
|
||||||
|
|
||||||
Это шаги 1–2.
|
Разведка закончена: правило вывода слоя, модель идентичности и формы точки
|
||||||
|
проверены на живом потоке, выводы — в
|
||||||
|
[local-research.md](local-research.md), 41 находка.
|
||||||
|
|
||||||
## Шаги
|
## Шаги
|
||||||
|
|
||||||
@@ -20,23 +21,40 @@
|
|||||||
← **подключаем телефон по локальной сети**
|
← **подключаем телефон по локальной сети**
|
||||||
- [ ] **3. Разбор и хранилище.** Миграции, часовые объекты метрик
|
- [ ] **3. Разбор и хранилище.** Миграции, часовые объекты метрик
|
||||||
(`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим
|
(`bucket`, ключ `метрика + слой + час`) + `workout`/`record` по своим
|
||||||
`id`, вывод слоя из выравнивания меток, канонизация с округлением чисел
|
`id`. Вывод слоя из выравнивания меток; развод `sleep_analysis` на два
|
||||||
и хеш содержимого, слияние точек в объект, два формата дат,
|
имени (находка 38). Канонизация с округлением чисел и хеш содержимого
|
||||||
`healthlog reindex`.
|
как детектор изменений. Слияние точек: при столкновении выигрывает
|
||||||
- [ ] **4. Read API.** Каталог метрик со слоями и диапазонами, точки с
|
**более полная** точка, а не последняя (находка 41). Три формата
|
||||||
пагинацией и выбором слоя (сборка из часовых объектов), тренировки,
|
времени: локальное со смещением, RFC 3339 Z, Unix-эпоха внутри
|
||||||
|
`heartbeatSeries` (находка 39). Словарь `(локаль, строка) → код
|
||||||
|
HealthKit` для переведённых значений (находка 37). `healthlog reindex`.
|
||||||
|
- [ ] **4. Каталог и род агрегации.** Род (`cumulative`/`instant`/`unknown`)
|
||||||
|
**измеряется** сверкой слоёв между собой, а не размечается руками
|
||||||
|
(находка 40). Каталог метрик со слоями, диапазонами и родом.
|
||||||
|
- [ ] **5. Read API.** Точки метрики из часовых объектов, выбор слоя,
|
||||||
|
необязательная свёртка по сетке. Огрубление, когда сетка не задана;
|
||||||
|
ошибка со списком доступных сеток, когда задана явно. Тренировки,
|
||||||
записи.
|
записи.
|
||||||
- [ ] **5. Самоописание.** Каталог разрезов + выведенные из данных схемы
|
- [ ] **6. Самоописание.** Выведенные из данных схемы содержимого со
|
||||||
содержимого со статистикой + статичная схема контракта API.
|
статистикой + статичная схема контракта API.
|
||||||
- [ ] **6. `healthlog import`.** Заливка полной истории кусками по годам.
|
- [ ] **7. MCP.** Эндпоинт того же процесса, транспорт Streamable HTTP, токен
|
||||||
- [ ] **7. Наблюдаемость.** `/stats`: последняя доставка, счётчики, тишина.
|
чтения общий с Read API. Три инструмента: каталог, значения за период,
|
||||||
- [ ] **8. Деплой.** `Dockerfile`, сборка образа локально, доставка на
|
значения с разбивкой.
|
||||||
rivendell, конфиг Caddy, поддомен, токены.
|
- [ ] **8. `healthlog import`.** Родной экспорт Apple Health: разбор
|
||||||
|
`экспорт.xml` в слой `sample`, маршруты GPX, ЭКГ из CSV. Заливка полной
|
||||||
|
истории кусками по годам.
|
||||||
|
- [ ] **9. Устаревание нижнего слоя.** Пометка данных HAE старше проверенного
|
||||||
|
экспорта. Проверка покрытия — непрерывность по дням и сходимость сумм с
|
||||||
|
часовым слоем. Пометка ≠ удаление: удаление включаем только после того,
|
||||||
|
как восстановление из экспорта отработает на живых данных хотя бы раз.
|
||||||
|
- [ ] **10. Наблюдаемость.** `/stats`: последняя доставка, счётчики, тишина по
|
||||||
|
потоку, список строк без кода в словаре.
|
||||||
|
- [ ] **11. Деплой.** `Dockerfile`, сборка образа локально, доставка на
|
||||||
|
rivendell, конфиг Caddy, поддомены приёма и чтения, токены.
|
||||||
|
|
||||||
Порядок неслучаен. После шага 2 автоматизация в Health Auto Export включена и
|
Порядок неслучаен. Шаг 4 стоит перед Read API, потому что без измеренного рода
|
||||||
копит настоящие пакеты в сыром архиве. Документация формата HAE скудная,
|
свёртка на шаге 5 неотличима от угадывания. Шаг 8 стоит перед 9: пока импорт
|
||||||
поэтому разбор на шаге 3 пишем по реальным данным, а не по догадкам — и
|
экспорта не написан, помечать что-либо устаревшим не на основании чего.
|
||||||
заодно видим фактический объём и характер потока.
|
|
||||||
|
|
||||||
## Отложено
|
## Отложено
|
||||||
|
|
||||||
@@ -45,12 +63,21 @@
|
|||||||
и теми же данными почти никогда не совпадают побайтно — хеш тела не
|
и теми же данными почти никогда не совпадают побайтно — хеш тела не
|
||||||
сработает. Дедупликация возможна только по канонизированному содержимому,
|
сработает. Дедупликация возможна только по канонизированному содержимому,
|
||||||
а это и делает хеш часового объекта. Отдельная механика не нужна.
|
а это и делает хеш часового объекта. Отдельная механика не нужна.
|
||||||
|
- ~~Вторая автоматизация без группировки.~~ **Сделано на телефоне:** метрики
|
||||||
|
здоровья идут в трёх разрезах — несуммированном для несуммируемых метрик,
|
||||||
|
минутном и часовом для всех.
|
||||||
|
- ~~Пометка локализованных полей в схемах.~~ **Переросло в шаг 3:** одной
|
||||||
|
пометки мало, нужен словарь кодов, иначе не сойтись с родным экспортом
|
||||||
|
(находка 37).
|
||||||
- **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление
|
- **Ретеншен сырого архива** (`storage.raw_retention`, 14 дней) — удаление
|
||||||
старых тел. Пока архив не подчищается; включить после того, как разбор
|
старых тел. Пока архив не подчищается; включить после того, как разбор
|
||||||
устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна.
|
устоится, иначе страховка исчезнет раньше, чем перестанет быть нужна.
|
||||||
- **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по
|
- **Порог `sealed`** — с какого возраста час считается запечатанным. Ставим по
|
||||||
факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая
|
факту: сначала пишем `WARN` на изменение старых объектов и смотрим, какая
|
||||||
глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10).
|
глубина досчёта встречается в жизни (наблюдалось до 22 минут, находка 10).
|
||||||
|
- **Месячный проход по ручным секциям.** Симптомы и лекарства заводятся задним
|
||||||
|
числом на недели; количественным метрикам недельного прохода хватает.
|
||||||
|
Заводить, когда эти секции появятся в потоке живьём.
|
||||||
- **Алерт «данных нет N часов».** Тихо сломавшаяся автоматизация — главный
|
- **Алерт «данных нет N часов».** Тихо сломавшаяся автоматизация — главный
|
||||||
эксплуатационный риск коллектора. В v1 факт виден в `/stats`; активное
|
эксплуатационный риск коллектора. В v1 факт виден в `/stats`; активное
|
||||||
уведомление добавим после.
|
уведомление добавим после.
|
||||||
@@ -60,20 +87,12 @@
|
|||||||
а это отслеживается.
|
а это отслеживается.
|
||||||
- **Схема тренировок** — глубину вывода определим по факту, когда увидим,
|
- **Схема тренировок** — глубину вывода определим по факту, когда увидим,
|
||||||
как приходят маршруты.
|
как приходят маршруты.
|
||||||
- **Пометка локализованных полей в схемах.** `context`, `value` и подобные
|
- **Отказ от `heartbeatSeries`.** 93% объёма HRV (находка 39) ради данных,
|
||||||
приходят на языке телефона и изменятся при смене языка iOS — клиентам не
|
которых нет ни в одном планируемом запросе. Решать, когда станет ясна цена
|
||||||
стоит завязываться на конкретные строки
|
хранения нижнего слоя за год.
|
||||||
([local-research.md](local-research.md), находка 8).
|
- **Выгрузка в parquet** — отдельной командой, на случай тяжёлой аналитики
|
||||||
- **Вторая автоматизация без группировки** для `sleep_analysis` и
|
снаружи. DuckDB читает и parquet, и файл SQLite напрямую, поэтому спешить
|
||||||
`heart_rate_variability` — минутная группировка съедает фазы сна и
|
некуда: дверь открыта без миграции.
|
||||||
межударные интервалы ценой ~130 точек в сутки (находка 6).
|
|
||||||
- **Переход на более грубый нижний слой.** Посекундный слой стоит ~730 МБ в
|
|
||||||
год против ~20 МБ у минутного. Когда решим, что мелкая подробность не нужна,
|
|
||||||
достаточно выключить несуммированную автоматизацию — старые данные останутся
|
|
||||||
в своих слоях, переписывать ничего не придётся. Обратный путь тоже есть:
|
|
||||||
ручной экспорт из Apple Health + `healthlog import` восстанавливает нижний
|
|
||||||
слой.
|
|
||||||
- **NDJSON-поток** для больших выборок из read API.
|
- **NDJSON-поток** для больших выборок из read API.
|
||||||
- **Слой агрегаций** поверх сырья (суточные/недельные срезы).
|
|
||||||
- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится
|
- **Разворачивание маршрутов тренировок** в отдельную таблицу — если появится
|
||||||
клиент, которому мало отдачи тренировки одним пакетом.
|
клиент, которому мало отдачи тренировки одним пакетом.
|
||||||
|
|||||||
Reference in New Issue
Block a user