Каталог разрезов и измеренный род агрегации

- род метрики выводится сверкой минутного слоя с часовым: часовое значение
  сходится с суммой минутных — накопительная, со средним — мгновенная, иначе
  `unknown` и свёртка не предлагается вовсе. На живом архиве (123 доставки,
  31 метрика) 7 накопительных, 9 мгновенных, противоречащих часов ноль
- `GET /api/v1/metrics` под токеном чтения отдаёт единицы, слои с границами и
  род вместе с основанием измерения; род нигде не хранится — он функция витрины,
  а витрина функция журнала, устаревать в нём нечему
- миграция 00009: покрывающий индекс, чтобы каталог отвечал по учётным колонкам,
  не разжимая содержимое объектов
This commit is contained in:
av
2026-08-02 19:23:59 +03:00
parent 98e0772ec5
commit 03edf1087d
39 changed files with 4744 additions and 58 deletions
+73
View File
@@ -0,0 +1,73 @@
package hae
import (
"bytes"
"encoding/json"
)
// PointValue достаёт число точки: `qty`, а при его отсутствии — `Avg`.
//
// Единственное место в проекте, знающее, какое поле точки HAE несёт число.
// Порядок именно такой: `qty` несут все метрики, `Avg` — только `heart_rate`
// (находка 40), и без второго кандидата самая частая метрика потока не
// измерялась бы вовсе.
//
// Это чтение, а не интерпретация: значение никуда не пишется и ничего не
// подменяет. Форма точки при этом рода метрики не выдаёт — род измеряется
// сверкой слоёв, а не выводится отсюда.
//
// **Ноль — значение.** Словарь пустоты из `canon` сюда не применяется и
// применяться не должен: там ноль объявлен пустотой, чтобы точка без измерений
// не вытесняла настоящее измерение при столкновении координат, — вопрос совсем
// другой. Взяв его, измерение не увидело бы точки `{"qty":0}`, час выпал бы из
// счётчиков ещё до правила различимости, и проверка «нулевой час свидетельством
// не является» зеленела бы по неверной причине.
//
// **Значением считается только JSON-число.** Строка `"72.5"` — не число, хотя
// `json.Number` её принимает (проверено): такой формы поток не приносил, и
// прочитать её как измерение значило бы угадать за источник. Отсутствие ключа и
// `null` — тоже «нет значения»; при этом `qty: null` не мешает прочитать `Avg`,
// а `qty` не того типа мешает: форма точки поменялась, и догадываться не о чем.
func PointValue(raw json.RawMessage) (float64, bool) {
var p struct {
Qty *json.RawMessage `json:"qty"`
Avg *json.RawMessage `json:"Avg"`
}
// Указатели, а не значения: `null` обязан отличаться от нуля, и в этой форме
// он приходит нулевым указателем.
if err := json.Unmarshal(raw, &p); err != nil {
return 0, false
}
if p.Qty != nil {
return jsonNumber(*p.Qty)
}
if p.Avg != nil {
return jsonNumber(*p.Avg)
}
return 0, false
}
// jsonNumber разбирает значение поля, требуя, чтобы это было JSON-число.
//
// Проверка первого байта нужна потому, что `json.Unmarshal` в `float64`
// отвергает строку, но `json.Number` — принимает; полагаться на тип-приёмник
// значило бы получить разное поведение от невидимой детали.
//
// Бесконечность значением не считается: `1e400` разбирается с ошибкой
// диапазона, и проглоти её — `+Inf` отравил бы и сумму, и среднее всего часа, а
// видно это было бы лишь тем, что род перестал определяться. Отдельной проверки
// на `Inf`/`NaN` после разбора нет намеренно: их литералов в JSON не бывает, а
// число вне диапазона `float64` даёт ошибку, а не бесконечность.
func jsonNumber(raw json.RawMessage) (float64, bool) {
b := bytes.TrimSpace(raw)
if len(b) == 0 || (b[0] != '-' && (b[0] < '0' || b[0] > '9')) {
return 0, false
}
var v float64
if err := json.Unmarshal(b, &v); err != nil {
return 0, false
}
return v, true
}
+96
View File
@@ -0,0 +1,96 @@
package hae_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"git.vakhrushev.me/av/healthlog/internal/hae"
)
func TestPointValueФормыТочки(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want float64
ok bool
}{
{"обычная точка", `{"qty":72.5,"date":"2026-07-31 12:00:00 +0300"}`, 72.5, true},
{"пульс без qty", `{"Min":60,"Avg":70.25,"Max":80}`, 70.25, true},
{"qty впереди Avg", `{"qty":1,"Avg":9}`, 1, true},
{"ноль — значение", `{"qty":0}`, 0, true},
{"отрицательное значение", `{"qty":-1.5}`, -1.5, true},
{"нет числовых полей", `{"date":"…","source":"часы"}`, 0, false},
{"qty равен null", `{"qty":null,"Avg":3}`, 3, true},
{"qty строкой", `{"qty":"72.5"}`, 0, false},
{"qty объектом", `{"qty":{"value":1}}`, 0, false},
{"переполнение float64", `{"qty":1e400}`, 0, false},
{"точка не объект", `[1,2,3]`, 0, false},
{"пустой объект", `{}`, 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
got, ok := hae.PointValue(json.RawMessage(c.raw))
if ok != c.ok {
t.Fatalf("наличие значения: получили %v, ждали %v", ok, c.ok)
}
if ok && got != c.want {
t.Errorf("значение: получили %v, ждали %v", got, c.want)
}
})
}
}
// Формы точки берутся из реальных пакетов: документация формата тонкая и
// местами расходится с тем, что приложение шлёт (docs/local-research.md).
func TestPointValueНаРеальныхПакетах(t *testing.T) {
t.Parallel()
for _, name := range []string{"minute.json", "hour.json", "raw.json"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
body, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("фикстура: %v", err)
}
var doc struct {
Data struct {
Metrics []struct {
Name string `json:"name"`
Data []json.RawMessage `json:"data"`
} `json:"metrics"`
} `json:"data"`
}
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatalf("разбор фикстуры: %v", err)
}
total, valued := 0, 0
for _, m := range doc.Data.Metrics {
for _, raw := range m.Data {
total++
if _, ok := hae.PointValue(raw); ok {
valued++
}
}
}
if total == 0 {
t.Fatal("в фикстуре нет точек — проверять нечего")
}
// Утверждается свойство, а не число: корпус фикстур пополняется, и
// константа протухла бы молча. Значение несёт подавляющее
// большинство точек — если вдруг перестанет, это видно сразу.
if valued*2 < total {
t.Errorf("значение прочиталось лишь у %d точек из %d", valued, total)
}
t.Logf("точек %d, со значением %d", total, valued)
})
}
}