разбор метрик 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:
@@ -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` в ключ не входит: он нестабилен и переписывается задним числом. При
|
||||
столкновении выигрывает более полная точка, а не последняя пришедшая.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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("точка без слоя доехала до хранилища")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Vendored
+45
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+281
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+281
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+314
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+163
@@ -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
@@ -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. Хранение — часовые объекты
|
||||
|
||||
|
||||
Reference in New Issue
Block a user