Files
healthlog/internal/hae/hae.go
T
av f8200f7f80 feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной
  строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`;
  миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки
  с этими ключами
- сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет
  содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а
  при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА —
  «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина
  расходилась бы с пересборкой молча
- отпечаток витрины покрывает тренировки и записи и снимается одним снимком
  базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
2026-08-02 13:05:16 +03:00

746 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"sort"
"time"
"unicode/utf8"
)
// 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
// local — метка в исходной зоне. Наружу не отдаётся: хранится всегда UTC
// плюс офсет. Нужна выводу слоя — HAE строит сетку по МЕСТНОМУ времени, и
// выравнивание, посчитанное по UTC, объявляет часовую выгрузку минутной в
// зонах с получасовым смещением (+0530, +0545, +0930).
local time.Time
}
// Entity — сущность с собственным идентификатором: тренировка или запись
// секции вроде stateOfMind. От точки отличается тем, что её адресует сам `id`,
// а не координаты, и слоя у неё нет вовсе: подробности выгрузки у этих секций
// в интерфейсе HAE не бывает.
type Entity struct {
// ID — идентификатор из HealthKit. Приходит из тела и ограничен по длине:
// уезжает и в первичный ключ, и в записи лога.
ID string
// Kind — верхнеуровневый ключ секции HAE ДОСЛОВНО (`stateOfMind`, не
// `state_of_mind`): инвариант «форма Apple не транслируется» относится и к
// именам секций, а переименование после того, как значение легло в базу,
// стоило бы миграции данных. У тренировки род один и в ключ не входит.
Kind string
// Name — имя тренировки как прислал HAE, локализованное («В помещении
// Ходьба»). У записей пустое.
Name string
// Start и End — координаты в UTC. Конец, которого нет или который не
// читается, равен началу: ключ сущности — `id`, схлопывать нечего, а истина
// остаётся в Raw. У точки то же вырождение запрещено — там оно схлопнуло бы
// две записи в одну координату.
Start time.Time
End time.Time
// OffsetSeconds — смещение зоны НАЧАЛА. Колонка одна, а тренировка через
// смену зоны дала бы два разных.
OffsetSeconds int
// Duration — длительность тренировки в секундах, как прислал HAE. Не
// вычисляется из интервала: HAE шлёт 91.746 при интервале в 91 секунду.
// Отсутствие выражается nil, а не нулём: ноль — законная длительность.
Duration *float64
// Raw — содержимое сущности исходными байтами, как пришло в теле, включая
// маршрут и внутренние ряды.
Raw json.RawMessage
}
// Result — итог разбора доставки. Частичные исходы живут в счётчиках, а не в
// ошибке: пакет, у которого не разобралась одна точка из тысячи, — обычное
// дело, и терять из-за неё остальное нельзя.
type Result struct {
Points []Point
// Workouts и Records — сущности с собственным идентификатором. Разведены,
// потому что у тренировки есть заголовок (имя, интервал, длительность), по
// которому идёт выборка, а у записи его нет.
Workouts []Entity
Records []Entity
// SkippedNoID — сущности без пригодного идентификатора: пустого, нет вовсе
// или длиннее предела. Один счётчик на все три случая: исход у них общий, а
// различает их только тело, лежащее в архиве.
SkippedNoID int
// SkippedEntityNoTime — сущности с идентификатором, но без разбираемой
// метки времени.
SkippedEntityNoTime int
// SkippedEntityMalformed — элементы секции, не разобравшиеся как объект.
SkippedEntityMalformed int
// Uncovered — верхнеуровневые ключи `data`, которых разбор не покрывает,
// отсортированные и без повторов. Половина живого потока состоит из таких
// доставок целиком (48 из 99: workouts и stateOfMind), и без этого списка
// они неотличимы от разобранной доставки с пустой секцией метрик.
//
// Список канонизирован потому, что уезжает в базу и сравнивается между
// доставками, а порядок ключей в JSON от HAE нестабилен.
Uncovered []string
// UncoveredDropped — сколько имён отброшено границей списка. Молчаливое
// усечение сделало бы список уверенным, но неполным ответом на вопрос «что
// останется потерянным, если тело удалить».
UncoveredDropped int
// Metrics — сколько метрик встретилось в секции.
Metrics int
// SkippedNoTime — точки без разбираемой метки времени.
SkippedNoTime int
// SkippedMalformed — точки, не разобравшиеся как объект JSON.
SkippedMalformed int
// SkippedBadEnd — точки, у которых есть `end`, но он не разбирается.
// Вырождать такую точку в мгновенную нельзя: она схлопнулась бы с соседней
// по координате.
SkippedBadEnd 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)
}
}()
env, err := decodeEnvelope(body)
if err != nil {
return Result{}, err
}
res.Uncovered = env.uncovered
res.UncoveredDropped = env.dropped
res.Workouts = decodeEntities(env.workouts, workoutsSection, &res)
res.Records = decodeEntities(env.stateOfMind, stateOfMindSection, &res)
metrics := env.metrics
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 len(g.summaries) > 0 {
groups = append(groups, group{
metric: sleepSummaryMetric,
units: g.units,
points: g.summaries,
layer: LayerDay,
fixed: true,
})
g.summaries = 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 {
// Список непокрытых секций переживает отказ: доставка, у которой не
// определился слой, обязана остаться записью о том, что в теле есть
// невосстановимая секция. Иначе ретеншен увидит failed без списка и
// решит, что терять нечего.
//
// Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они
// успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай
// мы их, доставка получила бы `failed` при частично записанной витрине,
// и повторная свёртка перестала бы быть no-op. Цена названа в спеке —
// такая доставка доедет пересборкой, а тело ждёт в архиве.
return Result{
Metrics: res.Metrics,
Uncovered: res.Uncovered,
UncoveredDropped: res.UncoveredDropped,
}, 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
// summaries — точки, схема которых опознана прямо при разборе и слой
// которым назначен, а не выведен (суточная сводка сна). Держатся отдельно,
// потому что в определении слоя доставки не участвуют: сводок бывает больше
// порога плотности, и их полуночные метки назначили бы всей доставке `hour`.
summaries []Point
}
// envelope — форма тела, ровно настолько подробная, насколько нужно разбору.
//
// Точки держатся сырыми сообщениями и декодируются по одной: разбор тела в
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
// OOM ровно на пике потока, когда терять доставки дороже всего.
// Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`,
// `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`)
// покрытыми намеренно не становятся: живой поток не приносил их ни разу, их
// форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью
// проекта не является.
const (
metricsSection = "metrics"
workoutsSection = "workouts"
stateOfMindSection = "stateOfMind"
)
// decodeCovered разбирает секцию, если разбор её покрывает; второй возврат
// говорит, взялся ли он за неё.
//
// Один источник и для разбора, и для перечисления непокрытых: перечисляющий
// спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается,
// но числится непокрытой» невыразимо по построению. Отдельный предикат
// `covered` разошёлся бы с этим switch при первой же новой секции.
//
// Повтор ключа покрытой секции JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
// побеждает последняя: терять данные молча нельзя.
func decodeCovered(name string, dec *json.Decoder, env *envelope) (bool, error) {
switch name {
case metricsSection:
var part []metricEnvelope
if err := dec.Decode(&part); err != nil {
return true, err
}
env.metrics = append(env.metrics, part...)
return true, nil
case workoutsSection:
part, err := decodeSection(dec)
if err != nil {
return true, err
}
env.workouts = append(env.workouts, part...)
return true, nil
case stateOfMindSection:
part, err := decodeSection(dec)
if err != nil {
return true, err
}
env.stateOfMind = append(env.stateOfMind, part...)
return true, nil
default:
return false, nil
}
}
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
// без границ сто тысяч однобуквенных ключей превращаются в одну строку в базе
// и одну строку в логе того же порядка. Секций у HAE восемь, самое длинное имя
// — heartRateNotifications (22 байта), так что запас велик.
const (
maxUncovered = 32
maxUncoveredLen = 64
)
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"`
}
// decodeEnvelope разбирает конверт: отдаёт секцию metrics и имена секций,
// которых разбор не покрывает.
//
// Идёт по верхнему уровню одним декодером: Token() читает рамку объекта и имена
// членов, Decode() — значения. Значение покрытого ключа декодируется на месте,
// значение непокрытого ПРОГЛАТЫВАЕТСЯ декодированием в выбрасываемый
// RawMessage. Это форма из ExampleDecoder_Decode_stream стандартной библиотеки;
// в encoding/json/v2 та же операция названа прямо — SkipValue.
//
// Пропуск ручным счётом глубины по Token() выглядит дешевле и измеримо хуже:
// делимитеры идут мимо сканера, поэтому ограничитель вложенности encoding/json
// не работает, а стек токенов растёт как O(глубины). Тело 40 МиБ из вложенных
// скобок даёт пик 488 МиБ вместо контрактных четырёх тел — Decode отвергает его
// мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но
// копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания
// копия одна и живёт до следующего члена.
func decodeEnvelope(body []byte) (envelope, error) {
var env envelope
fail := func(e error) (envelope, error) {
return envelope{}, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
}
dec := json.NewDecoder(bytes.NewReader(body))
// Верхний уровень тела: интересует только data. Прочие ключи конверта в
// список не идут — иначе в одном списке смешались бы имена секций и мусор
// конверта, а форму `{"data": …}` проверяет приём.
tok, err := dec.Token()
if err != nil {
return fail(err)
}
// Голый null телом ошибкой не был и не становится: прежний разбор
// раскладывал его в пустую структуру. Границы поведения этой задачей не
// двигаются — она добавляет список, а не строгость.
if tok == nil {
return envelope{}, nil
}
if d, ok := tok.(json.Delim); !ok || d != '{' {
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
}
seen := make(map[string]struct{})
for dec.More() {
name, err := memberName(dec)
if err != nil {
return fail(err)
}
if name != "data" {
if err := swallow(dec); err != nil {
return fail(err)
}
continue
}
// Повтор самого члена `data` JSON допускает, и результаты
// НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого
// члена целиком, причём молча — их имена уже отмечены в `seen` и во
// второй список непокрытых не попали бы. Правило то же, что уровнем
// ниже для повтора ключа секции.
if err := decodeData(dec, seen, &env); err != nil {
return fail(err)
}
}
if err := expectDelim(dec, '}'); err != nil {
return fail(err)
}
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
// значение уезжает в базу и сравнивается между доставками.
sort.Strings(env.uncovered)
return env, nil
}
// envelope — что разбор вынул из тела: покрытые секции и имена непокрытых.
type envelope struct {
metrics []metricEnvelope
workouts []json.RawMessage
stateOfMind []json.RawMessage
uncovered []string
dropped int
}
// decodeData разбирает объект data, дописывая в конверт покрытые секции и
// имена непокрытых.
func decodeData(dec *json.Decoder, seen map[string]struct{}, env *envelope) error {
tok, err := dec.Token()
if err != nil {
return err
}
// data не объект — прежнее поведение: ошибка ровно там, где была.
if d, ok := tok.(json.Delim); !ok || d != '{' {
return fmt.Errorf("data: ожидался объект, встречено %v", tok)
}
for dec.More() {
name, err := memberName(dec)
if err != nil {
return err
}
handled, err := decodeCovered(name, dec, env)
if err != nil {
return err
}
if handled {
continue
}
if err := swallow(dec); err != nil {
return err
}
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
if len(env.uncovered) >= maxUncovered {
env.dropped++
continue
}
env.uncovered = append(env.uncovered, clipSection(name))
}
if _, err := dec.Token(); err != nil { // закрывающая скобка data
return err
}
return nil
}
// decodeSection читает секцию сущностей элементами исходных байтов.
//
// Разбор до `json.RawMessage`, а не до структуры: сущность хранится дословно, и
// декодирование в типизированное значение потеряло бы литерал — ровно то, от
// чего защищает `Point.Raw`.
func decodeSection(dec *json.Decoder) ([]json.RawMessage, error) {
var part []json.RawMessage
if err := dec.Decode(&part); err != nil {
return nil, err
}
return part, nil
}
// memberName читает имя члена объекта. Token() отдаёт имя уже после разбора
// escape-последовательностей, поэтому границы считаются по декодированному.
func memberName(dec *json.Decoder) (string, error) {
tok, err := dec.Token()
if err != nil {
return "", err
}
name, ok := tok.(string)
if !ok {
return "", fmt.Errorf("ожидалось имя члена, встречено %v", tok)
}
return name, nil
}
// swallow проглатывает значение целиком, ничего не удерживая.
func swallow(dec *json.Decoder) error {
var skip json.RawMessage
return dec.Decode(&skip)
}
func expectDelim(dec *json.Decoder, want json.Delim) error {
tok, err := dec.Token()
if err != nil {
return err
}
if d, ok := tok.(json.Delim); !ok || d != want {
return fmt.Errorf("ожидалось %q, встречено %v", want, tok)
}
return nil
}
// clipSection обрезает слишком длинное имя по границе рун и помечает обрезку.
// Маркер приписывается СВЕРХ предела: обрезка не инъективна, и обрезанное имя
// сравнению со словарём известных секций не подлежит.
func clipSection(name string) string {
if len(name) <= maxUncoveredLen {
return name
}
cut := maxUncoveredLen
for cut > 0 && !utf8.RuneStart(name[cut]) {
cut--
}
return name[:cut] + "…"
}
// 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 != "" {
e, err := time.Parse(timeLayout, head.End)
if err != nil {
// Интервал, конец которого не читается, вырождать в точку
// нельзя: две записи с общим началом получили бы одну
// координату и одна из них исчезла бы. Пропускаем со
// счётчиком — тело остаётся в архиве.
res.SkippedBadEnd++
continue
}
end = e
}
_, offset := start.Zone()
p := Point{
Metric: m.Name,
Units: m.Units,
Start: start.UTC(),
End: end.UTC(),
OffsetSeconds: offset,
Raw: raw,
local: start,
}
// Под именем sleep_analysis HAE шлёт две несовместимые схемы:
// поэпизодную (start/end/value/qty) и суточную сводку
// (totalSleep/core/rem/deep/awake с меткой на местной полуночи). Имя
// sleep_analysis_summary — наше; инвариант «форма Apple не
// транслируется» это не нарушает: поля внутри точки не
// переименовываются, разделяются только имена метрик, под которыми
// HAE смешал две схемы.
if m.Name == sleepMetric && head.TotalSleep != nil {
p.Metric = sleepSummaryMetric
g.summaries = append(g.summaries, p)
continue
}
g.points = append(g.points, p)
}
g.dense = len(g.points) >= denseThreshold
g.alignment = finestAlignment(g.points)
return g
}
// Имена метрик сна: пришедшее от HAE и наше для суточной сводки.
const (
sleepMetric = "sleep_analysis"
sleepSummaryMetric = "sleep_analysis_summary"
)
// 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 {
// По МЕСТНОЙ метке, а не по UTC: HAE строит сетку в зоне телефона.
// В зонах с получасовым смещением (+0530 Индия, +0545 Непал, +0930
// Аделаида) ровный местный час превращается в UTC-метку на половине,
// и вся часовая выгрузка уехала бы в слой `minute` — а там столкнулась
// бы с настоящей минутной автоматизацией на координате hh:30 и завысила
// сумму минутного слоя вдвое.
switch {
case p.local.Second() != 0 || p.local.Nanosecond() != 0:
return LayerRaw
case p.local.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
}
}