разбор метрик HAE: фикстуры, канонизация, парсер

- tmp/research/fixtures.py собирает фикстуры из архива, вычищая измерения и
  сохраняя порядок ключей, форму литералов, выравнивание меток и невидимые
  символы; шесть фикстур в internal/hae/testdata
- internal/canon — общий дом канонической формы, полноты и хеша: числа читаются
  литералом через json.Number, округление до 12 значащих цифр
- internal/hae — разбор секции metrics, вывод слоя по метрике, разделение схем
  сна, координаты интервалом; recover внутри Parse, фаззинг
- миграция bucket и docs/database.md
This commit is contained in:
av
2026-08-01 17:31:41 +03:00
parent 3e93dd95b4
commit 01d0de59df
18 changed files with 3693 additions and 21 deletions
+80
View File
@@ -0,0 +1,80 @@
# Схема базы
SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, файлы в
`internal/store/migrations`. Время в текстовых колонках всегда **UTC RFC 3339**
с секундной точностью: фиксированная ширина делает лексикографическую
сортировку `TEXT` совпадающей с хронологией.
**База производна.** Источник истины — сырой архив тел (`data/raw`); состояние
пересобирается свёрткой `import(экспорт Apple) + replay(доставки по received_at)`.
Поэтому терять базу неприятно, но не смертельно, а вот терять архив — смертельно.
```
┌────────────────────────────┐ ┌──────────────────────────────┐
│ delivery │ │ bucket │
│ ───────────────────────── │ │ ──────────────────────────── │
│ id TEXT PK │ │ metric TEXT ┐ │
│ received_at TEXT │ │ layer TEXT ├ PK │
│ automation_name TEXT │ ┄┄┄┄┄▶ │ hour_utc TEXT ┘ │
│ automation_id TEXT │ первая │ units TEXT │
│ aggregation TEXT │ доставка │ payload BLOB │
│ period TEXT │ часа │ content_hash TEXT │
│ session_id TEXT │ │ points INTEGER │
│ bytes INTEGER │ │ first_ts TEXT │
│ sha256 TEXT │ │ last_ts TEXT │
│ raw_path TEXT │ │ first_delivery_id TEXT │
│ parse_status TEXT │ │ sealed INTEGER │
│ points INTEGER │ │ created_at TEXT │
│ headers TEXT │ │ updated_at TEXT │
└────────────────────────────┘ └──────────────────────────────┘
```
Связь `bucket.first_delivery_id → delivery.id` **внешним ключом не объявлена**
намеренно: доставки подрезаются ретеншеном до следующего проверенного экспорта,
а объекты живут дольше. Ссылка — провенанс для разбора слияний, а не
целостность.
## `delivery` — учёт принятых пакетов
Одна строка на принятое тело. Само тело лежит в архиве по `raw_path`
(относительный путь внутри `data/raw`).
| Колонка | Смысл |
|---|---|
| `id` | ULID, он же имя файла в архиве |
| `received_at` | время приёма, UTC |
| `automation_name`, `automation_id` | какая автоматизация HAE прислала; `automation_id` нужен выводу слоя — по нему наследуется слой доставки без плотных метрик |
| `aggregation` | заголовок `automation-aggregation`. Режима **не означает**: значение `Default` наблюдалось у посекундного, минутного и часового режимов одновременно |
| `period` | заголовок периода (`Since Last Sync` и прочие) |
| `bytes`, `sha256` | размер и хеш тела; хеш пока только для учёта |
| `parse_status` | `pending` / `parsed` / `failed`. Код ответа приёма от него **не зависит**: сохранили — значит приняли |
| `points` | сколько точек дал разбор |
| `headers` | все заголовки запроса JSON-объектом, кроме несущих секреты |
Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт
повторов).
## `bucket` — часовой объект точек
Единица хранения — час одной метрики в одном слое, а не отдельная точка.
| Колонка | Смысл |
|---|---|
| `metric` | имя метрики как прислал HAE. Исключение — `sleep_analysis_summary`: под именем `sleep_analysis` приезжают две несовместимые схемы, и они разводятся на разные имена |
| `layer` | `sample` / `raw` / `minute` / `hour` / `day`. Выводится из выравнивания меток |
| `hour_utc` | начало часа, UTC. Час берётся **по началу точки** |
| `units` | единицы метрики. Внутри точки их нет, они живут на уровне метрики |
| `payload` | точки часа: JSON-массив исходных байтов, gzip. Точки упорядочены по началу |
| `content_hash` | хеш канонической формы — **детектор изменений**, не ключ. Совпал — записи нет |
| `points` | сколько точек внутри |
| `first_ts`, `last_ts` | границы содержимого; каталогу разрезов, чтобы не разжимать блоб ради диапазона |
| `first_delivery_id` | доставка, создавшая объект |
| `sealed` | час, в который досчёт не ожидается. Правило перевода пока не определено |
Таблица `WITHOUT ROWID`: обращение всегда по полному первичному ключу, и
лишний уровень косвенности через rowid ни разу не нужен.
**Идентичность точки внутри объекта** — координаты
`метрика + слой + начало + конец`, у точки-измерения конец равен началу.
`source` в ключ не входит: он нестабилен и переписывается задним числом. При
столкновении выигрывает более полная точка, а не последняя пришедшая.
+244
View File
@@ -0,0 +1,244 @@
// Package canon — каноническая форма содержимого точки: то, по чему точки
// сравниваются и хешируются.
//
// Пакет общий для разбора и хранения намеренно. Слияние точек живёт в store,
// хеш пересчитывается там же, а сравнивать приходится то, что приехало из hae.
// Положи канонизацию в hae — store станет знать про формат HAE; положи в store
// — импорт родного экспорта Apple потребует второй реализации. Две реализации
// разошлись бы на дребезге последнего разряда, и хеш-детектор превратился бы в
// перезапись недели каждым глубоким проходом синхронизации.
//
// Каноническая форма существует только в момент сравнения. Хранится всегда
// исходные байты точки: обход через разобранные значения теряет литерал
// (`1.0` становится `1`, целые больше 2^53 сдвигаются, невалидный UTF-8
// заменяется на U+FFFD), и потеря не видна тестам на фикстурах — они
// сравнивают разобранное с разобранным.
package canon
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
)
// SignificantDigits — до скольки значащих цифр округляется число в
// канонической форме.
//
// Без округления сравнение бесполезно: 45 507 из 71 730 повторно приехавших
// точек различались последним разрядом double при одинаковом измерении — 63%
// повторов выглядели новыми (docs/local-research.md, находка 30). Двенадцать
// цифр отсекают дребезг сериализации и оставляют нетронутым всё, что Apple
// реально измеряет: даже доли процента у walking_asymmetry_percentage не
// доходят до седьмой значащей цифры.
const SignificantDigits = 12
// Form возвращает каноническую форму значения: ключи объектов отсортированы,
// числа округлены до SignificantDigits значащих цифр.
//
// Форма предназначена для сравнения и хеширования, а не для хранения.
func Form(raw []byte) ([]byte, error) {
v, err := decode(raw)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := write(&buf, v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Hash возвращает шестнадцатеричный SHA-256 канонической формы.
//
// Хеш — детектор изменений, а не ключ: совпал с сохранённым, значит писать
// нечего. Именно это делает широкие проходы синхронизации дешёвыми — глубокий
// проход переприсылает неделю, но почти все сравнения сходятся.
func Hash(raw []byte) (string, error) {
form, err := Form(raw)
if err != nil {
return "", err
}
sum := sha256.Sum256(form)
return hex.EncodeToString(sum[:]), nil
}
// HashAll возвращает хеш канонической формы последовательности значений —
// содержимого часового объекта целиком.
func HashAll(raws [][]byte) (string, error) {
h := sha256.New()
for _, raw := range raws {
form, err := Form(raw)
if err != nil {
return "", err
}
// Разделитель нужен, чтобы склейка соседних значений не давала тот же
// хеш, что другое их разбиение.
_, _ = h.Write(form)
_, _ = h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// Completeness — мера полноты точки: сколько значащих полей она несёт.
//
// Нужна правилу разрешения столкновений: по одним координатам приезжают точки
// с разным НАБОРОМ полей при одинаковом значении (0.66% координат), и правило
// «последняя победила» стирало бы у сохранённой точки поля, которых новая не
// несёт.
//
// Поля с пустым значением не считаются: точка с `context: null` не полнее
// точки без `context`. Поле source в счёт не идёт — оно нестабильно и
// переписывается задним числом, так что его наличие ничего не говорит о
// полноте измерения.
func Completeness(raw []byte) int {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return 0
}
n := 0
for k, v := range obj {
if k == "source" {
continue
}
if isEmpty(v) {
continue
}
n++
}
return n
}
// Less задаёт детерминированный порядок на точках равной полноты.
//
// Тай-брейк по времени приёма для этого не годится: у сохранённой точки нет
// провенанса, сравнивать не с чем, а четверть доставок несёт столкновения
// ВНУТРИ себя, где время приёма общее. Порядок канонических форм зависит
// только от самих значений, поэтому свёртка по журналу даёт то же состояние,
// что приём в реальном времени.
func Less(a, b []byte) bool {
fa, err := Form(a)
if err != nil {
return false
}
fb, err := Form(b)
if err != nil {
return true
}
return bytes.Compare(fa, fb) < 0
}
func isEmpty(v json.RawMessage) bool {
t := bytes.TrimSpace(v)
switch {
case len(t) == 0, bytes.Equal(t, []byte("null")):
return true
case bytes.Equal(t, []byte(`""`)):
return true
default:
return false
}
}
// decode разбирает значение с числами в виде json.Number: строковый литерал
// вместо float64. Без этого округление применялось бы к уже испорченному
// значению — round-trip через float64 сам по себе меняет литерал.
func decode(raw []byte) (any, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("canon: разбор значения: %w", err)
}
return v, nil
}
// write пишет каноническую форму значения.
//
// Сортировку ключей объекта делает encoding/json сам (json.Marshal для map
// сортирует ключи), но здесь она выполняется явно: значения приходится
// обходить всё равно — ради чисел, — и второй проход через json.Marshal
// означал бы round-trip числа через float64.
func write(buf *bytes.Buffer, v any) error {
switch t := v.(type) {
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
key, err := json.Marshal(k)
if err != nil {
return fmt.Errorf("canon: ключ %q: %w", k, err)
}
buf.Write(key)
buf.WriteByte(':')
if err := write(buf, t[k]); err != nil {
return err
}
}
buf.WriteByte('}')
case []any:
buf.WriteByte('[')
for i, e := range t {
if i > 0 {
buf.WriteByte(',')
}
if err := write(buf, e); err != nil {
return err
}
}
buf.WriteByte(']')
case json.Number:
buf.WriteString(roundNumber(t.String()))
default:
// Строки, bool и null: json.Marshal даёт для них ту же форму, что
// пришла, и своей реализации не требует.
b, err := json.Marshal(t)
if err != nil {
return fmt.Errorf("canon: значение: %w", err)
}
buf.Write(b)
}
return nil
}
// roundNumber округляет числовой литерал до SignificantDigits значащих цифр.
//
// Целые остаются как есть: у них дребезга сериализации не бывает, а округление
// сдвинуло бы большие идентификаторы. Литерал, не разбирающийся как число,
// возвращается дословно — канонизация не место, где решается судьба
// непонятного входа.
func roundNumber(lit string) string {
if !bytes.ContainsAny([]byte(lit), ".eE") {
return lit
}
f, err := strconv.ParseFloat(lit, 64)
if err != nil {
return lit
}
if math.IsInf(f, 0) || math.IsNaN(f) {
return lit
}
// %g с точностью в значащих цифрах — ровно то, что нужно: экспонента
// выбирается сама, хвост за пределами точности отбрасывается.
return strconv.FormatFloat(f, 'g', SignificantDigits, 64)
}
+275
View File
@@ -0,0 +1,275 @@
package canon_test
import (
"encoding/json"
"testing"
"git.vakhrushev.me/av/healthlog/internal/canon"
)
// Пары взяты с живого потока (docs/local-research.md, находка 30): те же
// измерения в двух выгрузках, разошедшиеся последним разрядом double. Без
// округления 63% повторов считались бы новыми точками.
func TestFormСхлопываетДребезгПоследнегоРазряда(t *testing.T) {
t.Parallel()
pairs := []struct {
name string
a, b string
}{
{
name: "basal_energy_burned",
a: `{"qty":0.09523182962471353}`,
b: `{"qty":0.09523182962471352}`,
},
{
name: "active_energy",
a: `{"qty":0.0074754192155406605}`,
b: `{"qty":0.00747541921554066}`,
},
{
// Тот же случай, вынесенный за пределы двенадцатой значащей цифры
// явно: граница округления должна работать и на круглых числах.
name: "граница округления",
a: `{"qty":123.4567890123456}`,
b: `{"qty":123.4567890123499}`,
},
}
for _, p := range pairs {
t.Run(p.name, func(t *testing.T) {
t.Parallel()
ha, err := canon.Hash([]byte(p.a))
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash([]byte(p.b))
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha != hb {
fa, _ := canon.Form([]byte(p.a))
fb, _ := canon.Form([]byte(p.b))
t.Errorf("дребезг не схлопнулся:\n %s\n %s", fa, fb)
}
})
}
}
// Обратная сторона округления: настоящее различие обязано выжить. Без этого
// теста округление можно было бы «улучшить» до полной бесполезности.
func TestFormСохраняетНастоящееРазличие(t *testing.T) {
t.Parallel()
a := []byte(`{"qty":0.09523182962}`)
b := []byte(`{"qty":0.09523182963}`)
ha, err := canon.Hash(a)
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash(b)
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha == hb {
t.Error("различие в одиннадцатой значащей цифре съедено округлением")
}
}
// Порядок ключей в JSON от HAE нестабилен (находка 2): та же точка приезжает с
// разной раскладкой. Идентичность не имеет права от этого зависеть.
func TestFormНеЗависитОтПорядкаКлючей(t *testing.T) {
t.Parallel()
a := []byte(`{"date":"2025-06-05 10:00:00 +0300","qty":1.5,"source":"Device A"}`)
b := []byte(`{"source":"Device A","qty":1.5,"date":"2025-06-05 10:00:00 +0300"}`)
ha, err := canon.Hash(a)
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash(b)
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha != hb {
t.Error("перестановка ключей изменила идентичность точки")
}
}
// Канонизация не имеет права быть путём, по которому значение попадает в
// хранилище: она читает байты и ничего не отдаёт обратно. Тест фиксирует
// именно это — литералы, которые не переживают round-trip через разобранные
// значения, обрабатываются без паники и дают устойчивый хеш, а сами байты
// остаются нетронутыми у вызывающего.
func TestHashНеПортитИсходныеБайты(t *testing.T) {
t.Parallel()
// `1.0` теряет ноль, целое больше 2^53 сдвигается, невалидный UTF-8
// заменяется на U+FFFD — всё это происходит с КОПИЕЙ внутри canon.
raw := []byte("{\"exact\":1.0,\"huge\":9007199254740993,\"broken\":\"a\xff\xfeb\"}")
before := string(raw)
h1, err := canon.Hash(raw)
if err != nil {
t.Fatalf("хеш: %v", err)
}
h2, err := canon.Hash(raw)
if err != nil {
t.Fatalf("повторный хеш: %v", err)
}
if string(raw) != before {
t.Errorf("исходные байты изменены:\n было %q\n стало %q", before, raw)
}
if h1 != h2 {
t.Error("хеш одного и того же значения не воспроизводится")
}
}
// Целое больше 2^53 не должно проходить через float64 даже внутри
// канонизации: округление сдвинуло бы его, и две разные точки стали бы одной.
func TestFormНеСдвигаетБольшиеЦелые(t *testing.T) {
t.Parallel()
a := []byte(`{"id":9007199254740993}`)
b := []byte(`{"id":9007199254740992}`)
ha, err := canon.Hash(a)
if err != nil {
t.Fatalf("хеш a: %v", err)
}
hb, err := canon.Hash(b)
if err != nil {
t.Fatalf("хеш b: %v", err)
}
if ha == hb {
t.Error("соседние целые за пределами точности double схлопнулись")
}
form, err := canon.Form(a)
if err != nil {
t.Fatalf("форма: %v", err)
}
if want := `{"id":9007199254740993}`; string(form) != want {
t.Errorf("целое переписано:\n получено %s\n ожидалось %s", form, want)
}
}
func TestCompleteness(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want int
}{
{"пустой объект", `{}`, 0},
{"только qty", `{"qty":1}`, 1},
{"qty и границы", `{"qty":1,"start":"a","end":"b"}`, 3},
{
// source нестабилен и переписывается задним числом, поэтому его
// наличие ничего не говорит о полноте измерения.
name: "source не считается",
raw: `{"qty":1,"source":"Device A"}`,
want: 1,
},
{
// Точка с пустым полем не полнее точки без него — иначе бедная
// доставка выиграла бы столкновение одним лишь наличием ключа.
name: "пустые значения не считаются",
raw: `{"qty":1,"context":null,"note":""}`,
want: 1,
},
{"не объект", `[1,2,3]`, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
if got := canon.Completeness([]byte(c.raw)); got != c.want {
t.Errorf("полнота %s = %d, ожидалось %d", c.raw, got, c.want)
}
})
}
}
// Тай-брейк обязан зависеть только от значений: свёртка по журналу должна
// давать то же состояние, что приём в реальном времени, а внутри одной
// доставки время приёма у столкнувшихся точек общее.
func TestLessДетерминирован(t *testing.T) {
t.Parallel()
a := []byte(`{"qty":1,"value":"Во сне"}`)
b := []byte(`{"value":"В кровати","qty":2}`)
ab := canon.Less(a, b)
ba := canon.Less(b, a)
if ab == ba {
t.Fatal("порядок не строгий: обе точки считаются меньшими")
}
for range 10 {
if canon.Less(a, b) != ab {
t.Fatal("порядок не воспроизводится между вызовами")
}
}
}
// Каноническая форма — вход хеша, поэтому она обязана оставаться разбираемым
// JSON: иначе отладка столкновения сведётся к чтению байтов глазами.
func TestFormОстаётсяJSON(t *testing.T) {
t.Parallel()
raw := []byte(`{"b":[1,2.50,{"z":null,"a":true}],"a":"строка"}`)
form, err := canon.Form(raw)
if err != nil {
t.Fatalf("форма: %v", err)
}
var v any
if err := json.Unmarshal(form, &v); err != nil {
t.Fatalf("каноническая форма не разбирается: %v\n%s", err, form)
}
if want := `{"a":"строка","b":[1,2.5,{"a":true,"z":null}]}`; string(form) != want {
t.Errorf("форма:\n получено %s\n ожидалось %s", form, want)
}
}
func TestFormОшибкаНаНеJSON(t *testing.T) {
t.Parallel()
if _, err := canon.Form([]byte(`{"qty":`)); err == nil {
t.Error("усечённый JSON принят за корректный")
}
}
func FuzzForm(f *testing.F) {
f.Add(`{"qty":1.5}`)
f.Add(`{"a":{"b":[1,2,3]}}`)
f.Add(`[]`)
f.Add(`null`)
f.Add(`1e400`)
f.Add("{\"s\":\"\xff\"}")
f.Fuzz(func(t *testing.T, raw string) {
// Единственное требование: разбор произвольного входа не роняет
// процесс. Разбор чужого формата обязан отвечать ошибкой, а не паникой.
form, err := canon.Form([]byte(raw))
if err != nil {
return
}
// Каноническая форма корректного входа обязана быть устойчивой:
// канонизация канонической формы даёт её же.
again, err := canon.Form(form)
if err != nil {
t.Fatalf("каноническая форма не канонизируется повторно: %v\n%s", err, form)
}
if string(again) != string(form) {
t.Fatalf("канонизация не идемпотентна:\n %s\n %s", form, again)
}
})
}
+429
View File
@@ -0,0 +1,429 @@
// Package hae — разбор тела доставки Health Auto Export в точки.
//
// Отдельно от хранения потому, что у разбора будет второй потребитель
// (пересборка витрины из архива) и второй источник (родной экспорт Apple —
// свой формат поверх того же хранилища). Пакет ничего не знает ни про SQLite,
// ни про архив, и ничего не пишет: `Parse` — чистая функция от тела и
// заголовков.
//
// Правила разбора выведены измерением живого потока, а не спроектированы:
// docs/local-research.md, находки 2, 30, 33, 35, 36, 38, 39, 47. Документация
// HAE местами расходится с тем, что приложение шлёт на самом деле, поэтому
// источник истины по формату — пакеты в testdata.
package hae
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"time"
)
// Layer — подробность, в которой метрика приехала. Выводится из выравнивания
// меток времени, а не из заголовка доставки: заголовок `Default` наблюдался
// одновременно у посекундного, минутного и часового режимов (находка 33).
type Layer string
// Слои хранения. `sample` появится с импортом родного экспорта Apple, `day`
// назначается схемам с фиксированной гранулярностью и не выводится.
const (
LayerSample Layer = "sample"
LayerRaw Layer = "raw"
LayerMinute Layer = "minute"
LayerHour Layer = "hour"
LayerDay Layer = "day"
)
// Ошибки разбора.
var (
// ErrMalformed — тело не разбирается как JSON ожидаемой формы.
ErrMalformed = errors.New("тело не разбирается")
// ErrLayerUnknown — в доставке есть метрики, но определить их слой нечем:
// плотных метрик нет, у автоматизации нет предыдущего надёжного слоя, а
// заголовок ненадёжен. Точки не сохраняются, тело остаётся в архиве —
// доставку подберёт пересборка, когда слой станет известен.
//
// Молчаливый выбор `raw` здесь недопустим: призрачный разрез поедет в
// каталог и в правило Read API «самый мелкий слой, покрывающий диапазон».
ErrLayerUnknown = errors.New("слой доставки не определяется")
)
// Порог плотности: метрика, у которой не меньше стольких точек с метками,
// классифицируется по собственному выравниванию. Редкая наследует слой
// доставки — её собственное выравнивание ничего не значит, потому что одна
// метка на часе бывает и у минутного ряда.
const denseThreshold = 10
// timeLayout — формат метки точки в секции metrics. Другого там не
// встречается: RFC 3339 живёт только в stateOfMind, а Unix-эпоха — внутри
// heartbeatSeries, и меткой точки не является.
const timeLayout = "2006-01-02 15:04:05 -0700"
// Point — одна разобранная точка.
type Point struct {
// Metric — имя метрики как прислал HAE. Исключение — sleep_analysis: под
// одним именем приезжают две несовместимые схемы, и они разводятся.
Metric string
Units string
Layer Layer
// Start и End — координаты точки в UTC. У точки-измерения End равен Start:
// ключ одной формы для всех точек, потому что интервальная и точечная
// формы не встречаются вперемешку внутри метрики одной доставки
// (находка 47).
Start time.Time
End time.Time
// OffsetSeconds — смещение исходной зоны. Нормализовать время без него
// значит потерять, в каком часовом поясе человек находился.
OffsetSeconds int
// Raw — содержимое точки исходными байтами, как пришло в теле. Пересборка
// повторной сериализацией теряет литерал (`1.0` → `1`, целые больше 2^53
// сдвигаются, невалидный UTF-8 → U+FFFD), и потеря не видна тестам на
// фикстурах: они сравнивают разобранное с разобранным.
Raw json.RawMessage
}
// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в
// ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное
// дело, и терять из-за неё остальное нельзя.
type Result struct {
Points []Point
// Metrics — сколько метрик встретилось в секции.
Metrics int
// SkippedNoTime — точки без разбираемой метки времени.
SkippedNoTime int
// SkippedMalformed — точки, не разобравшиеся как объект JSON.
SkippedMalformed int
// Layer — слой, выведенный для доставки в целом (тот, что наследуют редкие
// метрики). Пустой, если плотных метрик не было и наследовать было нечего.
// Его сохраняет вызывающий, чтобы следующая доставка той же автоматизации
// могла его унаследовать.
Layer Layer
// LayerMismatch — выведенный слой разошёлся с НАДЁЖНЫМ заголовком.
// Заголовок `Default` в сравнении не участвует: он не означает режима, и
// сравнение с ним давало бы WARN на каждой доставке потока в пять минут.
LayerMismatch bool
// HeaderLayer — слой по заголовку, если заголовок надёжен.
HeaderLayer Layer
}
// Meta — что доставка рассказала о себе заголовками, плюс память о прошлых
// доставках той же автоматизации.
type Meta struct {
// Aggregation — заголовок `automation-aggregation`. Надёжен только в
// значениях `Minutes` и `Hours`.
Aggregation string
// FallbackLayer — последний надёжно выведенный слой этой же автоматизации
// (`automation-id`). Нужен доставкам без плотных метрик: измерено 2 такие
// из 89, обе с заголовком `Default`. Ищет и передаёт его вызывающий —
// разбор остаётся чистой функцией.
FallbackLayer Layer
}
// Parse разбирает секцию metrics тела доставки в точки.
//
// Ошибка возвращается только когда точек не будет вовсе: тело не JSON
// (ErrMalformed) или слой не определяется (ErrLayerUnknown). Всё остальное —
// счётчики в Result. Отсутствие секции metrics ошибкой не является: доставки
// с одними тренировками или состоянием разума — норма.
func Parse(body []byte, meta Meta) (res Result, err error) {
// Разбор чужого формата обязан отвечать ошибкой, а не паникой: приём не
// имеет права упасть из-за того, что HAE прислал невиданное. Перехват
// стоит здесь, внутри разбора, а не выше: паника из хранилища — настоящий
// дефект, и глушить её нельзя.
defer func() {
if r := recover(); r != nil {
res = Result{}
err = fmt.Errorf("%w: паника разбора: %v", ErrMalformed, r)
}
}()
metrics, err := decodeMetrics(body)
if err != nil {
return Result{}, err
}
res.Metrics = len(metrics)
if len(metrics) == 0 {
return res, nil
}
groups := make([]group, 0, len(metrics))
for _, m := range metrics {
g := decodeGroup(m, &res)
if g.spillover != nil {
groups = append(groups, *g.spillover)
g.spillover = nil
}
if len(g.points) > 0 {
groups = append(groups, g)
}
}
if len(groups) == 0 {
return res, nil
}
res.HeaderLayer = headerLayer(meta.Aggregation)
if err := assignLayers(groups, meta, &res); err != nil {
return Result{Metrics: res.Metrics}, err
}
total := 0
for _, g := range groups {
total += len(g.points)
}
res.Points = make([]Point, 0, total)
for _, g := range groups {
for _, p := range g.points {
p.Layer = g.layer
res.Points = append(res.Points, p)
}
}
return res, nil
}
// group — точки одной метрики одной доставки: единица, для которой выводится
// слой. Классификация именно по метрике, а не по доставке: при перенастройке
// автоматизации приезжают смешанные доставки, и отнесение такой доставки к
// одному слою складывает минутные точки с посекундными (находка 33).
type group struct {
metric string
units string
points []Point
// layer — итоговый слой группы.
layer Layer
// fixed — слой назначен схемой, а не выведен (суточная сводка сна). Такие
// группы не участвуют в определении слоя доставки: сводок бывает больше
// порога плотности, и их полуночные метки назначили бы всей доставке hour.
fixed bool
// alignment — самое мелкое выравнивание среди меток группы.
alignment Layer
dense bool
// spillover — вторая группа, отделившаяся от этой при разделении схем под
// одним именем метрики.
spillover *group
}
// envelope — форма тела, ровно настолько подробная, насколько нужно разбору.
//
// Точки держатся сырыми сообщениями и декодируются по одной: разбор тела в
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
// OOM ровно на пике потока, когда терять доставки дороже всего.
type envelope struct {
Data struct {
Metrics []metricEnvelope `json:"metrics"`
} `json:"data"`
}
type metricEnvelope struct {
Name string `json:"name"`
Units string `json:"units"`
Data []json.RawMessage `json:"data"`
}
// pointHead — поля точки, нужные разбору. Всё остальное остаётся в Raw и
// хранится дословно: «служебных» полей у точки нет, отбрасывать нечего.
type pointHead struct {
Date string `json:"date"`
Start string `json:"start"`
End string `json:"end"`
// TotalSleep различает две схемы под именем sleep_analysis: поэпизодную и
// суточную сводку. Общих полей, кроме date и source, у них нет.
TotalSleep *json.RawMessage `json:"totalSleep"`
}
func decodeMetrics(body []byte) ([]metricEnvelope, error) {
var env envelope
dec := json.NewDecoder(bytes.NewReader(body))
if err := dec.Decode(&env); err != nil {
return nil, fmt.Errorf("%w: %v", ErrMalformed, err) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
}
return env.Data.Metrics, nil
}
// decodeGroup разбирает точки одной метрики. Точка без разбираемой метки
// пропускается со счётчиком — ронять из-за неё остальную доставку незачем.
func decodeGroup(m metricEnvelope, res *Result) group {
g := group{metric: m.Name, units: m.Units, points: make([]Point, 0, len(m.Data))}
for _, raw := range m.Data {
var head pointHead
if err := json.Unmarshal(raw, &head); err != nil {
res.SkippedMalformed++
continue
}
start, ok := parseTime(head.Date, head.Start)
if !ok {
res.SkippedNoTime++
continue
}
end := start
if head.End != "" {
if e, err := time.Parse(timeLayout, head.End); err == nil {
end = e
}
}
_, offset := start.Zone()
g.points = append(g.points, Point{
Metric: m.Name,
Units: m.Units,
Start: start.UTC(),
End: end.UTC(),
OffsetSeconds: offset,
Raw: raw,
})
}
if len(g.points) == 0 {
return g
}
// Разделение схем выполняется ДО вывода слоя: у суточной сводки слой
// назначен, и её полуночные метки не должны участвовать в голосовании.
if m.Name == "sleep_analysis" {
splitSleep(&g, m.Data)
}
g.dense = len(g.points) >= denseThreshold
g.alignment = finestAlignment(g.points)
return g
}
// splitSleep разводит суточную сводку сна на собственное имя метрики.
//
// Под именем sleep_analysis HAE шлёт две несовместимые схемы: поэпизодную
// (start/end/value/qty) и суточную сводку (totalSleep/core/rem/deep/awake с
// меткой на местной полуночи). Имя sleep_analysis_summary — наше; инвариант
// «форма Apple не транслируется» это не нарушает, потому что поля внутри точки
// не переименовываются, разделяются только имена метрик, под которыми HAE
// смешал две схемы.
//
// Если в группе оказались обе схемы, сводки переезжают в отдельную группу,
// возвращаемую через g.spillover. Живьём смеси не наблюдалось, но полагаться
// на это нельзя: HAE меняется между версиями приложения.
func splitSleep(g *group, raws []json.RawMessage) {
summaries := make([]Point, 0)
episodes := make([]Point, 0, len(g.points))
for i, p := range g.points {
if i < len(raws) && isSleepSummary(raws[i]) {
p.Metric = sleepSummaryMetric
summaries = append(summaries, p)
continue
}
episodes = append(episodes, p)
}
if len(summaries) == 0 {
return
}
g.spillover = &group{
metric: sleepSummaryMetric,
units: g.units,
points: summaries,
layer: LayerDay,
fixed: true,
}
g.points = episodes
}
const sleepSummaryMetric = "sleep_analysis_summary"
func isSleepSummary(raw json.RawMessage) bool {
var head pointHead
if err := json.Unmarshal(raw, &head); err != nil {
return false
}
return head.TotalSleep != nil
}
// parseTime разбирает метку точки. Начало берётся из start, а при его
// отсутствии — из date. Измерено: start, когда он есть, всегда совпадает с
// date, поэтому правило не вводит второго источника метки — оно закрывает
// случай, когда HAE перестанет их дублировать.
func parseTime(date, start string) (time.Time, bool) {
s := start
if s == "" {
s = date
}
if s == "" {
return time.Time{}, false
}
t, err := time.Parse(timeLayout, s)
if err != nil {
return time.Time{}, false
}
return t, true
}
// finestAlignment возвращает самое мелкое выравнивание среди меток.
//
// Именно самое мелкое, а не преобладающее: у плотных метрик выравнивания
// перемешаны (active_energy — 1320 минутных меток и 21 часовая), потому что
// метка ровно на часе одновременно является и минутной. Метрика, у которой
// хоть одна метка стоит на середине часа, часовой не является.
func finestAlignment(points []Point) Layer {
finest := LayerHour
for _, p := range points {
switch {
case p.Start.Second() != 0 || p.Start.Nanosecond() != 0:
return LayerRaw
case p.Start.Minute() != 0:
finest = LayerMinute
}
}
return finest
}
// headerLayer переводит заголовок в слой, но только когда заголовок надёжен.
// `Default` соответствует трём разным режимам выгрузки и не означает ничего.
func headerLayer(aggregation string) Layer {
switch aggregation {
case "Minutes":
return LayerMinute
case "Hours":
return LayerHour
default:
return ""
}
}
// finer возвращает более мелкий из двух слоёв.
func finer(a, b Layer) Layer {
if rank(a) < rank(b) {
return a
}
return b
}
func rank(l Layer) int {
switch l {
case LayerSample:
return 0
case LayerRaw:
return 1
case LayerMinute:
return 2
case LayerHour:
return 3
case LayerDay:
return 4
default:
return 5
}
}
+546
View File
@@ -0,0 +1,546 @@
package hae_test
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/hae"
)
func load(t *testing.T, name string) []byte {
t.Helper()
body, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("фикстура %s: %v", name, err)
}
return body
}
// layersOf собирает слой по имени метрики: слой обязан выводиться ПО МЕТРИКЕ,
// а не по доставке, поэтому сверять его надо тоже по метрике.
func layersOf(points []hae.Point) map[string]hae.Layer {
out := make(map[string]hae.Layer)
for _, p := range points {
out[p.Metric] = p.Layer
}
return out
}
func countsOf(points []hae.Point) map[string]int {
out := make(map[string]int)
for _, p := range points {
out[p.Metric]++
}
return out
}
// Фикстуры собраны из живого архива с вычищенными значениями: выравнивание
// меток, порядок ключей и форма литералов сохранены (см. testdata/README.md).
func TestParseФикстурыЖивогоПотока(t *testing.T) {
t.Parallel()
cases := []struct {
name string
file string
meta hae.Meta
layer hae.Layer
metrics map[string]hae.Layer
}{
{
name: "минутная доставка",
file: "minute.json",
meta: hae.Meta{Aggregation: "Minutes"},
layer: hae.LayerMinute,
metrics: map[string]hae.Layer{
"active_energy": hae.LayerMinute,
"heart_rate_variability": hae.LayerMinute,
"apple_stand_hour": hae.LayerHour, // плотная и своя
"sleep_analysis_summary": hae.LayerDay, // слой назначен схемой
"six_minute_walking_test_distance": hae.LayerMinute, // редкая наследует
},
},
{
name: "часовая доставка",
file: "hour.json",
meta: hae.Meta{Aggregation: "Hours"},
layer: hae.LayerHour,
metrics: map[string]hae.Layer{
"active_energy": hae.LayerHour,
"apple_stand_hour": hae.LayerHour,
"sleep_analysis_summary": hae.LayerDay,
},
},
{
name: "посекундная доставка",
file: "raw.json",
meta: hae.Meta{Aggregation: "Default"},
layer: hae.LayerRaw,
metrics: map[string]hae.Layer{
"heart_rate": hae.LayerRaw,
"resting_heart_rate": hae.LayerRaw,
},
},
{
// Перенастройка автоматизации: в одной доставке минутные,
// посекундные и часовые метрики. Отнесение доставки к одному слою
// сложило бы минутные точки с посекундными.
name: "смешанная доставка",
file: "mixed.json",
meta: hae.Meta{Aggregation: "Default"},
layer: hae.LayerRaw,
metrics: map[string]hae.Layer{
"heart_rate": hae.LayerRaw,
"active_energy": hae.LayerMinute,
"apple_stand_hour": hae.LayerHour,
"sleep_analysis_summary": hae.LayerDay,
"resting_heart_rate": hae.LayerRaw, // редкая наследует слой доставки
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, c.file), c.meta)
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Layer != c.layer {
t.Errorf("слой доставки = %q, ожидался %q", res.Layer, c.layer)
}
got := layersOf(res.Points)
for metric, want := range c.metrics {
if got[metric] != want {
t.Errorf("%s: слой %q, ожидался %q", metric, got[metric], want)
}
}
if res.SkippedMalformed != 0 {
t.Errorf("живая фикстура дала %d неразобранных точек", res.SkippedMalformed)
}
})
}
}
// Под именем sleep_analysis приезжают две несовместимые схемы. Разделение
// выполняется ДО вывода слоя: сводок бывает больше порога плотности, и их
// полуночные метки иначе назначили бы всей доставке слой hour.
func TestParseРазводитДвеСхемыСна(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "minute.json"), hae.Meta{Aggregation: "Minutes"})
if err != nil {
t.Fatalf("разбор: %v", err)
}
counts := countsOf(res.Points)
if counts["sleep_analysis_summary"] == 0 {
t.Fatal("суточная сводка сна не выделена в собственную метрику")
}
if counts["sleep_analysis"] != 0 {
t.Errorf("под именем sleep_analysis осталось %d точек сводки", counts["sleep_analysis"])
}
for _, p := range res.Points {
if p.Metric == "sleep_analysis_summary" && p.Layer != hae.LayerDay {
t.Errorf("слой сводки = %q, ожидался day", p.Layer)
}
}
}
// Ключ по метке схлопнул бы эти три записи в одну: у них общий date и разные
// интервалы. Живьём такое встречается в 22 доставках из 94 (находка 47).
func TestParseСохраняетЗаписиСОднойМеткой(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
var intervals []hae.Point
for _, p := range res.Points {
if p.Metric == "interval_points" {
intervals = append(intervals, p)
}
}
if len(intervals) != 3 {
t.Fatalf("точек-интервалов %d, ожидалось 3", len(intervals))
}
starts := map[time.Time]int{}
ends := map[time.Time]int{}
for _, p := range intervals {
starts[p.Start]++
ends[p.End]++
if !p.End.After(p.Start) {
t.Errorf("конец %v не позже начала %v", p.End, p.Start)
}
}
if len(starts) != 1 {
t.Errorf("начал %d, ожидалось одно общее", len(starts))
}
if len(ends) != 3 {
t.Errorf("концов %d, ожидалось три разных", len(ends))
}
}
// Точка без end адресуется вырожденным интервалом: ключ одной формы для всех
// точек, без ветвления по «классу метрики».
func TestParseТочкаБезКонцаВырожденнаяВИнтервал(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
found := false
for _, p := range res.Points {
if p.Metric != "mixed_alignment" {
continue
}
found = true
if !p.End.Equal(p.Start) {
t.Fatalf("конец %v не равен началу %v у точки-измерения", p.End, p.Start)
}
}
if !found {
t.Fatal("метрика mixed_alignment не разобрана")
}
}
// Метка ровно на часе одновременно является и минутной, поэтому слой метрики —
// самое мелкое встретившееся выравнивание, а не преобладающее.
func TestParseСлойПоСамомуМелкомуВыравниванию(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if got := layersOf(res.Points)["mixed_alignment"]; got != hae.LayerMinute {
t.Errorf("слой = %q, ожидался minute: десять меток часовые, одна минутная", got)
}
}
// Незнакомое поле — норма: HAE меняется между версиями, и точка обязана
// доехать до хранилища дословно, вместе с тем, чего разбор не понимает.
func TestParseСохраняетТочкуДословно(t *testing.T) {
t.Parallel()
body := load(t, "handmade_edge.json")
res, err := hae.Parse(body, hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
var raw string
for _, p := range res.Points {
if p.Metric == "unknown_shape" {
raw = string(p.Raw)
}
}
if raw == "" {
t.Fatal("метрика unknown_shape не разобрана")
}
for _, want := range []string{
`"inventedField"`, // незнакомое поле целиком
`"nested"`, // вместе с вложенной структурой
`1.0`, // литерал, который round-trip превратил бы в 1
`9007199254740993`, // целое за пределами точности double
`0.095231829624713534`, // дробь длиннее двенадцати значащих цифр
} {
if !strings.Contains(raw, want) {
t.Errorf("в сохранённой точке нет %s:\n%s", want, raw)
}
}
}
// Серия ударов — 93% объёма метрики heart_rate_variability и третий формат
// времени. Разворачивать её в точки нельзя, преобразовывать эпоху — тоже:
// time.Unix и обратно не гарантирует дословности.
func TestParseНеТрогаетСериюУдаров(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "heartbeat_series.json"), hae.Meta{FallbackLayer: hae.LayerRaw})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if len(res.Points) != 2 {
t.Fatalf("точек %d, ожидалось 2: серия не должна разворачиваться", len(res.Points))
}
var series []struct {
Date json.Number `json:"date"`
}
var point struct {
Series json.RawMessage `json:"heartbeatSeries"`
}
if err := json.Unmarshal(res.Points[0].Raw, &point); err != nil {
t.Fatalf("точка не разбирается: %v", err)
}
if len(point.Series) == 0 {
t.Fatal("в точке нет heartbeatSeries")
}
dec := json.NewDecoder(strings.NewReader(string(point.Series)))
dec.UseNumber()
if err := dec.Decode(&series); err != nil {
t.Fatalf("серия не разбирается: %v", err)
}
if len(series) == 0 {
t.Fatal("серия пуста")
}
// Эпоха внутри серии осталась дробным числом, а не превратилась в строку
// или в целое: разбор её не касался.
if !strings.Contains(series[0].Date.String(), ".") {
t.Errorf("эпоха внутри серии изменена: %s", series[0].Date)
}
}
// Доставка без плотных метрик: измерено 2 такие из 89, обе с заголовком
// Default. Молчаливый выбор raw создал бы призрачный разрез, который поедет в
// каталог и в правило Read API «самый мелкий слой, покрывающий диапазон».
func TestParseДоставкаБезПлотныхМетрик(t *testing.T) {
t.Parallel()
body := load(t, "sparse_sleep.json")
t.Run("наследовать нечего и заголовок ненадёжен", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"})
if !errors.Is(err, hae.ErrLayerUnknown) {
t.Fatalf("ошибка %v, ожидалась ErrLayerUnknown", err)
}
if len(res.Points) != 0 {
t.Errorf("сохранено %d точек при неизвестном слое", len(res.Points))
}
})
t.Run("наследует слой автоматизации", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse(body, hae.Meta{Aggregation: "Default", FallbackLayer: hae.LayerMinute})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Layer != hae.LayerMinute {
t.Errorf("слой = %q, ожидался унаследованный minute", res.Layer)
}
})
t.Run("надёжный заголовок как последнее средство", func(t *testing.T) {
t.Parallel()
res, err := hae.Parse(body, hae.Meta{Aggregation: "Hours"})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.Layer != hae.LayerHour {
t.Errorf("слой = %q, ожидался hour из заголовка", res.Layer)
}
})
}
// Заголовок Default не означает режима, поэтому расхождение с ним не
// фиксируется: сигнал утонул бы в собственном шуме на потоке в пять минут.
func TestParseРасхождениеСлояТолькоПротивНадёжногоЗаголовка(t *testing.T) {
t.Parallel()
body := load(t, "raw.json") // выводится raw
res, err := hae.Parse(body, hae.Meta{Aggregation: "Default"})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.LayerMismatch {
t.Error("расхождение зафиксировано против заголовка Default")
}
res, err = hae.Parse(body, hae.Meta{Aggregation: "Hours"})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if !res.LayerMismatch {
t.Error("расхождение с надёжным заголовком Hours не зафиксировано")
}
if res.Layer != hae.LayerRaw {
t.Errorf("слой = %q: сохранять надо по выведенному, а не по заголовку", res.Layer)
}
}
// Частичный исход — счётчиками, а не ошибкой: пакет, у которого не разобралась
// часть точек, доезжает остальным содержимым.
func TestParseПропускаетНеразобранныеТочки(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
if res.SkippedNoTime != 3 {
t.Errorf("точек без метки %d, ожидалось 3", res.SkippedNoTime)
}
if res.SkippedMalformed != 1 {
t.Errorf("неразобранных точек %d, ожидалась 1", res.SkippedMalformed)
}
if countsOf(res.Points)["broken_timestamps"] != 0 {
t.Error("точка с непонятной меткой попала в хранилище")
}
}
// Время нормализовано в UTC, но зона не потеряна: без офсета неизвестно, в
// каком поясе человек находился.
func TestParseСохраняетСмещениеЗоны(t *testing.T) {
t.Parallel()
res, err := hae.Parse(load(t, "handmade_edge.json"), hae.Meta{})
if err != nil {
t.Fatalf("разбор: %v", err)
}
for _, p := range res.Points {
if p.Metric != "mixed_alignment" {
continue
}
if p.Start.Location() != time.UTC {
t.Errorf("время не в UTC: %v", p.Start)
}
if p.OffsetSeconds != 3*3600 {
t.Errorf("смещение %d секунд, ожидалось %d", p.OffsetSeconds, 3*3600)
}
return
}
t.Fatal("метрика mixed_alignment не разобрана")
}
// Разбор чужого формата обязан отвечать ошибкой, а не паникой: приём не имеет
// права упасть из-за того, что HAE прислал невиданное.
func TestParseВраждебныйВход(t *testing.T) {
t.Parallel()
cases := []struct {
name string
body string
wantErr error
}{
{"усечённое тело", `{"data":{"metrics":[`, hae.ErrMalformed},
{"не JSON вовсе", `не json`, hae.ErrMalformed},
{"пустое тело", ``, hae.ErrMalformed},
{"null вместо объекта", `null`, nil},
{"массив вместо data", `{"data":[1,2,3]}`, hae.ErrMalformed},
{"массив вместо metrics", `{"data":{"metrics":{}}}`, hae.ErrMalformed},
{"метрики отсутствуют", `{"data":{"workouts":[]}}`, nil},
{"точка не объект", `{"data":{"metrics":[{"name":"m","data":[1,2]}]}}`, nil},
{"число вместо строки даты", `{"data":{"metrics":[{"name":"m","data":[{"date":1}]}]}}`, nil},
{"дата не того формата", `{"data":{"metrics":[{"name":"m","data":[{"date":"2025-06-05T10:00:00Z"}]}]}}`, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, err := hae.Parse([]byte(c.body), hae.Meta{})
if c.wantErr == nil {
if err != nil && !errors.Is(err, hae.ErrLayerUnknown) {
t.Fatalf("неожиданная ошибка: %v", err)
}
return
}
if !errors.Is(err, c.wantErr) {
t.Fatalf("ошибка %v, ожидалась %v", err, c.wantErr)
}
})
}
}
// Разбор — чистая функция: ни времени, ни генерации идентификаторов. Два
// вызова на одном входе обязаны совпасть, иначе пересборка витрины из архива
// даст не то состояние, что живой приём.
func TestParseДетерминирован(t *testing.T) {
t.Parallel()
body := load(t, "mixed.json")
meta := hae.Meta{Aggregation: "Default"}
a, err := hae.Parse(body, meta)
if err != nil {
t.Fatalf("первый разбор: %v", err)
}
b, err := hae.Parse(body, meta)
if err != nil {
t.Fatalf("второй разбор: %v", err)
}
if len(a.Points) != len(b.Points) {
t.Fatalf("точек %d против %d", len(a.Points), len(b.Points))
}
for i := range a.Points {
if a.Points[i].Metric != b.Points[i].Metric ||
!a.Points[i].Start.Equal(b.Points[i].Start) ||
!a.Points[i].End.Equal(b.Points[i].End) ||
a.Points[i].Layer != b.Points[i].Layer ||
string(a.Points[i].Raw) != string(b.Points[i].Raw) {
t.Fatalf("точка %d разошлась между вызовами", i)
}
}
}
// Тело не должно портиться разбором: оно уже лежит в архиве, и пересборка
// прочитает те же байты.
func TestParseНеПортитТело(t *testing.T) {
t.Parallel()
body := load(t, "mixed.json")
before := string(body)
if _, err := hae.Parse(body, hae.Meta{Aggregation: "Default"}); err != nil {
t.Fatalf("разбор: %v", err)
}
if string(body) != before {
t.Error("разбор изменил тело доставки")
}
}
func FuzzParse(f *testing.F) {
for _, name := range []string{"minute.json", "sparse_sleep.json", "handmade_edge.json"} {
body, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
f.Fatalf("фикстура %s: %v", name, err)
}
f.Add(body)
}
f.Add([]byte(`{"data":{"metrics":[{"name":"m","units":"u","data":[{"date":"2025-06-05 10:00:00 +0300"}]}]}}`))
f.Fuzz(func(t *testing.T, body []byte) {
// Единственное требование: разбор произвольного входа не роняет
// процесс и не отдаёт точку без координат.
res, err := hae.Parse(body, hae.Meta{})
if err != nil {
return
}
for _, p := range res.Points {
if p.Start.IsZero() {
t.Fatal("точка без метки времени доехала до хранилища")
}
if p.End.Before(p.Start) {
t.Fatalf("конец раньше начала: %v < %v", p.End, p.Start)
}
if p.Layer == "" {
t.Fatal("точка без слоя доехала до хранилища")
}
}
})
}
+78
View File
@@ -0,0 +1,78 @@
package hae
// assignLayers проставляет слой каждой группе точек.
//
// Правило проверено на всей истории (находка 33) и дважды ломалось на живых
// данных при более простых формулировках: классификация доставки целиком
// сложила минутные точки с посекундными и удвоила сумму за час, а
// классификация каждой метрики по отдельности растащила редкие метрики по трём
// слоям.
//
// Работающая формулировка в три шага:
//
// 1. плотная метрика (не меньше denseThreshold точек) классифицируется по
// собственному выравниванию;
// 2. редкая наследует самый мелкий слой среди плотных метрик этой доставки —
// её собственное выравнивание ничего не значит, одна метка на часе бывает
// и у минутного ряда;
// 3. доставка без плотных метрик вовсе наследует последний надёжно выведенный
// слой той же автоматизации, а если и его нет — берёт слой из НАДЁЖНОГО
// заголовка.
//
// Третий шаг не роскошь: измерено 2 такие доставки из 89, обе с заголовком
// `Default`. Заменить его заголовком нельзя — `Default` не означает режима.
func assignLayers(groups []group, meta Meta, res *Result) error {
delivery := deliveryLayer(groups)
if delivery == "" {
// Плотных метрик нет: наследуем от прошлых доставок этой автоматизации,
// иначе — от надёжного заголовка.
delivery = meta.FallbackLayer
if delivery == "" {
delivery = res.HeaderLayer
}
if delivery == "" {
return ErrLayerUnknown
}
}
res.Layer = delivery
// Расхождение сверяется только с надёжным заголовком: `Default` не
// означает режима, и сравнение с ним давало бы WARN на каждой доставке
// потока в пять минут — сигнал утонул бы в собственном шуме.
res.LayerMismatch = res.HeaderLayer != "" && delivery != res.HeaderLayer
for i := range groups {
g := &groups[i]
switch {
case g.fixed:
// Слой назначен схемой, выводить нечего.
case g.dense:
g.layer = g.alignment
default:
g.layer = delivery
}
}
return nil
}
// deliveryLayer — самый мелкий слой среди плотных метрик доставки. Пустой,
// если плотных метрик нет.
//
// Группы с назначенным слоем в голосовании не участвуют: суточных сводок сна
// бывает больше порога плотности, и их полуночные метки иначе назначили бы
// всей доставке слой hour.
func deliveryLayer(groups []group) Layer {
var out Layer
for _, g := range groups {
if g.fixed || !g.dense {
continue
}
if out == "" {
out = g.alignment
continue
}
out = finer(out, g.alignment)
}
return out
}
+84
View File
@@ -0,0 +1,84 @@
package hae_test
import (
"fmt"
"runtime"
"strings"
"testing"
"time"
"git.vakhrushev.me/av/healthlog/internal/hae"
)
// Тела доходили до 42 МиБ, и стратегия декодирования — часть контракта, а не
// деталь: разбор такого тела в map[string]any удерживает 197 МиБ кучи против
// 54 МиБ у формы с json.RawMessage. Вместе с самим телом и удвоением в чтении
// пик доходил бы до ~300 МиБ на доставку — это OOM ровно на пике потока, когда
// терять доставки дороже всего.
//
// Тест сторожит именно порядок величины, а не точное число: он обязан падать
// при возврате к map[string]any и не падать от смены версии Go.
func TestParseУдержаниеКучи(t *testing.T) {
if testing.Short() {
t.Skip("измерение кучи: не для -short")
}
body := largeBody(40, 4000)
t.Logf("тело %d МиБ", len(body)>>20)
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
res, err := hae.Parse(body, hae.Meta{Aggregation: "Minutes"})
if err != nil {
t.Fatalf("разбор: %v", err)
}
runtime.GC()
runtime.ReadMemStats(&after)
retained := int64(after.HeapAlloc) - int64(before.HeapAlloc)
limit := int64(len(body)) * 4
t.Logf("точек %d, удержано %d МиБ при теле %d МиБ",
len(res.Points), retained>>20, len(body)>>20)
if retained > limit {
t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+
"похоже, точки снова разбираются в map[string]any",
retained>>20, len(body)>>20)
}
// Результат обязан дожить до замера, иначе мерить нечего.
runtime.KeepAlive(res)
runtime.KeepAlive(body)
}
// largeBody собирает тело, похожее на настоящее: те же поля, тот же формат
// метки, минутное выравнивание.
func largeBody(metrics, points int) []byte {
var b strings.Builder
b.WriteString(`{"data":{"metrics":[`)
base := time.Date(2025, 6, 5, 0, 0, 0, 0, time.FixedZone("", 3*3600))
for m := range metrics {
if m > 0 {
b.WriteByte(',')
}
fmt.Fprintf(&b, `{"name":"metric_%d","units":"count","data":[`, m)
for p := range points {
if p > 0 {
b.WriteByte(',')
}
ts := base.Add(time.Duration(p) * time.Minute).Format("2006-01-02 15:04:05 -0700")
fmt.Fprintf(&b,
`{"date":"%s","qty":%d.09523182962471353,"source":"Device A|Device B"}`,
ts, p%97)
}
b.WriteString(`]}`)
}
b.WriteString(`]}}`)
return []byte(b.String())
}
+45
View File
@@ -0,0 +1,45 @@
# Фикстуры разбора Health Auto Export
Конвенция требует тестов на **реальных** пакетах: документация формата тонкая и
местами расходится с тем, что приложение шлёт на самом деле. Инвариант при этом
запрещает данным о здоровье попадать под контроль версий. Поэтому фикстуры
собраны из живого архива скриптом `tmp/research/fixtures.py`, который сохраняет
всё, на чём работает разбор, и уничтожает всё, что является измерением.
Сохранено дословно: порядок ключей, форма числового литерала, выравнивание
меток по секундам и минутам, смещение зоны, длительности интервалов, невидимые
символы в именах устройств, категориальные значения. Заменено: числа (цифры
переставлены с сохранением формы), даты (сдвинуты на постоянную величину),
имена устройств.
| Файл | Что проверяет |
|---|---|
| `minute.json` | минутная доставка; плотные метрики минутные, одна (`apple_stand_hour`) часовая — классификация **по метрике**, а не по доставке; редкие наследуют минутный слой; суточная сводка сна |
| `hour.json` | часовая доставка целиком |
| `raw.json` | посекундная доставка |
| `mixed.json` | одна доставка с минутными, посекундными и часовыми метриками — перенастройка автоматизации |
| `heartbeat_series.json` | точка с `heartbeatSeries`: третий формат времени, серия проходит исходными байтами |
| `sparse_sleep.json` | доставка **без плотных метрик** (заголовок `Default` не спасает) и поэпизодный сон с задвоенной меткой — интервальная идентичность |
| `handmade_edge.json` | случаи, которых живой поток не даёт (см. ниже) |
`handmade_edge.json` собран руками, скриптом не воспроизводится:
- `mixed_alignment` — плотная метрика, где 10 меток часовые и одна минутная:
слой обязан выйти минутным, потому что слой метрики — **самое мелкое**
встретившееся выравнивание;
- `unknown_shape` — незнакомое поле точки (в том числе вложенное), `1.0`, целое
больше 2⁵³ и дробь длиннее двенадцати значащих цифр: всё это обязано пережить
хранение дословно;
- `broken_timestamps` — RFC 3339 вместо формата HAE, пустая строка, отсутствие
`date` и число вместо строки: каждая точка пропускается со счётчиком, разбор
не падает;
- `interval_points` — три записи с одной меткой и разными интервалами: ключ по
метке схлопнул бы их в одну.
Обновить набор:
```
python3 tmp/research/fixtures.py --list
python3 tmp/research/fixtures.py <файл из data/raw> --metrics a,b --limit 14 \
--out internal/hae/testdata/<имя>.json
```
+78
View File
@@ -0,0 +1,78 @@
{
"_comment": "Рукотворная фикстура: случаи, которых живой поток не даёт. Скриптом tmp/research/fixtures.py не порождается. Значения выдуманы целиком.",
"data": {
"metrics": [
{
"name": "mixed_alignment",
"units": "count",
"data": [
{ "date": "2025-06-05 10:00:00 +0300", "qty": 1, "source": "Device A" },
{ "date": "2025-06-05 11:00:00 +0300", "qty": 2, "source": "Device A" },
{ "date": "2025-06-05 12:00:00 +0300", "qty": 3, "source": "Device A" },
{ "date": "2025-06-05 13:00:00 +0300", "qty": 4, "source": "Device A" },
{ "date": "2025-06-05 14:00:00 +0300", "qty": 5, "source": "Device A" },
{ "date": "2025-06-05 15:00:00 +0300", "qty": 6, "source": "Device A" },
{ "date": "2025-06-05 16:00:00 +0300", "qty": 7, "source": "Device A" },
{ "date": "2025-06-05 17:00:00 +0300", "qty": 8, "source": "Device A" },
{ "date": "2025-06-05 18:00:00 +0300", "qty": 9, "source": "Device A" },
{ "date": "2025-06-05 19:00:00 +0300", "qty": 10, "source": "Device A" },
{ "date": "2025-06-05 19:30:00 +0300", "qty": 11, "source": "Device A" }
]
},
{
"name": "unknown_shape",
"units": "widget",
"data": [
{
"date": "2025-06-05 10:00:00 +0300",
"qty": 1.0,
"hugeInteger": 9007199254740993,
"longFraction": 0.095231829624713534,
"inventedField": { "nested": ["значение", 42], "flag": true },
"source": "Device A"
}
]
},
{
"name": "broken_timestamps",
"units": "count",
"data": [
{ "date": "2025-06-05T10:00:00Z", "qty": 1, "source": "Device A" },
{ "date": "", "qty": 2, "source": "Device A" },
{ "qty": 3, "source": "Device A" },
{ "date": 1749110400, "qty": 4, "source": "Device A" }
]
},
{
"name": "interval_points",
"units": "hr",
"data": [
{
"date": "2025-06-05 22:04:00 +0300",
"start": "2025-06-05 22:04:00 +0300",
"end": "2025-06-05 22:16:00 +0300",
"qty": 0.2,
"value": "Во сне",
"source": "Device A"
},
{
"date": "2025-06-05 22:04:00 +0300",
"start": "2025-06-05 22:04:00 +0300",
"end": "2025-06-06 02:21:00 +0300",
"qty": 4.28,
"value": "В кровати",
"source": "Device A"
},
{
"date": "2025-06-05 22:04:00 +0300",
"start": "2025-06-05 22:04:00 +0300",
"end": "2025-06-06 07:51:00 +0300",
"qty": 9.78,
"value": "В кровати",
"source": "Device A"
}
]
}
]
}
}
+625
View File
@@ -0,0 +1,625 @@
{
"data": {
"metrics": [
{
"data": [
{
"source": "Device A  ",
"qty": 23.750315211285474,
"heartbeatSeries": [
{
"timeSinceStart": 0.30703959892431944,
"date": 4664261259.9741159,
"precededByGap": true
},
{
"timeSinceStart": 7.2309957839083619,
"precededByGap": false,
"date": 4041665251.900013
},
{
"date": 8876178853.342975,
"timeSinceStart": 1.4527448217879887,
"precededByGap": false
},
{
"timeSinceStart": 1.4165263660769081,
"precededByGap": false,
"date": 1000535638.3966900
},
{
"timeSinceStart": 9.0521978531957290,
"date": 9957382189.7722167,
"precededByGap": false
},
{
"precededByGap": false,
"timeSinceStart": 5.1930893136228618,
"date": 6117853154.6013721
},
{
"timeSinceStart": 6.168269553311381,
"precededByGap": false,
"date": 1745725940.9978662
},
{
"date": 1976712978.903725,
"precededByGap": false,
"timeSinceStart": 2.3069475267343171
},
{
"precededByGap": false,
"timeSinceStart": 5.8297908527491859,
"date": 3587622577.7499014
},
{
"date": 9819293385.3179276,
"precededByGap": false,
"timeSinceStart": 15.188011048333195
},
{
"timeSinceStart": 95.21863539994910,
"date": 1308788664.0329858,
"precededByGap": false
},
{
"timeSinceStart": 64.894156680251802,
"date": 5505628959.0309993,
"precededByGap": false
},
{
"timeSinceStart": 21.161691194935153,
"precededByGap": false,
"date": 6015674224.9529920
},
{
"precededByGap": false,
"timeSinceStart": 55.60822150782505,
"date": 1400661582.8317718
},
{
"date": 3224841469.3113241,
"timeSinceStart": 66.474073083266903,
"precededByGap": false
},
{
"date": 1057590720.8567,
"timeSinceStart": 74.506280415883408,
"precededByGap": false
},
{
"timeSinceStart": 51.567387739757723,
"date": 3963510291.5702180,
"precededByGap": false
},
{
"date": 7082814560.4384724,
"precededByGap": false,
"timeSinceStart": 83.299392816278859
},
{
"date": 6757810417.418241,
"precededByGap": false,
"timeSinceStart": 75.088533409617035
},
{
"precededByGap": false,
"date": 3540114945.8209328,
"timeSinceStart": 56.255259710811021
},
{
"date": 9959500053.3602796,
"timeSinceStart": 43.067500980343614,
"precededByGap": false
},
{
"date": 4426461214.9080886,
"precededByGap": false,
"timeSinceStart": 19.646576005446991
},
{
"timeSinceStart": 81.124503678836476,
"date": 6699644671.7938884,
"precededByGap": false
},
{
"date": 7749707811.2428946,
"precededByGap": false,
"timeSinceStart": 61.227798246118771
},
{
"timeSinceStart": 67.676748577463995,
"date": 6210596381.331694,
"precededByGap": false
},
{
"timeSinceStart": 70.159292660068567,
"date": 7715645840.5534507,
"precededByGap": false
},
{
"timeSinceStart": 92.172251351930605,
"date": 2323554989.0717421,
"precededByGap": false
},
{
"precededByGap": false,
"date": 3886410707.7568370,
"timeSinceStart": 38.86637603495670
},
{
"date": 2657848907.7568171,
"timeSinceStart": 50.485827093291539,
"precededByGap": false
},
{
"date": 2069101105.1355647,
"timeSinceStart": 60.047066360528097,
"precededByGap": false
},
{
"date": 4262931019.7365472,
"precededByGap": false,
"timeSinceStart": 56.760770648554205
},
{
"date": 4457320713.1803937,
"precededByGap": false,
"timeSinceStart": 41.802607769366120
},
{
"precededByGap": false,
"date": 1533078800.0423091,
"timeSinceStart": 49.601596154020277
},
{
"timeSinceStart": 18.441498943378064,
"date": 5980986523.3220126,
"precededByGap": false
},
{
"precededByGap": false,
"date": 7712336863.5983489,
"timeSinceStart": 71.414598926762732
},
{
"precededByGap": false,
"timeSinceStart": 92.4437,
"date": 3514512985.6868826
},
{
"date": 8322478175.1114594,
"precededByGap": false,
"timeSinceStart": 88.6024
},
{
"precededByGap": false,
"timeSinceStart": 29.248195440085157,
"date": 1168676776.9662425
},
{
"timeSinceStart": 79.433379869332866,
"date": 5035752878.088219,
"precededByGap": false
},
{
"date": 1816622784.7747694,
"precededByGap": false,
"timeSinceStart": 75.952011843074849
},
{
"date": 4080028792.2903372,
"timeSinceStart": 53.348069560324092,
"precededByGap": false
},
{
"precededByGap": false,
"date": 1811194281.9697089,
"timeSinceStart": 35.933995598712666
},
{
"date": 8111403588.2629364,
"precededByGap": false,
"timeSinceStart": 40.745484764163
},
{
"timeSinceStart": 72.730211831269887,
"date": 3169688989.3112253,
"precededByGap": false
},
{
"timeSinceStart": 40.986504143982092,
"date": 6527999135.5416463,
"precededByGap": false
},
{
"precededByGap": false,
"timeSinceStart": 62.331864539823858,
"date": 7385945563.3943794
},
{
"precededByGap": false,
"date": 9921005072.0964865,
"timeSinceStart": 30.064845423262511
},
{
"timeSinceStart": 11.478003936471302,
"date": 1508595739.6221127,
"precededByGap": false
},
{
"date": 5875305221.5406955,
"timeSinceStart": 94.512920953956073,
"precededByGap": false
},
{
"timeSinceStart": 62.149608758072446,
"date": 1457298229.9513884,
"precededByGap": false
},
{
"timeSinceStart": 62.618941135915397,
"precededByGap": false,
"date": 5303487004.6047872
}
],
"date": "2025-06-06 00:16:29 +0300",
"start": "2025-06-06 00:16:29 +0300",
"end": "2025-06-06 00:17:28 +0300"
},
{
"end": "2025-06-06 11:31:35 +0300",
"qty": 90.14058990512054,
"heartbeatSeries": [
{
"precededByGap": true,
"date": 7083484890.61297,
"timeSinceStart": 0.407468
},
{
"date": 1474906531.5871756,
"precededByGap": false,
"timeSinceStart": 5.004982856308269
},
{
"date": 8658903263.1018850,
"timeSinceStart": 1.4807963559238753,
"precededByGap": false
},
{
"timeSinceStart": 1.024701,
"date": 4526622271.20697,
"precededByGap": true
},
{
"timeSinceStart": 2.5091503502811948,
"date": 5019070240.3891780,
"precededByGap": false
},
{
"timeSinceStart": 1.4947476825708789,
"precededByGap": true,
"date": 2436626670.6771267
},
{
"timeSinceStart": 4.2381164797072304,
"date": 7859156077.2136691,
"precededByGap": false
},
{
"timeSinceStart": 1.4840301800760543,
"precededByGap": false,
"date": 3350788867.9988499
},
{
"timeSinceStart": 1.6174706338581461,
"date": 2201781030.1790726,
"precededByGap": false
},
{
"timeSinceStart": 76.362114876750040,
"date": 4956007120.482461,
"precededByGap": false
},
{
"timeSinceStart": 53.631480432828275,
"precededByGap": false,
"date": 9149621715.2385838
},
{
"date": 3715855703.0157515,
"timeSinceStart": 72.497390832000272,
"precededByGap": false
},
{
"precededByGap": false,
"timeSinceStart": 86.018403315174563,
"date": 4736441058.57521
},
{
"date": 9000957328.1726289,
"precededByGap": false,
"timeSinceStart": 85.838039628007138
},
{
"date": 6576796906.3091040,
"precededByGap": false,
"timeSinceStart": 48.492219341044194
},
{
"timeSinceStart": 10.361132650103484,
"date": 6256615706.247128,
"precededByGap": false
},
{
"timeSinceStart": 29.87069792740100,
"date": 1294004322.690807,
"precededByGap": false
},
{
"date": 1001961220.510529,
"timeSinceStart": 61.39134,
"precededByGap": false
},
{
"precededByGap": false,
"timeSinceStart": 51.680727012238700,
"date": 1839323959.7726563
},
{
"precededByGap": false,
"date": 3472361417.096725,
"timeSinceStart": 91
},
{
"date": 4826990425.3176300,
"timeSinceStart": 83.619765240667191,
"precededByGap": false
},
{
"timeSinceStart": 10.497349,
"date": 6106959041.51327,
"precededByGap": false
},
{
"timeSinceStart": 71.683393,
"date": 5914109094.15767,
"precededByGap": false
},
{
"date": 8535185762.4824187,
"precededByGap": false,
"timeSinceStart": 77.757778295471486
},
{
"date": 1951867913.0849229,
"timeSinceStart": 67.24866615699993,
"precededByGap": false
},
{
"date": 3290122044.1324209,
"timeSinceStart": 36.193907888310981,
"precededByGap": false
},
{
"timeSinceStart": 41.853869154744907,
"date": 7553108736.6595391,
"precededByGap": true
},
{
"precededByGap": false,
"timeSinceStart": 54.278271967966989,
"date": 3397429066.0191995
},
{
"date": 3383964606.0910157,
"timeSinceStart": 15.374542569216173,
"precededByGap": false
},
{
"precededByGap": false,
"date": 9453909931.5302071,
"timeSinceStart": 70.688961451284457
},
{
"timeSinceStart": 85.5376113915716,
"date": 8527903185.5201554,
"precededByGap": false
},
{
"date": 3833532726.0466381,
"timeSinceStart": 39.325074723044682,
"precededByGap": false
},
{
"timeSinceStart": 51.677835663423346,
"precededByGap": false,
"date": 7096040758.6256057
},
{
"date": 6685143704.3116423,
"precededByGap": false,
"timeSinceStart": 61.04785026
},
{
"date": 9765989645.7654644,
"precededByGap": false,
"timeSinceStart": 15.919857383265833
},
{
"precededByGap": false,
"timeSinceStart": 30.709081717827611,
"date": 2123404262.6558111
},
{
"timeSinceStart": 69.688839940091103,
"date": 7413445159.6479758,
"precededByGap": false
},
{
"date": 5814969011.2566160,
"timeSinceStart": 57.667422028031522,
"precededByGap": false
},
{
"date": 6592553346.0727878,
"precededByGap": false,
"timeSinceStart": 60.47162189527939
},
{
"date": 5587039604.0316581,
"precededByGap": false,
"timeSinceStart": 19.900839099832010
},
{
"date": 4614951959.348113,
"precededByGap": false,
"timeSinceStart": 83.612189839618083
},
{
"precededByGap": false,
"timeSinceStart": 60.161461940965297,
"date": 8112355943.0298539
},
{
"timeSinceStart": 83.782742593829671,
"date": 1484454505.5246212,
"precededByGap": false
},
{
"date": 2959518110.4921864,
"precededByGap": false,
"timeSinceStart": 24.841421745176644
},
{
"precededByGap": false,
"date": 1314440885.0832479,
"timeSinceStart": 64.915260287690918
},
{
"timeSinceStart": 74.170133735782600,
"date": 4031188734.4970767,
"precededByGap": false
},
{
"date": 9123404825.219476,
"precededByGap": false,
"timeSinceStart": 29.865935408801001
},
{
"precededByGap": false,
"timeSinceStart": 38.166543961036381,
"date": 2727966353.3850555
},
{
"date": 2706441390.2806612,
"timeSinceStart": 33.591223332904774,
"precededByGap": false
},
{
"date": 5914992824.6840509,
"timeSinceStart": 83.388875928168685,
"precededByGap": false
},
{
"timeSinceStart": 71.83816912598245,
"date": 1492585088.0453538,
"precededByGap": false
},
{
"timeSinceStart": 69.466728703019153,
"precededByGap": false,
"date": 5894821074.7681045
},
{
"date": 3287835300.1858831,
"timeSinceStart": 22.908710811229499,
"precededByGap": false
},
{
"timeSinceStart": 85.360101931423561,
"date": 5640034746.7509943,
"precededByGap": false
},
{
"precededByGap": false,
"timeSinceStart": 73.9419,
"date": 1636291248.305578
},
{
"date": 2119560477.3399937,
"precededByGap": false,
"timeSinceStart": 94.668323626933766
},
{
"date": 3271617698.7128734,
"precededByGap": false,
"timeSinceStart": 64.9421751881103
},
{
"date": 1940624563.8133917,
"precededByGap": false,
"timeSinceStart": 88.635802724250809
},
{
"date": 9840541026.4970818,
"precededByGap": false,
"timeSinceStart": 78.314590572727896
},
{
"date": 5491053006.9390733,
"timeSinceStart": 85.769540859478425,
"precededByGap": false
},
{
"precededByGap": false,
"timeSinceStart": 93.473139781209616,
"date": 6535063885.8336494
},
{
"timeSinceStart": 65.146412902364838,
"precededByGap": false,
"date": 2347718058.8244127
},
{
"precededByGap": false,
"timeSinceStart": 11.611609,
"date": 1267561134.54121
},
{
"date": 1195742041.5845607,
"timeSinceStart": 28.889132122802926,
"precededByGap": false
},
{
"date": 1569940739.6020094,
"timeSinceStart": 23.652990950531513,
"precededByGap": false
},
{
"timeSinceStart": 97.519180657346742,
"precededByGap": false,
"date": 7714417592.5918662
},
{
"timeSinceStart": 17.331792299665982,
"precededByGap": false,
"date": 5509625875.398436
},
{
"precededByGap": false,
"timeSinceStart": 63.379430537893649,
"date": 2848110736.6401262
}
],
"start": "2025-06-06 11:30:35 +0300",
"source": "Device A  ",
"date": "2025-06-06 11:30:35 +0300"
}
],
"name": "heart_rate_variability",
"units": "ms"
}
]
}
}
+281
View File
@@ -0,0 +1,281 @@
{
"data": {
"metrics": [
{
"name": "active_energy",
"units": "kJ",
"data": [
{
"qty": 9.0964610089950502,
"date": "2025-06-05 00:00:00 +0300",
"source": "Device A  "
},
{
"date": "2025-06-05 01:00:00 +0300",
"qty": 1.8011831544351500,
"source": "Device A  "
},
{
"qty": 8.3677756683275249,
"source": "Device A  ",
"date": "2025-06-05 02:00:00 +0300"
},
{
"source": "Device A  ",
"qty": 3.3985452404807405,
"date": "2025-06-05 03:00:00 +0300"
},
{
"qty": 12.618722903999360,
"date": "2025-06-05 04:00:00 +0300",
"source": "Device A  "
},
{
"source": "Device A  ",
"date": "2025-06-05 05:00:00 +0300",
"qty": 40.351034546189656
},
{
"source": "Device A  ",
"qty": 6.507009541624532,
"date": "2025-06-05 06:00:00 +0300"
},
{
"date": "2025-06-06 06:00:00 +0300",
"qty": 55.821611193078514,
"source": "Device A  "
},
{
"qty": 2.295029103054565,
"date": "2025-06-06 07:00:00 +0300",
"source": "Device A  "
},
{
"date": "2025-06-06 08:00:00 +0300",
"qty": 322.8983115506521,
"source": "Device A  "
},
{
"qty": 91.588676831471213,
"date": "2025-06-06 09:00:00 +0300",
"source": "Device A  "
},
{
"qty": 462.61760657433137,
"source": "Device A  ",
"date": "2025-06-06 10:00:00 +0300"
},
{
"date": "2025-06-06 11:00:00 +0300",
"qty": 958.07555299152843,
"source": "Device A  "
},
{
"qty": 67.473524552265953,
"source": "Device A  ",
"date": "2025-06-06 12:00:00 +0300"
}
]
},
{
"units": "hr",
"data": [
{
"core": 6.2052788381114499,
"inBedEnd": "2025-06-05 05:22:26 +0300",
"date": "2025-06-05 00:00:00 +0300",
"inBed": 0,
"rem": 0.89195271872064281,
"sleepEnd": "2025-06-05 05:22:26 +0300",
"source": "Device A  ",
"totalSleep": 4.0716939170300220,
"inBedStart": "2025-06-04 22:34:34 +0300",
"asleep": 0,
"awake": 4.3396901109717361,
"sleepStart": "2025-06-04 22:34:34 +0300",
"deep": 0.68835541253715609
},
{
"awake": 9.638151247778914,
"inBed": 0,
"deep": 0.38341974910703597,
"inBedStart": "2025-06-05 22:43:20 +0300",
"rem": 0.43532011302795238,
"sleepEnd": "2025-06-06 07:57:39 +0300",
"date": "2025-06-06 00:00:00 +0300",
"core": 5.152708504451071,
"source": "Device A  ",
"asleep": 0,
"sleepStart": "2025-06-05 22:43:20 +0300",
"inBedEnd": "2025-06-06 07:57:39 +0300",
"totalSleep": 4.4003075324079272
}
],
"name": "sleep_analysis"
},
{
"name": "apple_stand_hour",
"data": [
{
"qty": 8,
"source": "Device A",
"date": "2025-06-05 07:00:00 +0300"
},
{
"source": "Device A",
"qty": 8,
"date": "2025-06-05 08:00:00 +0300"
},
{
"date": "2025-06-05 09:00:00 +0300",
"qty": 8,
"source": "Device A"
},
{
"source": "Device A",
"date": "2025-06-05 10:00:00 +0300",
"qty": 8
},
{
"source": "Device A",
"date": "2025-06-05 12:00:00 +0300",
"qty": 8
},
{
"qty": 8,
"source": "Device A",
"date": "2025-06-05 13:00:00 +0300"
},
{
"qty": 8,
"source": "Device A",
"date": "2025-06-05 15:00:00 +0300"
},
{
"qty": 8,
"date": "2025-06-05 20:00:00 +0300",
"source": "Device A"
},
{
"date": "2025-06-05 21:00:00 +0300",
"qty": 8,
"source": "Device A"
},
{
"date": "2025-06-06 02:00:00 +0300",
"source": "Device A",
"qty": 8
},
{
"qty": 8,
"source": "Device A",
"date": "2025-06-06 08:00:00 +0300"
},
{
"date": "2025-06-06 09:00:00 +0300",
"source": "Device A",
"qty": 8
},
{
"date": "2025-06-06 10:00:00 +0300",
"qty": 8,
"source": "Device A"
},
{
"source": "Device A",
"date": "2025-06-06 11:00:00 +0300",
"qty": 8
}
],
"units": "count"
},
{
"units": "ms",
"data": [
{
"date": "2025-06-05 00:00:00 +0300",
"qty": 51.209189787471513,
"source": "Device A  "
},
{
"date": "2025-06-05 02:00:00 +0300",
"source": "Device A  ",
"qty": 62.091594303847203
},
{
"date": "2025-06-05 04:00:00 +0300",
"qty": 496.11595771813635,
"source": "Device A  "
},
{
"qty": 89.502763665053950,
"source": "Device A  ",
"date": "2025-06-05 06:00:00 +0300"
},
{
"source": "Device A  ",
"qty": 15.145830153758666,
"date": "2025-06-05 08:00:00 +0300"
},
{
"source": "Device A  ",
"date": "2025-06-05 10:00:00 +0300",
"qty": 96.442927435831922
},
{
"date": "2025-06-05 12:00:00 +0300",
"qty": 16.490675484303301,
"source": "Device A  "
},
{
"date": "2025-06-05 22:00:00 +0300",
"source": "Device A  ",
"qty": 25.801867024959922
},
{
"date": "2025-06-06 00:00:00 +0300",
"qty": 23.750315211285474,
"source": "Device A  "
},
{
"source": "Device A  ",
"date": "2025-06-06 02:00:00 +0300",
"qty": 21.676280750344913
},
{
"qty": 108.159159427689,
"date": "2025-06-06 04:00:00 +0300",
"source": "Device A  "
},
{
"source": "Device A  ",
"date": "2025-06-06 06:00:00 +0300",
"qty": 14.548300046808019
},
{
"qty": 22.843350035832434,
"date": "2025-06-06 08:00:00 +0300",
"source": "Device A  "
},
{
"date": "2025-06-06 11:00:00 +0300",
"qty": 90.14058990512054,
"source": "Device A  "
}
],
"name": "heart_rate_variability"
},
{
"data": [
{
"qty": 745,
"source": "Device A",
"date": "2025-06-05 05:00:00 +0300"
}
],
"units": "m",
"name": "six_minute_walking_test_distance"
}
]
}
}
+281
View File
@@ -0,0 +1,281 @@
{
"data": {
"metrics": [
{
"units": "hr",
"data": [
{
"rem": 0.89195271872064281,
"inBed": 0,
"awake": 4.3396901109717361,
"deep": 0.68835541253715609,
"asleep": 0,
"totalSleep": 4.0716939170300220,
"source": "Device A  ",
"core": 6.2052788381114499,
"inBedStart": "2025-06-04 22:34:34 +0300",
"sleepEnd": "2025-06-05 05:22:26 +0300",
"inBedEnd": "2025-06-05 05:22:26 +0300",
"date": "2025-06-05 00:00:00 +0300",
"sleepStart": "2025-06-04 22:34:34 +0300"
},
{
"inBedStart": "2025-06-05 22:43:20 +0300",
"deep": 0.38341974910703597,
"rem": 0.43532011302795238,
"core": 5.152708504451071,
"date": "2025-06-06 00:00:00 +0300",
"inBed": 0,
"inBedEnd": "2025-06-06 07:57:39 +0300",
"asleep": 0,
"source": "Device A  ",
"sleepStart": "2025-06-05 22:43:20 +0300",
"awake": 9.638151247778914,
"sleepEnd": "2025-06-06 07:57:39 +0300",
"totalSleep": 4.4003075324079272
}
],
"name": "sleep_analysis"
},
{
"name": "apple_stand_hour",
"data": [
{
"source": "Device A",
"date": "2025-06-05 07:00:00 +0300",
"qty": 8
},
{
"source": "Device A",
"qty": 8,
"date": "2025-06-05 08:00:00 +0300"
},
{
"qty": 8,
"date": "2025-06-05 09:00:00 +0300",
"source": "Device A"
},
{
"qty": 8,
"source": "Device A",
"date": "2025-06-05 10:00:00 +0300"
},
{
"source": "Device A",
"date": "2025-06-05 12:00:00 +0300",
"qty": 8
},
{
"source": "Device A",
"date": "2025-06-05 13:00:00 +0300",
"qty": 8
},
{
"date": "2025-06-05 15:00:00 +0300",
"qty": 8,
"source": "Device A"
},
{
"source": "Device A",
"qty": 8,
"date": "2025-06-05 19:00:00 +0300"
},
{
"source": "Device A",
"qty": 8,
"date": "2025-06-05 20:00:00 +0300"
},
{
"source": "Device A",
"qty": 8,
"date": "2025-06-05 21:00:00 +0300"
},
{
"source": "Device A",
"qty": 8,
"date": "2025-06-06 02:00:00 +0300"
},
{
"source": "Device A",
"date": "2025-06-06 08:00:00 +0300",
"qty": 8
},
{
"qty": 8,
"date": "2025-06-06 09:00:00 +0300",
"source": "Device A"
},
{
"source": "Device A",
"date": "2025-06-06 10:00:00 +0300",
"qty": 8
}
],
"units": "count"
},
{
"units": "kJ",
"name": "active_energy",
"data": [
{
"qty": 0.94633004162661996,
"source": "Device A  ",
"date": "2025-06-05 00:24:00 +0300"
},
{
"source": "Device A  ",
"date": "2025-06-05 00:25:00 +0300",
"qty": 0.22385813552875930
},
{
"source": "Device A  ",
"date": "2025-06-05 00:26:00 +0300",
"qty": 0.22385813552875930
},
{
"source": "Device A  ",
"date": "2025-06-05 00:27:00 +0300",
"qty": 0.27046667301884517
},
{
"qty": 0.23659841288096517,
"date": "2025-06-05 00:28:00 +0300",
"source": "Device A  "
},
{
"qty": 0.23659841288096517,
"source": "Device A  ",
"date": "2025-06-05 00:29:00 +0300"
},
{
"source": "Device A  ",
"qty": 0.23659841288096517,
"date": "2025-06-05 00:30:00 +0300"
},
{
"qty": 4.3538077885409284,
"source": "Device A  ",
"date": "2025-06-06 11:47:00 +0300"
},
{
"date": "2025-06-06 11:48:00 +0300",
"qty": 4.3538077885409284,
"source": "Device A  "
},
{
"date": "2025-06-06 11:49:00 +0300",
"qty": 4.3538077885409284,
"source": "Device A  "
},
{
"date": "2025-06-06 11:50:00 +0300",
"source": "Device A  ",
"qty": 2.1847867270334114
},
{
"date": "2025-06-06 11:51:00 +0300",
"qty": 2.1847867270334114,
"source": "Device A  "
},
{
"source": "Device A  ",
"date": "2025-06-06 11:52:00 +0300",
"qty": 3.3347070079576802
},
{
"source": "Device A  ",
"date": "2025-06-06 11:53:00 +0300",
"qty": 0.96620370889283594
}
]
},
{
"data": [
{
"qty": 51.209189787471513,
"source": "Device A  ",
"date": "2025-06-05 00:16:00 +0300"
},
{
"date": "2025-06-05 02:16:00 +0300",
"source": "Device A  ",
"qty": 62.091594303847203
},
{
"qty": 496.11595771813635,
"date": "2025-06-05 04:16:00 +0300",
"source": "Device A  "
},
{
"date": "2025-06-05 06:16:00 +0300",
"qty": 89.502763665053950,
"source": "Device A  "
},
{
"date": "2025-06-05 08:16:00 +0300",
"source": "Device A  ",
"qty": 15.145830153758666
},
{
"date": "2025-06-05 10:16:00 +0300",
"source": "Device A  ",
"qty": 96.442927435831922
},
{
"date": "2025-06-05 12:20:00 +0300",
"qty": 16.490675484303301,
"source": "Device A  "
},
{
"date": "2025-06-05 22:19:00 +0300",
"qty": 25.801867024959922,
"source": "Device A  "
},
{
"qty": 23.750315211285474,
"source": "Device A  ",
"date": "2025-06-06 00:16:00 +0300"
},
{
"qty": 21.676280750344913,
"date": "2025-06-06 02:16:00 +0300",
"source": "Device A  "
},
{
"date": "2025-06-06 04:16:00 +0300",
"source": "Device A  ",
"qty": 108.159159427689
},
{
"date": "2025-06-06 06:16:00 +0300",
"qty": 14.548300046808019,
"source": "Device A  "
},
{
"source": "Device A  ",
"qty": 22.843350035832434,
"date": "2025-06-06 08:16:00 +0300"
},
{
"date": "2025-06-06 11:30:00 +0300",
"source": "Device A  ",
"qty": 90.14058990512054
}
],
"units": "ms",
"name": "heart_rate_variability"
},
{
"name": "six_minute_walking_test_distance",
"units": "m",
"data": [
{
"date": "2025-06-05 05:46:00 +0300",
"source": "Device A",
"qty": 745
}
]
}
]
}
}
+314
View File
@@ -0,0 +1,314 @@
{
"data": {
"metrics": [
{
"name": "apple_stand_hour",
"units": "count",
"data": [
{
"qty": 8,
"source": "Device A",
"date": "2025-06-05 07:00:00 +0300"
},
{
"source": "Device A",
"date": "2025-06-05 08:00:00 +0300",
"qty": 8
},
{
"date": "2025-06-05 09:00:00 +0300",
"source": "Device A",
"qty": 8
},
{
"date": "2025-06-05 10:00:00 +0300",
"qty": 8,
"source": "Device A"
},
{
"source": "Device A",
"qty": 8,
"date": "2025-06-05 12:00:00 +0300"
},
{
"qty": 8,
"date": "2025-06-05 13:00:00 +0300",
"source": "Device A"
},
{
"qty": 8,
"date": "2025-06-05 15:00:00 +0300",
"source": "Device A"
},
{
"qty": 8,
"date": "2025-06-05 19:00:00 +0300",
"source": "Device A"
},
{
"qty": 8,
"source": "Device A",
"date": "2025-06-05 20:00:00 +0300"
},
{
"date": "2025-06-05 21:00:00 +0300",
"source": "Device A",
"qty": 8
},
{
"source": "Device A",
"qty": 8,
"date": "2025-06-06 02:00:00 +0300"
},
{
"date": "2025-06-06 08:00:00 +0300",
"source": "Device A",
"qty": 8
},
{
"date": "2025-06-06 09:00:00 +0300",
"qty": 8,
"source": "Device A"
},
{
"source": "Device A",
"qty": 8,
"date": "2025-06-06 10:00:00 +0300"
}
]
},
{
"units": "hr",
"data": [
{
"sleepEnd": "2025-06-05 05:22:26 +0300",
"inBedStart": "2025-06-04 22:34:34 +0300",
"core": 6.2052788381114499,
"rem": 0.89195271872064281,
"date": "2025-06-05 00:00:00 +0300",
"totalSleep": 4.0716939170300220,
"sleepStart": "2025-06-04 22:34:34 +0300",
"awake": 4.3396901109717361,
"inBed": 0,
"deep": 0.68835541253715609,
"source": "Device A  ",
"inBedEnd": "2025-06-05 05:22:26 +0300",
"asleep": 0
},
{
"source": "Device A  ",
"date": "2025-06-06 00:00:00 +0300",
"asleep": 0,
"core": 5.152708504451071,
"inBed": 0,
"totalSleep": 4.4003075324079272,
"rem": 0.43532011302795238,
"inBedEnd": "2025-06-06 07:57:39 +0300",
"sleepStart": "2025-06-05 22:43:20 +0300",
"awake": 9.638151247778914,
"deep": 0.38341974910703597,
"sleepEnd": "2025-06-06 07:57:39 +0300",
"inBedStart": "2025-06-05 22:43:20 +0300"
}
],
"name": "sleep_analysis"
},
{
"units": "kJ",
"name": "active_energy",
"data": [
{
"source": "Device A  ",
"qty": 0.94633004162661996,
"date": "2025-06-05 00:24:00 +0300"
},
{
"date": "2025-06-05 00:25:00 +0300",
"source": "Device A  ",
"qty": 0.22385813552875930
},
{
"source": "Device A  ",
"date": "2025-06-05 00:26:00 +0300",
"qty": 0.22385813552875930
},
{
"qty": 0.27046667301884517,
"source": "Device A  ",
"date": "2025-06-05 00:27:00 +0300"
},
{
"date": "2025-06-05 00:28:00 +0300",
"qty": 0.23659841288096517,
"source": "Device A  "
},
{
"qty": 0.23659841288096517,
"date": "2025-06-05 00:29:00 +0300",
"source": "Device A  "
},
{
"source": "Device A  ",
"date": "2025-06-05 00:30:00 +0300",
"qty": 0.23659841288096517
},
{
"date": "2025-06-06 11:47:00 +0300",
"source": "Device A  ",
"qty": 4.3538077885409284
},
{
"source": "Device A  ",
"date": "2025-06-06 11:48:00 +0300",
"qty": 4.3538077885409284
},
{
"date": "2025-06-06 11:49:00 +0300",
"qty": 4.3538077885409284,
"source": "Device A  "
},
{
"date": "2025-06-06 11:50:00 +0300",
"source": "Device A  ",
"qty": 2.1847867270334114
},
{
"source": "Device A  ",
"qty": 2.1847867270334114,
"date": "2025-06-06 11:51:00 +0300"
},
{
"date": "2025-06-06 11:52:00 +0300",
"source": "Device A  ",
"qty": 3.3347070079576802
},
{
"qty": 0.96620370889283594,
"date": "2025-06-06 11:53:00 +0300",
"source": "Device A  "
}
]
},
{
"data": [
{
"date": "2025-06-05 00:00:00 +0300",
"qty": 96,
"source": "Device A  "
},
{
"source": "Device A  ",
"date": "2025-06-06 00:02:00 +0300",
"qty": 60
}
],
"name": "resting_heart_rate",
"units": "count/min"
},
{
"units": "count/min",
"data": [
{
"Min": 67,
"date": "2025-06-05 00:00:53 +0300",
"source": "Device A  ",
"Avg": 67,
"Max": 67
},
{
"source": "Device A  ",
"Avg": 18,
"Min": 18,
"date": "2025-06-05 00:02:37 +0300",
"Max": 18
},
{
"Max": 18,
"date": "2025-06-05 00:05:25 +0300",
"source": "Device A  |Device B",
"Min": 18,
"Avg": 18
},
{
"date": "2025-06-05 00:11:01 +0300",
"source": "Device A  |Device B",
"Max": 18,
"Min": 18,
"Avg": 18
},
{
"date": "2025-06-05 00:12:30 +0300",
"Min": 18,
"Avg": 18,
"source": "Device A  |Device B",
"Max": 18
},
{
"Max": 82.669907989920684,
"Min": 82.669907989920684,
"Avg": 82.669907989920684,
"source": "Device A  |Device B",
"date": "2025-06-05 00:16:36 +0300"
},
{
"Avg": 67,
"source": "Device A  |Device B",
"Max": 67,
"date": "2025-06-05 00:18:54 +0300",
"Min": 67
},
{
"date": "2025-06-06 11:30:12 +0300",
"Max": 33,
"Min": 33,
"source": "Device A  |Device B",
"Avg": 33
},
{
"date": "2025-06-06 11:30:20 +0300",
"source": "Device A  |Device B",
"Max": 88,
"Min": 88,
"Avg": 88
},
{
"Avg": 37.044306705474884,
"date": "2025-06-06 11:30:35 +0300",
"source": "Device A  |Device B",
"Max": 37.044306705474884,
"Min": 37.044306705474884
},
{
"date": "2025-06-06 11:33:33 +0300",
"Min": 33,
"source": "Device A  |Device B",
"Max": 33,
"Avg": 33
},
{
"source": "Device A  |Device B",
"Avg": 16,
"date": "2025-06-06 11:36:55 +0300",
"Min": 16,
"Max": 16
},
{
"Min": 45,
"date": "2025-06-06 11:44:45 +0300",
"source": "Device A  |Device B",
"Max": 45,
"Avg": 45
},
{
"Avg": 64,
"date": "2025-06-06 11:51:40 +0300",
"Max": 64,
"source": "Device A  |Device B",
"Min": 64
}
],
"name": "heart_rate"
}
]
}
}
+163
View File
@@ -0,0 +1,163 @@
{
"data": {
"metrics": [
{
"data": [
{
"source": "Device A  ",
"qty": 60,
"date": "2025-06-06 00:02:07 +0300",
"start": "2025-06-06 00:02:07 +0300",
"end": "2025-06-06 11:51:40 +0300"
}
],
"name": "resting_heart_rate",
"units": "count/min"
},
{
"name": "heart_rate",
"units": "count/min",
"data": [
{
"Min": 71,
"source": "Device A  ",
"Avg": 71,
"date": "2025-06-06 00:02:07 +0300",
"start": "2025-06-06 00:02:07 +0300",
"context": "Не задано",
"Max": 71,
"end": "2025-06-06 00:02:07 +0300"
},
{
"start": "2025-06-06 00:05:48 +0300",
"date": "2025-06-06 00:05:48 +0300",
"end": "2025-06-06 00:05:48 +0300",
"Max": 31,
"source": "Device A",
"Min": 31,
"Avg": 31
},
{
"source": "Device A  ",
"Min": 18,
"Max": 18,
"end": "2025-06-06 00:06:54 +0300",
"start": "2025-06-06 00:06:54 +0300",
"Avg": 18,
"date": "2025-06-06 00:06:54 +0300",
"context": "Не задано"
},
{
"context": "Не задано",
"Avg": 67,
"Max": 67,
"start": "2025-06-06 00:13:52 +0300",
"end": "2025-06-06 00:13:52 +0300",
"Min": 67,
"source": "Device A  ",
"date": "2025-06-06 00:13:52 +0300"
},
{
"source": "Device A  ",
"start": "2025-06-06 00:16:29 +0300",
"Min": 57.120105773170690,
"context": "Не задано",
"Max": 57.120105773170690,
"Avg": 57.120105773170690,
"date": "2025-06-06 00:16:29 +0300",
"end": "2025-06-06 00:16:29 +0300"
},
{
"Avg": 67,
"start": "2025-06-06 00:18:17 +0300",
"Min": 67,
"date": "2025-06-06 00:18:17 +0300",
"Max": 67,
"source": "Device A  ",
"end": "2025-06-06 00:18:17 +0300",
"context": "Не задано"
},
{
"Avg": 18,
"end": "2025-06-06 00:26:32 +0300",
"date": "2025-06-06 00:26:32 +0300",
"context": "Не задано",
"start": "2025-06-06 00:26:32 +0300",
"source": "Device A  ",
"Max": 18,
"Min": 18
},
{
"Avg": 33,
"Max": 33,
"Min": 33,
"context": "Сидячий образ жизни",
"date": "2025-06-06 11:30:12 +0300",
"start": "2025-06-06 11:30:12 +0300",
"source": "Device A  ",
"end": "2025-06-06 11:30:12 +0300"
},
{
"start": "2025-06-06 11:30:20 +0300",
"source": "Device A",
"Min": 88,
"Avg": 88,
"end": "2025-06-06 11:30:20 +0300",
"Max": 88,
"date": "2025-06-06 11:30:20 +0300"
},
{
"date": "2025-06-06 11:30:35 +0300",
"end": "2025-06-06 11:30:35 +0300",
"Avg": 37.044306705474884,
"Max": 37.044306705474884,
"start": "2025-06-06 11:30:35 +0300",
"source": "Device A  ",
"context": "Не задано",
"Min": 37.044306705474884
},
{
"source": "Device A  ",
"context": "Сидячий образ жизни",
"date": "2025-06-06 11:33:33 +0300",
"Max": 33,
"Min": 33,
"end": "2025-06-06 11:33:33 +0300",
"Avg": 33,
"start": "2025-06-06 11:33:33 +0300"
},
{
"date": "2025-06-06 11:36:55 +0300",
"start": "2025-06-06 11:36:55 +0300",
"Avg": 16,
"Max": 16,
"Min": 16,
"context": "Сидячий образ жизни",
"source": "Device A  ",
"end": "2025-06-06 11:36:55 +0300"
},
{
"Min": 45,
"Avg": 45,
"context": "Сидячий образ жизни",
"source": "Device A  ",
"Max": 45,
"start": "2025-06-06 11:44:45 +0300",
"end": "2025-06-06 11:44:45 +0300",
"date": "2025-06-06 11:44:45 +0300"
},
{
"source": "Device A  ",
"Avg": 64,
"context": "Сидячий образ жизни",
"Max": 64,
"Min": 64,
"end": "2025-06-06 11:51:40 +0300",
"start": "2025-06-06 11:51:40 +0300",
"date": "2025-06-06 11:51:40 +0300"
}
]
}
]
}
}
+78
View File
@@ -0,0 +1,78 @@
{
"data": {
"metrics": [
{
"data": [
{
"qty": 0.48052029446490479,
"value": "Во сне",
"source": "Device A",
"start": "2025-06-05 22:04:00 +0300",
"startDate": "2025-06-05 22:04:00 +0300",
"endDate": "2025-06-05 22:16:00 +0300",
"end": "2025-06-05 22:16:00 +0300",
"date": "2025-06-05 22:04:00 +0300"
},
{
"endDate": "2025-06-06 02:21:00 +0300",
"qty": 1.5130355381745861,
"startDate": "2025-06-05 22:04:00 +0300",
"date": "2025-06-05 22:04:00 +0300",
"value": "В кровати",
"source": "Device A",
"end": "2025-06-06 02:21:00 +0300",
"start": "2025-06-05 22:04:00 +0300"
},
{
"qty": 9.1550074802731359,
"source": "Device A",
"end": "2025-06-06 01:26:00 +0300",
"value": "Во сне",
"start": "2025-06-05 22:40:00 +0300",
"startDate": "2025-06-05 22:40:00 +0300",
"date": "2025-06-05 22:40:00 +0300",
"endDate": "2025-06-06 01:26:00 +0300"
},
{
"date": "2025-06-06 01:27:00 +0300",
"endDate": "2025-06-06 02:21:00 +0300",
"qty": 0.78349523542270867,
"end": "2025-06-06 02:21:00 +0300",
"source": "Device A",
"value": "Во сне",
"start": "2025-06-06 01:27:00 +0300",
"startDate": "2025-06-06 01:27:00 +0300"
}
],
"units": "hr",
"name": "sleep_analysis"
},
{
"name": "heart_rate",
"units": "count/min",
"data": [
{
"context": "Не задано",
"Max": 31,
"date": "2025-06-06 03:00:36 +0300",
"start": "2025-06-06 03:00:36 +0300",
"Avg": 31,
"source": "Device A  ",
"Min": 31,
"end": "2025-06-06 03:00:36 +0300"
},
{
"Min": 12,
"source": "Device A  ",
"start": "2025-06-06 03:04:20 +0300",
"date": "2025-06-06 03:04:20 +0300",
"Max": 12,
"Avg": 12,
"context": "Не задано",
"end": "2025-06-06 03:04:20 +0300"
}
]
}
]
}
}
@@ -0,0 +1,58 @@
-- +goose Up
-- Часовой объект — единица хранения разобранных точек. Строка на точку дала бы
-- десятки миллионов строк в год; час даёт ~260 тысяч на слой независимо от
-- плотности потока.
--
-- Внутрь payload средствами SQL не заглянуть — это осознанная плата. Границы
-- содержимого и счётчик вынесены колонками, чтобы каталог разрезов не разжимал
-- каждый блоб ради диапазона.
CREATE TABLE bucket (
metric TEXT NOT NULL,
-- Слой выводится из выравнивания меток, а не из заголовка доставки:
-- sample / raw / minute / hour / day.
layer TEXT NOT NULL,
-- Начало часа в UTC, RFC 3339. Час берётся по НАЧАЛУ точки: интервал
-- пересекает границы часов, и любой другой выбор сделал бы принадлежность
-- объекту зависящей от длительности.
hour_utc TEXT NOT NULL,
-- Единицы измерения живут на уровне метрики, внутри точки их нет (проверено
-- на 89 доставках: поле units не встретилось ни разу). Без этой колонки
-- единицы метрики, переставшей приходить, теряются навсегда.
units TEXT NOT NULL DEFAULT '',
-- Точки часа: JSON-массив исходных байтов точек, сжатый gzip. Точки
-- упорядочены по времени начала.
payload BLOB NOT NULL,
-- Хеш канонической формы содержимого: детектор изменений, а не ключ.
-- Совпал — записи не происходит, и широкие проходы синхронизации
-- обходятся даром.
content_hash TEXT NOT NULL,
points INTEGER NOT NULL DEFAULT 0,
-- Границы содержимого: первая и последняя метка начала, UTC RFC 3339.
first_ts TEXT NOT NULL,
last_ts TEXT NOT NULL,
-- Провенанс: доставка, создавшая объект. Нужен, чтобы разбирать слияния;
-- у отдельной точки провенанса нет и не будет.
first_delivery_id TEXT NOT NULL DEFAULT '',
-- Час, в который досчёт уже не ожидается. Правило перевода в sealed пока
-- не определено — порог ставится по наблюдениям, которых нет; до тех пор
-- признак остаётся невыставленным.
sealed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (metric, layer, hour_utc)
) WITHOUT ROWID;
-- Каталог разрезов и Read API ходят «дай метрику за период»: метрика и слой
-- уже в первичном ключе, но выборка по времени поверх всех метрик нужна
-- ретеншену и проверке сходимости.
CREATE INDEX bucket_hour_utc ON bucket (hour_utc);
-- +goose Down
DROP TABLE bucket;
@@ -45,6 +45,13 @@
но **не выводится** — он назначается схемам с фиксированной гранулярностью
(см. «Разделение схем под одним именем метрики»).
Слоем метрики SHALL становиться **самое мелкое** выравнивание среди её меток, а
не преобладающее. Измерено: у плотных метрик выравнивания перемешаны
(`active_energy` — 1320 минутных меток и 21 часовая, `heart_rate` — 654
посекундных и 10 минутных), потому что метка ровно на часе одновременно
является и минутной. Метрика, у которой хоть одна метка стоит на середине часа,
часовой не является.
Разделение схем под одним именем выполняется **до** вывода слоя, и точки с
назначенным слоем в определении преобладающего слоя доставки не участвуют:
суточных сводок сна бывает больше порога плотности, и их полуночные метки
@@ -60,6 +67,12 @@
- **WHEN** в доставке у метрики не меньше десяти точек с метками
- **THEN** слой определяется выравниванием её собственных меток
#### Scenario: Одна метка на середине часа делает метрику минутной
- **WHEN** у плотной метрики десять меток стоят ровно на часе, а одна — на
середине часа
- **THEN** слой метрики `minute`, а не `hour`
#### Scenario: Редкая метрика наследует преобладающий слой
- **WHEN** в доставке у метрики меньше десяти точек
@@ -1,32 +1,32 @@
## 1. Фикстуры и схема
- [ ] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени
- [ ] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default`
- [ ] 1.3a Фикстура с эпизодами сна: три точки с одной меткой `date` и разными парами `start`/`end`, включая эпизод, пересекающий границу часа (в архиве такие есть — вычистить значения, интервалы сохранить)
- [ ] 1.3 Рукотворная фикстура с **выдуманным полем точки** — скрипт из архива её не породит, а без неё требование «незнакомое поле сохраняется» останется без теста
- [ ] 1.4 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `units`, `payload` BLOB, `content_hash`, `points`, `first_ts`, `last_ts`, `first_delivery_id`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)`
- [ ] 1.5 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций)
- [x] 1.1 Скрипт `tmp/research/fixtures.py`: собирает фикстуры из `data/raw`, вычищая измеренные значения и сохраняя порядок ключей, точность чисел, неразрывные пробелы и форматы времени
- [x] 1.2 Набор `internal/hae/testdata`: минутная доставка, посекундная, часовая, смешанная (перенастройка автоматизации), обе схемы `sleep_analysis`, точка с `heartbeatSeries`, доставка без плотных метрик с заголовком `Default`
- [x] 1.3a Фикстура с эпизодами сна: три точки с одной меткой `date` и разными парами `start`/`end`, включая эпизод, пересекающий границу часа (в архиве такие есть — вычистить значения, интервалы сохранить)
- [x] 1.3 Рукотворная фикстура с **выдуманным полем точки** — скрипт из архива её не породит, а без неё требование «незнакомое поле сохраняется» останется без теста
- [x] 1.4 Миграция `internal/store/migrations/00003_bucket.sql`: таблица `bucket` (`metric`, `layer`, `hour_utc`, `units`, `payload` BLOB, `content_hash`, `points`, `first_ts`, `last_ts`, `first_delivery_id`, `sealed`, `created_at`, `updated_at`), уникальность по `(metric, layer, hour_utc)`
- [x] 1.5 `docs/database.md` — ER-схема с `delivery` и `bucket` (шаг гейта `er-schema` требует её при изменении миграций)
## 2. Канонизация — общий дом
- [ ] 2.1 Пакет `internal/canon`: каноническая форма (числа читаются литералом через `json.Number`, округление до 12 значащих цифр — именованная константа со ссылкой на находку 30), полнота точки, хеш
- [ ] 2.2 Сортировку ключей **не писать** — её делает `encoding/json`
- [ ] 2.3 Тест канонизации на настоящих парах чисел из находки 30: все три схлопываются
- [ ] 2.4 Тест: значение с `1.0`, целым больше 2^53 и невалидным UTF-8 переживает хранение дословно
- [x] 2.1 Пакет `internal/canon`: каноническая форма (числа читаются литералом через `json.Number`, округление до 12 значащих цифр — именованная константа со ссылкой на находку 30), полнота точки, хеш
- [x] 2.2 Сортировку ключей **не писать** — её делает `encoding/json`
- [x] 2.3 Тест канонизации на настоящих парах чисел из находки 30: все три схлопываются
- [x] 2.4 Тест: значение с `1.0`, целым больше 2^53 и невалидным UTF-8 переживает хранение дословно
## 3. Разбор — пакет `internal/hae`
- [ ] 3.1 Декодирование конверта в структуру с `Data []json.RawMessage`; точка декодируется по одной. Тест удержания кучи на теле в десятки МиБ
- [ ] 3.2 Разбор метки `2006-01-02 15:04:05 -0700` в UTC с сохранением офсета; неразобранная метка — пропуск точки со счётчиком, не паника
- [ ] 3.3 Эпоха внутри `heartbeatSeries` **не разбирается**: элементы проходят исходными байтами
- [ ] 3.4 Вывод слоя: плотная метрика (≥10 точек) сама, редкая наследует преобладающий, доставка без плотных — наследует последний слой автоматизации по `automation-id`
- [ ] 3.5 Наследовать нечего и заголовок `Default` — точки не сохраняются, `WARN` и счётчик
- [ ] 3.6 `WARN` о расхождении слоя — только против `Minutes`/`Hours`; `Default` в сравнении не участвует
- [ ] 3.7 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`; выполняется **до** вывода слоя, точки с назначенным слоем в голосовании не участвуют
- [ ] 3.8 `recover` **внутри** `hae.Parse` — превращает панику разбора в ошибку пакета; паника из `store` наверх не перехватывается
- [ ] 3.9 Контракт: `error` ненулевая только когда точек нет вовсе; частичные исходы — счётчиками в результате
- [ ] 3.10 `FuzzParse` и таблица враждебных входов: усечённое тело, `null` вместо объекта, массив вместо `data`, число вместо строки даты
- [ ] 3.11 Тесты разбора на фикстурах 1.2–1.3, включая смешанную доставку и обе схемы сна
- [x] 3.1 Декодирование конверта в структуру с `Data []json.RawMessage`; точка декодируется по одной. Тест удержания кучи на теле в десятки МиБ
- [x] 3.2 Разбор метки `2006-01-02 15:04:05 -0700` в UTC с сохранением офсета; неразобранная метка — пропуск точки со счётчиком, не паника
- [x] 3.3 Эпоха внутри `heartbeatSeries` **не разбирается**: элементы проходят исходными байтами
- [x] 3.4 Вывод слоя: плотная метрика (≥10 точек) сама, редкая наследует преобладающий, доставка без плотных — наследует последний слой автоматизации по `automation-id`
- [x] 3.5 Наследовать нечего и заголовок `Default` — точки не сохраняются, `WARN` и счётчик
- [x] 3.6 `WARN` о расхождении слоя — только против `Minutes`/`Hours`; `Default` в сравнении не участвует
- [x] 3.7 Разделение `sleep_analysis` на поэпизодную и `sleep_analysis_summary` со слоем `day`; выполняется **до** вывода слоя, точки с назначенным слоем в голосовании не участвуют
- [x] 3.8 `recover` **внутри** `hae.Parse` — превращает панику разбора в ошибку пакета; паника из `store` наверх не перехватывается
- [x] 3.9 Контракт: `error` ненулевая только когда точек нет вовсе; частичные исходы — счётчиками в результате
- [x] 3.10 `FuzzParse` и таблица враждебных входов: усечённое тело, `null` вместо объекта, массив вместо `data`, число вместо строки даты
- [x] 3.11 Тесты разбора на фикстурах 1.2–1.3, включая смешанную доставку и обе схемы сна
## 4. Хранение — часовые объекты