feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
package hae
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxEntityID — предел длины идентификатора сущности.
|
||||
//
|
||||
// `id` приходит из тела, которым отправитель управляет целиком, а уезжает и в
|
||||
// первичный ключ таблицы, и в записи лога. UUID HealthKit — 36 байт, так что
|
||||
// запас велик; правило то же, что уже действует для имён непокрытых секций, и
|
||||
// оно снимает класс, а не случай.
|
||||
const maxEntityID = 128
|
||||
|
||||
// rfc3339Layout — второй формат метки, которым HAE шлёт stateOfMind
|
||||
// (находка 16). Метрики и тренировки идут первым, `timeLayout`.
|
||||
const rfc3339Layout = time.RFC3339
|
||||
|
||||
// entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и
|
||||
// хранится дословно.
|
||||
//
|
||||
// Длительность читается сырым сообщением, а не числом: нечисловое значение —
|
||||
// это пропуск ОДНОГО поля, а не сломанная сущность, и типизированное поле
|
||||
// уводило бы всю тренировку в счётчик «не разобралась как объект».
|
||||
type entityHead struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Date string `json:"date"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
|
||||
Duration json.RawMessage `json:"duration"`
|
||||
}
|
||||
|
||||
// decodeEntities разбирает элементы одной покрытой секции в сущности.
|
||||
//
|
||||
// Пропуск одного элемента не уносит соседей: у каждого класса пропуска свой
|
||||
// счётчик, тело остаётся в архиве, и доставку вернёт пересборка, когда разбор
|
||||
// научится понимать пропущенное.
|
||||
func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity {
|
||||
if len(raws) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]Entity, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
var head entityHead
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
res.SkippedEntityMalformed++
|
||||
continue
|
||||
}
|
||||
if head.ID == "" || len(head.ID) > maxEntityID {
|
||||
res.SkippedNoID++
|
||||
continue
|
||||
}
|
||||
|
||||
start, ok := parseEntityTime(firstNonEmpty(head.Start, head.Date))
|
||||
if !ok {
|
||||
res.SkippedEntityNoTime++
|
||||
continue
|
||||
}
|
||||
|
||||
// Конец, которого нет или который не читается, равен началу. У точки то
|
||||
// же вырождение запрещено — там оно схлопнуло бы две записи в одну
|
||||
// координату, — а сущность адресуется своим `id`, и схлопывать нечего.
|
||||
// Истина при этом остаётся в Raw дословно.
|
||||
end := start
|
||||
if head.End != "" {
|
||||
if e, ok := parseEntityTime(head.End); ok {
|
||||
end = e
|
||||
}
|
||||
}
|
||||
|
||||
_, offset := start.Zone()
|
||||
e := Entity{
|
||||
ID: head.ID,
|
||||
Kind: kind,
|
||||
Name: head.Name,
|
||||
Start: start.UTC(),
|
||||
End: end.UTC(),
|
||||
OffsetSeconds: offset,
|
||||
Duration: parseDuration(head.Duration),
|
||||
Raw: raw,
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseEntityTime разбирает метку сущности, принимая оба измеренных формата.
|
||||
//
|
||||
// Оба, а не приписанный секции: формы однозначны и не пересекаются (RFC 3339
|
||||
// несёт `T` и `Z`), а HAE выравнивает секции между собой по ходу своих
|
||||
// обновлений — `stateOfMind` уже шлёт стабильные коды HealthKit там, где старые
|
||||
// секции шлют переводы. Приписанный секции формат ломался бы молча в день
|
||||
// такого выравнивания.
|
||||
//
|
||||
// Метка ТОЧКИ остаётся строгой (parseTime), и асимметрия намеренная: по метке
|
||||
// точки выводится слой, причём по метке в исходной зоне. Терпимость там
|
||||
// означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка
|
||||
// складывается с минутной; у сущности слоя нет, и терять на строгости нечего.
|
||||
func parseEntityTime(s string) (time.Time, bool) {
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if t, err := time.Parse(timeLayout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if t, err := time.Parse(rfc3339Layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// parseDuration переводит длительность в секунды.
|
||||
//
|
||||
// Отсутствие и нечисловое значение дают nil, а не ноль: ноль — законная
|
||||
// длительность, и потребитель, сложивший столбец, иначе не отличил бы
|
||||
// «источник не прислал» от «измерено ноль». Вычислять длительность из
|
||||
// интервала нельзя: HAE шлёт 91.746 при интервале в 91 секунду.
|
||||
func parseDuration(raw json.RawMessage) *float64 {
|
||||
lit := bytes.TrimSpace(raw)
|
||||
if len(lit) == 0 {
|
||||
return nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(string(lit), 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package hae_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/hae"
|
||||
)
|
||||
|
||||
func parseFixture(t *testing.T, name string) hae.Result {
|
||||
t.Helper()
|
||||
|
||||
res, err := hae.Parse(load(t, name), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор %s: %v", name, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Тренировка хранится дословно: маршрут и внутренние ряды остаются теми же
|
||||
// байтами, какими пришли. Раскладывать их по колонкам значило бы решить за
|
||||
// Apple, что в тренировке главное.
|
||||
func TestParseТренировкаСМаршрутом(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "workout_route.json")
|
||||
|
||||
var w hae.Entity
|
||||
for _, e := range res.Workouts {
|
||||
if strings.Contains(string(e.Raw), `"route"`) {
|
||||
w = e
|
||||
}
|
||||
}
|
||||
if w.ID == "" {
|
||||
t.Fatal("уличной тренировки с маршрутом в фикстуре не нашлось")
|
||||
}
|
||||
if w.Name == "" {
|
||||
t.Error("имя пусто — по нему идёт выборка заголовков")
|
||||
}
|
||||
if !w.End.After(w.Start) {
|
||||
t.Errorf("интервал %v — %v", w.Start, w.End)
|
||||
}
|
||||
if w.OffsetSeconds != 3*3600 {
|
||||
t.Errorf("офсет %d, ожидался 10800", w.OffsetSeconds)
|
||||
}
|
||||
if w.Duration == nil || *w.Duration <= 0 {
|
||||
t.Errorf("длительность %v — обязана браться из тела", w.Duration)
|
||||
}
|
||||
|
||||
var body map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Raw, &body); err != nil {
|
||||
t.Fatalf("содержимое не разбирается: %v", err)
|
||||
}
|
||||
for _, key := range []string{"route", "heartRateData", "activeEnergy", "heartRateRecovery"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
t.Errorf("в содержимом нет %q — внутренние ряды обязаны храниться дословно", key)
|
||||
}
|
||||
}
|
||||
// Точки маршрута проходят исходными байтами: их метка (`timestamp`)
|
||||
// меткой сущности не является и не разбирается.
|
||||
if !strings.Contains(string(body["route"]), "timestamp") {
|
||||
t.Error("точки маршрута потеряли своё поле времени")
|
||||
}
|
||||
}
|
||||
|
||||
// Пульс приезжает дважды — в общем потоке метрик и внутри тренировки. Это
|
||||
// разные таблицы; смешение задвоило бы ряд.
|
||||
func TestParseРядПульсаТренировкиНеСтановитсяМетрикой(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "workout_route.json")
|
||||
|
||||
if len(res.Points) != 0 {
|
||||
t.Errorf("точек %d, ожидалось 0: доставка несёт только тренировки", len(res.Points))
|
||||
}
|
||||
if res.Metrics != 0 {
|
||||
t.Errorf("метрик %d, ожидалось 0", res.Metrics)
|
||||
}
|
||||
if len(res.Uncovered) != 0 {
|
||||
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Набор полей тренировки зависит от её типа: у домашней нет маршрута, зато
|
||||
// есть температура и влажность. Фиксированной схемы не существует.
|
||||
func TestParseТренировкаБезМаршрута(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "workout_indoor.json")
|
||||
|
||||
if len(res.Workouts) == 0 {
|
||||
t.Fatal("тренировок нет")
|
||||
}
|
||||
var body map[string]json.RawMessage
|
||||
if err := json.Unmarshal(res.Workouts[0].Raw, &body); err != nil {
|
||||
t.Fatalf("содержимое не разбирается: %v", err)
|
||||
}
|
||||
if _, ok := body["route"]; ok {
|
||||
t.Error("у домашней тренировки взялся маршрут")
|
||||
}
|
||||
for _, key := range []string{"temperature", "humidity", "intensity"} {
|
||||
if _, ok := body[key]; !ok {
|
||||
t.Errorf("в содержимом нет %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stateOfMind живёт по другим соглашениям: RFC 3339 в UTC, коды HealthKit
|
||||
// вместо переводов, поля source нет вовсе.
|
||||
func TestParseСостояниеРазума(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "state_of_mind.json")
|
||||
|
||||
if len(res.Records) == 0 {
|
||||
t.Fatal("записей нет")
|
||||
}
|
||||
for _, r := range res.Records {
|
||||
if r.Kind != "stateOfMind" {
|
||||
t.Errorf("род %q, ожидался stateOfMind — имя секции хранится дословно", r.Kind)
|
||||
}
|
||||
if r.ID == "" {
|
||||
t.Error("идентификатор пуст")
|
||||
}
|
||||
// HAE прислал UTC — офсет ноль. Это значит «источник прислал UTC», а не
|
||||
// «человек был в Гринвиче»: местной зоны у секции в потоке нет вовсе.
|
||||
if r.OffsetSeconds != 0 {
|
||||
t.Errorf("офсет %d, ожидался 0", r.OffsetSeconds)
|
||||
}
|
||||
if r.Start.IsZero() {
|
||||
t.Error("метка не разобралась — RFC 3339 обязан приниматься")
|
||||
}
|
||||
}
|
||||
if len(res.Uncovered) != 0 {
|
||||
t.Errorf("непокрытые %v, ожидался пустой список", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Пропуск одного элемента не уносит соседей, и у каждого класса свой счётчик:
|
||||
// тело остаётся в архиве, а вернуть сущность может только пересборка.
|
||||
func TestParseКраевыеСлучаиСущностей(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res := parseFixture(t, "handmade_entities.json")
|
||||
|
||||
byID := make(map[string]hae.Entity, len(res.Workouts))
|
||||
for _, w := range res.Workouts {
|
||||
byID[w.ID] = w
|
||||
}
|
||||
|
||||
// Пустой id, отсутствующий id и id длиннее предела — один счётчик на три
|
||||
// случая: исход у них общий.
|
||||
if res.SkippedNoID != 3 {
|
||||
t.Errorf("пропущено по идентификатору %d, ожидалось 3", res.SkippedNoID)
|
||||
}
|
||||
// Метка не разбирается и метки нет вовсе.
|
||||
if res.SkippedEntityNoTime != 2 {
|
||||
t.Errorf("пропущено по метке %d, ожидалось 2", res.SkippedEntityNoTime)
|
||||
}
|
||||
// Элемент, не являющийся объектом.
|
||||
if res.SkippedEntityMalformed != 1 {
|
||||
t.Errorf("пропущено по форме %d, ожидалось 1", res.SkippedEntityMalformed)
|
||||
}
|
||||
|
||||
t.Run("нечитаемый конец не отбрасывает тренировку", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-000000000003"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с нечитаемым концом потерялась целиком")
|
||||
}
|
||||
if !w.End.Equal(w.Start) {
|
||||
t.Errorf("конец %v, ожидался равным началу %v", w.End, w.Start)
|
||||
}
|
||||
if !strings.Contains(string(w.Raw), "никогда") {
|
||||
t.Error("исходное значение конца не сохранилось дословно")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("нечисловая длительность не становится нулём", func(t *testing.T) {
|
||||
w := byID["00000000-0000-4000-8000-000000000004"]
|
||||
if w.Duration != nil {
|
||||
t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("отсутствующая длительность отличима от нуля", func(t *testing.T) {
|
||||
w := byID["00000000-0000-4000-8000-000000000005"]
|
||||
if w.Duration != nil {
|
||||
t.Errorf("длительность %v, ожидалось отсутствие", *w.Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("начало берётся из date, когда start отсутствует", func(t *testing.T) {
|
||||
w, ok := byID["00000000-0000-4000-8000-000000000006"]
|
||||
if !ok {
|
||||
t.Fatal("тренировка с меткой в date потерялась")
|
||||
}
|
||||
if w.Start.IsZero() {
|
||||
t.Error("метка не разобралась")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("незнакомое поле переживает разбор дословно", func(t *testing.T) {
|
||||
w := byID["00000000-0000-4000-8000-000000000009"]
|
||||
for _, lit := range []string{"невиданноеПоле", "1.0", "9007199254740993", "0.123456789012345678"} {
|
||||
if !strings.Contains(string(w.Raw), lit) {
|
||||
t.Errorf("литерал %q потерян при разборе", lit)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("запись со временем в формате метрик тоже разбирается", func(t *testing.T) {
|
||||
var daily *hae.Entity
|
||||
for i, r := range res.Records {
|
||||
if strings.Contains(string(r.Raw), "daily_mood") {
|
||||
daily = &res.Records[i]
|
||||
}
|
||||
}
|
||||
if daily == nil {
|
||||
t.Fatal("запись daily_mood потерялась")
|
||||
}
|
||||
if daily.OffsetSeconds != 3*3600 {
|
||||
t.Errorf("офсет %d, ожидался 10800: формат метрик обязан приниматься", daily.OffsetSeconds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Отказ разбора — операция «всё или ничего»: ошибка после уже разобранной
|
||||
// секции не имеет права оставить сущности в результате.
|
||||
func TestParseОбрывПослеСекцииТренировокНеОтдаётСущностей(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}],"metrics":`
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err == nil {
|
||||
t.Fatal("оборванное тело разобралось без ошибки")
|
||||
}
|
||||
if len(res.Workouts) != 0 {
|
||||
t.Errorf("сущностей %d, ожидалось 0", len(res.Workouts))
|
||||
}
|
||||
}
|
||||
|
||||
// Невыводимый слой — тоже «всё или ничего»: доставка целиком уходит в failed,
|
||||
// иначе она получила бы failed при частично записанной витрине.
|
||||
func TestParseНевыводимыйСлойНеОтдаётСущностей(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{
|
||||
"metrics":[{"name":"vo2_max","units":"ml/kg*min","data":[{"date":"2025-06-05 10:11:12 +0300","qty":1}]}],
|
||||
"stateOfMind":[{"id":"e1","start":"2025-06-05T18:00:00Z","valence":0.5}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{Aggregation: "Default"})
|
||||
if err == nil {
|
||||
t.Fatal("доставка без выводимого слоя разобралась без ошибки")
|
||||
}
|
||||
if len(res.Records) != 0 {
|
||||
t.Errorf("записей %d, ожидалось 0", len(res.Records))
|
||||
}
|
||||
if len(res.Points) != 0 {
|
||||
t.Errorf("точек %d, ожидалось 0", len(res.Points))
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор ключа покрытой секции объединяет её, а не отдаёт победу последней.
|
||||
func TestParseПовторСекцииТренировокОбъединяет(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{
|
||||
"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}],
|
||||
"workouts":[{"id":"w2","start":"2025-06-05 11:00:00 +0300"}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 2 {
|
||||
t.Errorf("тренировок %d, ожидалось 2 — секции обязаны объединиться", len(res.Workouts))
|
||||
}
|
||||
}
|
||||
|
||||
// Повтор самого члена `data` накапливает результаты: присваивание теряло бы
|
||||
// секции первого члена молча — их имена уже отмечены и во второй список не
|
||||
// попали бы.
|
||||
func TestParseПовторЧленаDataНакапливает(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{"ecg":[{"id":"e"}]},"data":{"workouts":[{"id":"w1","start":"2025-06-05 10:00:00 +0300"}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Workouts) != 1 {
|
||||
t.Errorf("тренировок %d, ожидалась 1", len(res.Workouts))
|
||||
}
|
||||
if len(res.Uncovered) != 1 || res.Uncovered[0] != "ecg" {
|
||||
t.Errorf("непокрытые %v, ожидался [ecg] — иначе секция потеряна молча", res.Uncovered)
|
||||
}
|
||||
}
|
||||
|
||||
// Непокрытой секцией с собственными `id` остаются те, чьей формы никто не
|
||||
// видел: разбор вслепую хуже честного «не покрыто».
|
||||
func TestParseНепокрытыеСекцииССобственнымиID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const body = `{"data":{"ecg":[{"id":"e1","start":"2025-06-05 10:00:00 +0300"}]}}`
|
||||
|
||||
res, err := hae.Parse([]byte(body), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if len(res.Records) != 0 {
|
||||
t.Errorf("записей %d, ожидалось 0", len(res.Records))
|
||||
}
|
||||
if len(res.Uncovered) != 1 || res.Uncovered[0] != "ecg" {
|
||||
t.Errorf("непокрытые %v", res.Uncovered)
|
||||
}
|
||||
}
|
||||
+165
-43
@@ -95,12 +95,66 @@ type Point struct {
|
||||
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), и без этого списка
|
||||
@@ -170,13 +224,17 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
}
|
||||
}()
|
||||
|
||||
metrics, uncovered, dropped, err := decodeEnvelope(body)
|
||||
env, err := decodeEnvelope(body)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
res.Uncovered = uncovered
|
||||
res.UncoveredDropped = dropped
|
||||
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
|
||||
@@ -209,6 +267,12 @@ func Parse(body []byte, meta Meta) (res Result, err error) {
|
||||
// определился слой, обязана остаться записью о том, что в теле есть
|
||||
// невосстановимая секция. Иначе ретеншен увидит failed без списка и
|
||||
// решит, что терять нечего.
|
||||
//
|
||||
// Сущности при этом НЕ отдаются, хотя слоя у них нет и разобрались они
|
||||
// успешно. «Всё или ничего» относится к доставке, а не к точкам: отдай
|
||||
// мы их, доставка получила бы `failed` при частично записанной витрине,
|
||||
// и повторная свёртка перестала бы быть no-op. Цена названа в спеке —
|
||||
// такая доставка доедет пересборкой, а тело ждёт в архиве.
|
||||
return Result{
|
||||
Metrics: res.Metrics,
|
||||
Uncovered: res.Uncovered,
|
||||
@@ -262,16 +326,53 @@ type group struct {
|
||||
// 42 МиБ через map[string]any удерживает 197 МиБ кучи против 54 МиБ у этой
|
||||
// формы. Вместе с самим телом пик доходил бы до ~300 МиБ на доставку — это
|
||||
// OOM ровно на пике потока, когда терять доставки дороже всего.
|
||||
// metricsSection — единственная секция, которую разбор покрывает сегодня.
|
||||
const metricsSection = "metrics"
|
||||
// Секции, которые разбор покрывает. Прочие секции с собственными `id` (`ecg`,
|
||||
// `symptoms`, `cycleTracking`, `medications`, `heartRateNotifications`)
|
||||
// покрытыми намеренно не становятся: живой поток не приносил их ни разу, их
|
||||
// форма никем не наблюдалась, а полнота покрытия HealthKit ради полноты целью
|
||||
// проекта не является.
|
||||
const (
|
||||
metricsSection = "metrics"
|
||||
workoutsSection = "workouts"
|
||||
stateOfMindSection = "stateOfMind"
|
||||
)
|
||||
|
||||
// covered говорит, покрывает ли разбор секцию с таким именем.
|
||||
// decodeCovered разбирает секцию, если разбор её покрывает; второй возврат
|
||||
// говорит, взялся ли он за неё.
|
||||
//
|
||||
// Функция, а не изменяемая карта: разбор и перечисление непокрытых ходят по
|
||||
// одному источнику, поэтому состояние «секция разбирается, но числится
|
||||
// непокрытой» невыразимо.
|
||||
func covered(section string) bool {
|
||||
return section == metricsSection
|
||||
// Один источник и для разбора, и для перечисления непокрытых: перечисляющий
|
||||
// спрашивает ровно того, кто разбирает, поэтому состояние «секция разбирается,
|
||||
// но числится непокрытой» невыразимо по построению. Отдельный предикат
|
||||
// `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
|
||||
}
|
||||
}
|
||||
|
||||
// Границы на список непокрытых ключей. Тело контролирует отправитель целиком:
|
||||
@@ -317,9 +418,11 @@ type pointHead struct {
|
||||
// мгновенно. Разбор `data` в map[string]json.RawMessage дешевле по коду, но
|
||||
// копирует байты ВСЕХ секций и держит их до конца разбора; у проглатывания
|
||||
// копия одна и живёт до следующего члена.
|
||||
func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string, dropped int, err error) {
|
||||
fail := func(e error) ([]metricEnvelope, []string, int, error) {
|
||||
return nil, nil, 0, fmt.Errorf("%w: %v", ErrMalformed, e) //nolint:errorlint // причина уходит в лог, наружу не раскрывается
|
||||
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))
|
||||
@@ -335,7 +438,7 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
// раскладывал его в пустую структуру. Границы поведения этой задачей не
|
||||
// двигаются — она добавляет список, а не строгость.
|
||||
if tok == nil {
|
||||
return nil, nil, 0, nil
|
||||
return envelope{}, nil
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return fail(fmt.Errorf("ожидался объект, встречено %v", tok))
|
||||
@@ -352,8 +455,12 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
}
|
||||
continue
|
||||
}
|
||||
metrics, uncovered, dropped, err = decodeData(dec, seen)
|
||||
if err != nil {
|
||||
// Повтор самого члена `data` JSON допускает, и результаты
|
||||
// НАКАПЛИВАЮТСЯ, а не замещаются: присваивание теряло бы секции первого
|
||||
// члена целиком, причём молча — их имена уже отмечены в `seen` и во
|
||||
// второй список непокрытых не попали бы. Правило то же, что уровнем
|
||||
// ниже для повтора ключа секции.
|
||||
if err := decodeData(dec, seen, &env); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
@@ -363,60 +470,75 @@ func decodeEnvelope(body []byte) (metrics []metricEnvelope, uncovered []string,
|
||||
|
||||
// Список канонизируется: порядок ключей в JSON от HAE нестабилен, а
|
||||
// значение уезжает в базу и сравнивается между доставками.
|
||||
sort.Strings(uncovered)
|
||||
return metrics, uncovered, dropped, nil
|
||||
sort.Strings(env.uncovered)
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// decodeData разбирает объект data, собирая metrics и имена непокрытых секций.
|
||||
func decodeData(dec *json.Decoder, seen map[string]struct{}) ([]metricEnvelope, []string, int, error) {
|
||||
// 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 nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
// data не объект — прежнее поведение: ошибка ровно там, где была.
|
||||
if d, ok := tok.(json.Delim); !ok || d != '{' {
|
||||
return nil, nil, 0, fmt.Errorf("data: ожидался объект, встречено %v", tok)
|
||||
return fmt.Errorf("data: ожидался объект, встречено %v", tok)
|
||||
}
|
||||
|
||||
var (
|
||||
metrics []metricEnvelope
|
||||
uncovered []string
|
||||
dropped int
|
||||
)
|
||||
for dec.More() {
|
||||
name, err := memberName(dec)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
|
||||
if covered(name) {
|
||||
// Повтор ключа metrics JSON допускает; секции ОБЪЕДИНЯЮТСЯ, а не
|
||||
// побеждает последняя: терять точки молча нельзя.
|
||||
var part []metricEnvelope
|
||||
if err := dec.Decode(&part); err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
metrics = append(metrics, part...)
|
||||
handled, err := decodeCovered(name, dec, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := swallow(dec); err != nil {
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
if len(uncovered) >= maxUncovered {
|
||||
dropped++
|
||||
if len(env.uncovered) >= maxUncovered {
|
||||
env.dropped++
|
||||
continue
|
||||
}
|
||||
uncovered = append(uncovered, clipSection(name))
|
||||
env.uncovered = append(env.uncovered, clipSection(name))
|
||||
}
|
||||
if _, err := dec.Token(); err != nil { // закрывающая скобка data
|
||||
return nil, nil, 0, err
|
||||
return err
|
||||
}
|
||||
return metrics, uncovered, dropped, nil
|
||||
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() отдаёт имя уже после разбора
|
||||
|
||||
+12
-11
@@ -548,10 +548,11 @@ func FuzzParse(f *testing.F) {
|
||||
})
|
||||
}
|
||||
|
||||
// Половина живого потока состоит из непокрытых секций целиком (48 доставок из
|
||||
// 99: workouts и stateOfMind). Без списка они неотличимы от разобранной
|
||||
// доставки с пустой секцией метрик, и ретеншен, ориентируясь на статус, срезал
|
||||
// бы тела, которые для stateOfMind единственный источник.
|
||||
// Непокрытая секция неотличима от разобранной доставки с пустой секцией
|
||||
// метрик, если её не назвать: ретеншен, ориентируясь на статус, срезал бы
|
||||
// тела, которые для секций без экспорта Apple единственный источник. С тех пор
|
||||
// как workouts и stateOfMind стали покрытыми, роль непокрытой в фикстуре
|
||||
// играют секции, которых поток ещё не приносил.
|
||||
func TestParseПеречисляетНепокрытыеСекции(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -560,7 +561,7 @@ func TestParseПеречисляетНепокрытыеСекции(t *testing.
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"stateOfMind", "workouts"}
|
||||
want := []string{"ecg", "symptoms"}
|
||||
if !slices.Equal(res.Uncovered, want) {
|
||||
t.Errorf("непокрытые %v, ожидались %v", res.Uncovered, want)
|
||||
}
|
||||
@@ -578,11 +579,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
|
||||
t.Run("доставка из одной непокрытой секции", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
res, err := hae.Parse([]byte(`{"data":{"stateOfMind":[{"x":1}]}}`), hae.Meta{})
|
||||
res, err := hae.Parse([]byte(`{"data":{"ecg":[{"x":1}]}}`), hae.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("разбор: %v", err)
|
||||
}
|
||||
if !slices.Equal(res.Uncovered, []string{"stateOfMind"}) {
|
||||
if !slices.Equal(res.Uncovered, []string{"ecg"}) {
|
||||
t.Errorf("непокрытые %v", res.Uncovered)
|
||||
}
|
||||
if len(res.Points) != 0 {
|
||||
@@ -609,11 +610,11 @@ func TestParseНепокрытыеСекцииГраницыИДетермини
|
||||
t.Parallel()
|
||||
|
||||
bodies := []string{
|
||||
`{"data":{"workouts":[],"stateOfMind":[],"ecg":[]}}`,
|
||||
`{"data":{"ecg":[],"workouts":[],"stateOfMind":[]}}`,
|
||||
`{"data":{"stateOfMind":[],"ecg":[],"workouts":[],"ecg":[]}}`,
|
||||
`{"data":{"symptoms":[],"medications":[],"ecg":[]}}`,
|
||||
`{"data":{"ecg":[],"symptoms":[],"medications":[]}}`,
|
||||
`{"data":{"medications":[],"ecg":[],"symptoms":[],"ecg":[]}}`,
|
||||
}
|
||||
want := []string{"ecg", "stateOfMind", "workouts"}
|
||||
want := []string{"ecg", "medications", "symptoms"}
|
||||
for _, b := range bodies {
|
||||
res, err := hae.Parse([]byte(b), hae.Meta{})
|
||||
if err != nil {
|
||||
|
||||
@@ -116,6 +116,13 @@ func TestParseУдержаниеКучиНепокрытойСекции(t *test
|
||||
t.Logf("непокрытых %v, удержано %d МиБ при теле %d МиБ",
|
||||
res.Uncovered, retained>>20, len(body)>>20)
|
||||
|
||||
// Замер обязан идти по ветке проглатывания. Без этой проверки расширение
|
||||
// множества покрытых секций превращает сторож в зелёную пустышку — что уже
|
||||
// однажды и произошло.
|
||||
if len(res.Uncovered) == 0 {
|
||||
t.Fatal("непокрытых секций нет — замер идёт мимо проглатывания и ничего не сторожит")
|
||||
}
|
||||
|
||||
if retained > limit {
|
||||
t.Errorf("удержано %d МиБ при теле %d МиБ — больше четырёх тел; "+
|
||||
"похоже, секции удерживаются, а не проглатываются",
|
||||
@@ -149,9 +156,13 @@ func bodyWithUncovered(size int) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"data":{"metrics":[{"name":"m","units":"count","data":[`)
|
||||
b.WriteString(`{"date":"2025-06-05 10:00:00 +0300","qty":1}`)
|
||||
b.WriteString(`]}],"stateOfMind":[`)
|
||||
// Секция обязана быть ЗАВЕДОМО НЕПОКРЫТОЙ: тело из покрытой секции идёт
|
||||
// мимо проглатывания, и замер вырождается в ноль, оставаясь зелёным.
|
||||
// Так уже случилось однажды: здесь стоял `stateOfMind`, и задача, покрывшая
|
||||
// его разбором, обезоружила сторож молча.
|
||||
b.WriteString(`]}],"ecg":[`)
|
||||
|
||||
const entry = `{"id":"00000000-0000-0000-0000-000000000000","kind":"momentary_emotion","valence":0.5},`
|
||||
const entry = `{"id":"00000000-0000-0000-0000-000000000000","classification":"sinusRhythm"},`
|
||||
for b.Len() < size {
|
||||
b.WriteString(entry)
|
||||
}
|
||||
|
||||
Vendored
+21
@@ -12,6 +12,19 @@
|
||||
переставлены с сохранением формы), даты (сдвинуты на постоянную величину),
|
||||
имена устройств.
|
||||
|
||||
Для сущностей с собственным `id` вычищается дополнительно: сами
|
||||
идентификаторы (псевдо-UUID той же формы), метки RFC 3339 и `route[].timestamp`
|
||||
(сдвигаются, как и прочие даты), а также словарные значения `stateOfMind` —
|
||||
`kind`, `valenceClassification`, `labels`, `associations`. Последнее не
|
||||
перестраховка: это измерение душевного состояния, самое чувствительное, что
|
||||
есть в потоке. Значения подменяются другими кодами из того же словаря HealthKit,
|
||||
поэтому форма (snake_case, строка против массива строк) сохраняется, а смысл —
|
||||
нет. Побочный эффект подмены: в `labels` могут появиться повторы, которых HAE не
|
||||
шлёт; разбору это безразлично.
|
||||
|
||||
Координаты маршрута вычищаются как обычные числа — они не отличаются от прочих
|
||||
измерений и после подмены указывают в никуда.
|
||||
|
||||
| Файл | Что проверяет |
|
||||
|---|---|
|
||||
| `minute.json` | минутная доставка; плотные метрики минутные, одна (`apple_stand_hour`) часовая — классификация **по метрике**, а не по доставке; редкие наследуют минутный слой; суточная сводка сна |
|
||||
@@ -20,7 +33,12 @@
|
||||
| `mixed.json` | одна доставка с минутными, посекундными и часовыми метриками — перенастройка автоматизации |
|
||||
| `heartbeat_series.json` | точка с `heartbeatSeries`: третий формат времени, серия проходит исходными байтами |
|
||||
| `sparse_sleep.json` | доставка **без плотных метрик** (заголовок `Default` не спасает) и поэпизодный сон с задвоенной меткой — интервальная идентичность |
|
||||
| `workout_route.json` | уличная тренировка с маршрутом и внутренними рядами; ряды урезаны `--limit`, форма точки маршрута сохранена |
|
||||
| `workout_indoor.json` | тренировка без маршрута: набор полей зависит от типа (`temperature`, `humidity`, `intensity` вместо `route`, `avgSpeed`, `flightsClimbed`) |
|
||||
| `state_of_mind.json` | `stateOfMind`: RFC 3339 в UTC, коды вместо переводов, поля `source` нет вовсе |
|
||||
| `uncovered_sections.json` | одна доставка со всеми родами секций сразу — покрытыми и непокрытыми. Рукотворная: живой поток шлёт по одной секции за раз |
|
||||
| `handmade_edge.json` | случаи, которых живой поток не даёт (см. ниже) |
|
||||
| `handmade_entities.json` | краевые случаи сущностей: пустой, отсутствующий и слишком длинный `id`; неразбираемая метка и её отсутствие; неразбираемый `end`; нечисловая и отсутствующая длительность; две версии одного `id` в одном теле; элемент, не являющийся объектом |
|
||||
|
||||
`handmade_edge.json` собран руками, скриптом не воспроизводится:
|
||||
|
||||
@@ -43,3 +61,6 @@ python3 tmp/research/fixtures.py --list
|
||||
python3 tmp/research/fixtures.py <файл из data/raw> --metrics a,b --limit 14 \
|
||||
--out internal/hae/testdata/<имя>.json
|
||||
```
|
||||
|
||||
`--limit` режет и внутренние ряды тренировки (маршрут, пульс, энергия): фикстуре
|
||||
нужна форма точки ряда, а не 593 её экземпляра.
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"_comment": "Рукотворная фикстура: краевые случаи сущностей с собственным id, которых живой поток не даёт. Скриптом tmp/research/fixtures.py не порождается, значения выдуманы целиком.",
|
||||
"data": {
|
||||
"workouts": [
|
||||
{
|
||||
"id": "",
|
||||
"name": "Пустой идентификатор",
|
||||
"start": "2025-06-05 10:00:00 +0300",
|
||||
"end": "2025-06-05 10:10:00 +0300",
|
||||
"duration": 600
|
||||
},
|
||||
{
|
||||
"name": "Идентификатора нет вовсе",
|
||||
"start": "2025-06-05 10:00:00 +0300",
|
||||
"end": "2025-06-05 10:10:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"name": "Метка не разбирается",
|
||||
"start": "вчера вечером",
|
||||
"end": "2025-06-05 10:10:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000002",
|
||||
"name": "Метки нет вовсе",
|
||||
"duration": 60
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000003",
|
||||
"name": "Конец не разбирается",
|
||||
"start": "2025-06-05 11:00:00 +0300",
|
||||
"end": "никогда",
|
||||
"duration": 61.5
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000004",
|
||||
"name": "Длительность не число",
|
||||
"start": "2025-06-05 12:00:00 +0300",
|
||||
"end": "2025-06-05 12:01:00 +0300",
|
||||
"duration": "минута"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000005",
|
||||
"name": "Длительности нет",
|
||||
"start": "2025-06-05 13:00:00 +0300",
|
||||
"end": "2025-06-05 13:01:00 +0300"
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000006",
|
||||
"name": "Начало берётся из date",
|
||||
"date": "2025-06-05 14:00:00 +0300",
|
||||
"duration": 30
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000007",
|
||||
"name": "Две версии в одном теле, первая",
|
||||
"start": "2025-06-05 15:00:00 +0300",
|
||||
"end": "2025-06-05 15:30:00 +0300",
|
||||
"duration": 1800,
|
||||
"totalEnergy": {"qty": 100.5, "units": "kJ"}
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000007",
|
||||
"name": "Две версии в одном теле, вторая",
|
||||
"start": "2025-06-05 15:00:00 +0300",
|
||||
"end": "2025-06-05 15:30:00 +0300",
|
||||
"duration": 1800,
|
||||
"totalEnergy": {"qty": 110.5, "units": "kJ"}
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000008-и-ещё-очень-длинный-хвост-который-заведомо-выходит-за-предел-длины-идентификатора-принятый-разбором-сущностей",
|
||||
"name": "Идентификатор длиннее предела",
|
||||
"start": "2025-06-05 16:00:00 +0300",
|
||||
"end": "2025-06-05 16:01:00 +0300"
|
||||
},
|
||||
"не объект вовсе",
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000009",
|
||||
"name": "Незнакомое поле и дословные литералы",
|
||||
"start": "2025-06-05 17:00:00 +0300",
|
||||
"end": "2025-06-05 17:05:00 +0300",
|
||||
"duration": 300,
|
||||
"невиданноеПоле": {"вложенное": [1.0, 9007199254740993, 0.123456789012345678]},
|
||||
"isIndoor": false
|
||||
}
|
||||
],
|
||||
"stateOfMind": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000a",
|
||||
"kind": "momentary_emotion",
|
||||
"start": "2025-06-05T18:00:00Z",
|
||||
"end": "2025-06-05T18:00:00Z",
|
||||
"valence": 0.5,
|
||||
"valenceClassification": "pleasant",
|
||||
"labels": ["calm"],
|
||||
"associations": ["hobbies"]
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-00000000000b",
|
||||
"kind": "daily_mood",
|
||||
"start": "2025-06-05 21:00:00 +0300",
|
||||
"end": "2025-06-06 21:00:00 +0300",
|
||||
"valence": -0.25,
|
||||
"valenceClassification": "slightly_unpleasant",
|
||||
"labels": [],
|
||||
"associations": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"data": {
|
||||
"stateOfMind": [
|
||||
{
|
||||
"associations": [
|
||||
"fitness"
|
||||
],
|
||||
"valenceClassification": "slightly_unpleasant",
|
||||
"valence": 0.021376147736425923,
|
||||
"end": "2025-06-05T18:03:51Z",
|
||||
"labels": [
|
||||
"peaceful",
|
||||
"peaceful"
|
||||
],
|
||||
"kind": "momentary_emotion",
|
||||
"id": "50244944-3582-4508-7804-525425479700",
|
||||
"start": "2025-06-05T18:03:51Z"
|
||||
},
|
||||
{
|
||||
"kind": "daily_mood",
|
||||
"end": "2025-06-05T18:03:17Z",
|
||||
"id": "87868435-7832-4736-7633-078876535422",
|
||||
"associations": [
|
||||
"hobbies"
|
||||
],
|
||||
"start": "2025-06-05T18:03:17Z",
|
||||
"valence": 0.12057562819279189,
|
||||
"valenceClassification": "slightly_unpleasant",
|
||||
"labels": [
|
||||
"relieved",
|
||||
"relieved"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+90
-15
@@ -5,16 +5,56 @@
|
||||
"name": "step_count",
|
||||
"units": "count",
|
||||
"data": [
|
||||
{"date": "2025-06-05 09:00:00 +0300", "qty": 41.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:01:00 +0300", "qty": 17.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:02:00 +0300", "qty": 82.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:03:00 +0300", "qty": 5.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:04:00 +0300", "qty": 63.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:05:00 +0300", "qty": 28.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:06:00 +0300", "qty": 94.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:07:00 +0300", "qty": 12.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:08:00 +0300", "qty": 71.0, "source": "Device A"},
|
||||
{"date": "2025-06-05 09:09:00 +0300", "qty": 36.0, "source": "Device A"}
|
||||
{
|
||||
"date": "2025-06-05 09:00:00 +0300",
|
||||
"qty": 41.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:01:00 +0300",
|
||||
"qty": 17.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:02:00 +0300",
|
||||
"qty": 82.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:03:00 +0300",
|
||||
"qty": 5.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:04:00 +0300",
|
||||
"qty": 63.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:05:00 +0300",
|
||||
"qty": 28.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:06:00 +0300",
|
||||
"qty": 94.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:07:00 +0300",
|
||||
"qty": 12.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:08:00 +0300",
|
||||
"qty": 71.0,
|
||||
"source": "Device A"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 09:09:00 +0300",
|
||||
"qty": 36.0,
|
||||
"source": "Device A"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -26,11 +66,28 @@
|
||||
"end": "2025-06-05 08:30:00 +0300",
|
||||
"duration": 1800,
|
||||
"heartRateData": [
|
||||
{"date": "2025-06-05 08:00:07 +0300", "Min": 91.0, "Avg": 94.5, "Max": 98.0, "units": "count/min"},
|
||||
{"date": "2025-06-05 08:00:21 +0300", "Min": 93.0, "Avg": 95.5, "Max": 99.0, "units": "count/min"}
|
||||
{
|
||||
"date": "2025-06-05 08:00:07 +0300",
|
||||
"Min": 91.0,
|
||||
"Avg": 94.5,
|
||||
"Max": 98.0,
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 08:00:21 +0300",
|
||||
"Min": 93.0,
|
||||
"Avg": 95.5,
|
||||
"Max": 99.0,
|
||||
"units": "count/min"
|
||||
}
|
||||
],
|
||||
"route": [
|
||||
{"lat": 10.0, "lon": 20.0, "altitude": 30.0, "timestamp": "2025-06-05 08:00:07 +0300"}
|
||||
{
|
||||
"lat": 10.0,
|
||||
"lon": 20.0,
|
||||
"altitude": 30.0,
|
||||
"timestamp": "2025-06-05 08:00:07 +0300"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -41,9 +98,27 @@
|
||||
"end": "2025-06-05T05:12:33Z",
|
||||
"kind": "momentary_emotion",
|
||||
"valence": 0.25,
|
||||
"labels": ["slightly_pleasant"],
|
||||
"labels": [
|
||||
"slightly_pleasant"
|
||||
],
|
||||
"associations": []
|
||||
}
|
||||
],
|
||||
"ecg": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"start": "2025-06-05 07:00:00 +0300",
|
||||
"classification": "sinusRhythm"
|
||||
}
|
||||
],
|
||||
"symptoms": [
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000004",
|
||||
"start": "2025-06-05 06:00:00 +0300",
|
||||
"name": "Головная боль",
|
||||
"severity": "mild"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"_comment": "Рукотворная фикстура: одна доставка со всеми родами секций сразу — покрытыми (metrics, workouts, stateOfMind) и непокрытыми (ecg, symptoms). Живой поток такого не даёт: автоматизация HAE шлёт одну секцию за раз."
|
||||
}
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"data": {
|
||||
"workouts": [
|
||||
{
|
||||
"id": "19336133-5198-0770-0219-503343909539",
|
||||
"maxHeartRate": {
|
||||
"qty": 199,
|
||||
"units": "count/min"
|
||||
},
|
||||
"temperature": {
|
||||
"units": "degC",
|
||||
"qty": 25.642900109344569
|
||||
},
|
||||
"name": "В помещении Ходьба",
|
||||
"avgHeartRate": {
|
||||
"qty": 47.271142352144859,
|
||||
"units": "count/min"
|
||||
},
|
||||
"isIndoor": true,
|
||||
"duration": 11.104378534563007,
|
||||
"walkingAndRunningDistance": [
|
||||
{
|
||||
"qty": 0.051178653222924688,
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"units": "km",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"qty": 0.0022744856624713684,
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"units": "km",
|
||||
"source": "Device A "
|
||||
}
|
||||
],
|
||||
"intensity": {
|
||||
"qty": 1.1101433306047975,
|
||||
"units": "kcal/hr·kg"
|
||||
},
|
||||
"start": "2025-06-05 21:07:25 +0300",
|
||||
"end": "2025-06-05 21:08:56 +0300",
|
||||
"activeEnergy": [
|
||||
{
|
||||
"units": "kJ",
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"qty": 49.484435766191329,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"qty": 1.2893292371894432,
|
||||
"units": "kJ",
|
||||
"source": "Device A "
|
||||
}
|
||||
],
|
||||
"basalEnergy": [
|
||||
{
|
||||
"units": "kJ",
|
||||
"qty": 8.118214886809112,
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"units": "kJ",
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"source": "Device A ",
|
||||
"qty": 8.3170473912094565
|
||||
}
|
||||
],
|
||||
"location": "В помещении",
|
||||
"metadata": {},
|
||||
"distance": {
|
||||
"units": "km",
|
||||
"qty": 0.097916506499049191
|
||||
},
|
||||
"totalEnergy": {
|
||||
"qty": 66.474914481955616,
|
||||
"units": "kJ"
|
||||
},
|
||||
"activeEnergyBurned": {
|
||||
"units": "kJ",
|
||||
"qty": 21.260499065517783
|
||||
},
|
||||
"speed": {
|
||||
"qty": 3.842565481147650,
|
||||
"units": "km/hr"
|
||||
},
|
||||
"heartRateData": [
|
||||
{
|
||||
"Max": 199,
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"Avg": 86.126023892474565,
|
||||
"source": "Device A ",
|
||||
"Min": 68,
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"Min": 62,
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"Avg": 42.420241306988357,
|
||||
"Max": 11,
|
||||
"units": "count/min"
|
||||
}
|
||||
],
|
||||
"heartRateRecovery": [
|
||||
{
|
||||
"Avg": 18,
|
||||
"units": "count/min",
|
||||
"Max": 18,
|
||||
"date": "2025-06-05 21:09:01 +0300",
|
||||
"Min": 18,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Min": 18,
|
||||
"date": "2025-06-05 21:09:05 +0300",
|
||||
"Avg": 18,
|
||||
"Max": 18,
|
||||
"units": "count/min",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Max": 18,
|
||||
"date": "2025-06-05 21:09:07 +0300",
|
||||
"Avg": 18,
|
||||
"source": "Device A ",
|
||||
"Min": 18,
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"Max": 64,
|
||||
"Avg": 64,
|
||||
"Min": 64,
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-05 21:10:44 +0300",
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"Avg": 84,
|
||||
"Max": 84,
|
||||
"units": "count/min",
|
||||
"Min": 84,
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-05 21:10:49 +0300"
|
||||
},
|
||||
{
|
||||
"source": "Device A |Device B",
|
||||
"Min": 16,
|
||||
"units": "count/min",
|
||||
"Max": 16,
|
||||
"Avg": 16,
|
||||
"date": "2025-06-05 21:10:54 +0300"
|
||||
}
|
||||
],
|
||||
"humidity": {
|
||||
"units": "%",
|
||||
"qty": 49
|
||||
},
|
||||
"heartRate": {
|
||||
"max": {
|
||||
"qty": 199,
|
||||
"units": "count/min"
|
||||
},
|
||||
"avg": {
|
||||
"qty": 47.271142352144859,
|
||||
"units": "count/min"
|
||||
},
|
||||
"min": {
|
||||
"qty": 68,
|
||||
"units": "count/min"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"data": {
|
||||
"workouts": [
|
||||
{
|
||||
"elevationDown": {
|
||||
"qty": 58,
|
||||
"units": "m"
|
||||
},
|
||||
"end": "2025-06-06 10:14:23 +0300",
|
||||
"activeEnergy": [
|
||||
{
|
||||
"qty": 19.843076834478356,
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:04:28 +0300"
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"qty": 36.215835452964045,
|
||||
"units": "kJ"
|
||||
},
|
||||
{
|
||||
"qty": 44.740245661570856,
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"units": "kJ"
|
||||
},
|
||||
{
|
||||
"qty": 11.685483741512438,
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:11:28 +0300"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:12:28 +0300",
|
||||
"source": "Device A ",
|
||||
"units": "kJ",
|
||||
"qty": 57.203753970215048
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:13:28 +0300",
|
||||
"units": "kJ",
|
||||
"qty": 92.751506515081775,
|
||||
"source": "Device A "
|
||||
}
|
||||
],
|
||||
"basalEnergy": [
|
||||
{
|
||||
"qty": 1.7719543711823085,
|
||||
"date": "2025-06-06 10:04:28 +0300",
|
||||
"units": "kJ",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"qty": 3.5941548161054400
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"units": "kJ",
|
||||
"qty": 8.4754498192994867
|
||||
},
|
||||
{
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:11:28 +0300",
|
||||
"qty": 3.5994877058893775
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"units": "kJ",
|
||||
"qty": 1.3821773496019762,
|
||||
"date": "2025-06-06 10:12:28 +0300"
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"qty": 3.5575445948756234,
|
||||
"units": "kJ",
|
||||
"date": "2025-06-06 10:13:28 +0300"
|
||||
}
|
||||
],
|
||||
"maxSpeed": {
|
||||
"qty": 1.7500239301072799,
|
||||
"units": "km"
|
||||
},
|
||||
"heartRate": {
|
||||
"avg": {
|
||||
"qty": 580.51441585109356,
|
||||
"units": "count/min"
|
||||
},
|
||||
"min": {
|
||||
"units": "count/min",
|
||||
"qty": 304
|
||||
},
|
||||
"max": {
|
||||
"units": "count/min",
|
||||
"qty": 149
|
||||
}
|
||||
},
|
||||
"heartRateRecovery": [
|
||||
{
|
||||
"Min": 530,
|
||||
"units": "count/min",
|
||||
"Avg": 530,
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:14:26 +0300",
|
||||
"Max": 530
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"Max": 509,
|
||||
"Min": 509,
|
||||
"units": "count/min",
|
||||
"Avg": 509,
|
||||
"date": "2025-06-06 10:14:29 +0300"
|
||||
},
|
||||
{
|
||||
"Min": 103,
|
||||
"Avg": 103,
|
||||
"Max": 103,
|
||||
"date": "2025-06-06 10:14:37 +0300",
|
||||
"units": "count/min",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Avg": 744,
|
||||
"date": "2025-06-06 10:16:12 +0300",
|
||||
"source": "Device A ",
|
||||
"Max": 744,
|
||||
"Min": 744,
|
||||
"units": "count/min"
|
||||
},
|
||||
{
|
||||
"Min": 744,
|
||||
"units": "count/min",
|
||||
"Avg": 744,
|
||||
"source": "Device A ",
|
||||
"Max": 744,
|
||||
"date": "2025-06-06 10:16:17 +0300"
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"Max": 556,
|
||||
"Min": 556,
|
||||
"Avg": 556,
|
||||
"units": "count/min",
|
||||
"date": "2025-06-06 10:16:22 +0300"
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"heartRateData": [
|
||||
{
|
||||
"Min": 304,
|
||||
"Avg": 378.98897969841650,
|
||||
"date": "2025-06-06 10:04:28 +0300",
|
||||
"units": "count/min",
|
||||
"Max": 147,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Avg": 989.39259425621223,
|
||||
"source": "Device A ",
|
||||
"units": "count/min",
|
||||
"Max": 485,
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"Min": 933
|
||||
},
|
||||
{
|
||||
"Min": 859,
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"Max": 485,
|
||||
"units": "count/min",
|
||||
"Avg": 763.09312691469744
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:11:28 +0300",
|
||||
"Min": 925,
|
||||
"source": "Device A ",
|
||||
"Avg": 930.87887543899777,
|
||||
"units": "count/min",
|
||||
"Max": 744
|
||||
},
|
||||
{
|
||||
"Max": 136,
|
||||
"Avg": 194.26919436633345,
|
||||
"units": "count/min",
|
||||
"date": "2025-06-06 10:12:28 +0300",
|
||||
"Min": 225,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"Min": 136,
|
||||
"source": "Device A ",
|
||||
"units": "count/min",
|
||||
"date": "2025-06-06 10:13:28 +0300",
|
||||
"Max": 149,
|
||||
"Avg": 503.16594564319518
|
||||
}
|
||||
],
|
||||
"totalEnergy": {
|
||||
"qty": 492.41220115508732,
|
||||
"units": "kJ"
|
||||
},
|
||||
"isIndoor": false,
|
||||
"avgSpeed": {
|
||||
"qty": 1.3262919151335835,
|
||||
"units": "km"
|
||||
},
|
||||
"id": "88509471-0669-5670-1525-588189120267",
|
||||
"flightsClimbed": {
|
||||
"qty": 3,
|
||||
"units": "count"
|
||||
},
|
||||
"avgHeartRate": {
|
||||
"units": "count/min",
|
||||
"qty": 580.51441585109356
|
||||
},
|
||||
"route": [
|
||||
{
|
||||
"speedAccuracy": 1.1423493491291924,
|
||||
"courseAccuracy": -8,
|
||||
"horizontalAccuracy": 37.394709386127353,
|
||||
"course": -8,
|
||||
"speed": 6.370761754418105,
|
||||
"longitude": 19.710543914850393,
|
||||
"altitude": 11.113585029955223,
|
||||
"timestamp": "2025-06-06 10:04:31 +0300",
|
||||
"latitude": 19.536524809019373,
|
||||
"verticalAccuracy": 1.1014334704737449
|
||||
},
|
||||
{
|
||||
"speedAccuracy": 3.6635325607233020,
|
||||
"timestamp": "2025-06-06 10:04:32 +0300",
|
||||
"longitude": 42.942268598619119,
|
||||
"horizontalAccuracy": 60.170973672383984,
|
||||
"verticalAccuracy": 13.574261948723033,
|
||||
"speed": 0,
|
||||
"courseAccuracy": -8,
|
||||
"altitude": 95.460693244383061,
|
||||
"course": -8,
|
||||
"latitude": 55.963120726050803
|
||||
},
|
||||
{
|
||||
"altitude": 98.38291790977828,
|
||||
"speed": 0.24397521532364589,
|
||||
"course": -8,
|
||||
"speedAccuracy": 0.61821384896158206,
|
||||
"courseAccuracy": -8,
|
||||
"latitude": 89.43783944027558,
|
||||
"verticalAccuracy": 9,
|
||||
"timestamp": "2025-06-06 10:04:33 +0300",
|
||||
"longitude": 38.436129962995469,
|
||||
"horizontalAccuracy": 7.0942497223602135
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-06-06 10:14:21 +0300",
|
||||
"latitude": 13.560422352014850,
|
||||
"verticalAccuracy": 9,
|
||||
"speed": 0.43710174633556862,
|
||||
"course": 138.44753628780219,
|
||||
"speedAccuracy": 0.9626723648822914,
|
||||
"horizontalAccuracy": 4.2411766781980601,
|
||||
"courseAccuracy": 974.54474058674578,
|
||||
"longitude": 16.879590022407365,
|
||||
"altitude": 27.895395296040802
|
||||
},
|
||||
{
|
||||
"courseAccuracy": 77.457067793746674,
|
||||
"altitude": 68.180480909094274,
|
||||
"course": 260.10957552178519,
|
||||
"verticalAccuracy": 9,
|
||||
"horizontalAccuracy": 8.6389325539171599,
|
||||
"speedAccuracy": 0.66244428495829519,
|
||||
"speed": 0.73309170745262559,
|
||||
"latitude": 25.591167408963750,
|
||||
"longitude": 84.179710558888322,
|
||||
"timestamp": "2025-06-06 10:14:22 +0300"
|
||||
},
|
||||
{
|
||||
"verticalAccuracy": 9,
|
||||
"course": 626.70356760825397,
|
||||
"altitude": 35.541136651044451,
|
||||
"latitude": 16.540540353133693,
|
||||
"horizontalAccuracy": 6.5267643473591963,
|
||||
"courseAccuracy": 28.348317314778372,
|
||||
"longitude": 54.812184540297820,
|
||||
"timestamp": "2025-06-06 10:14:23 +0300",
|
||||
"speed": 0.50936788164417521,
|
||||
"speedAccuracy": 0.6056449968288769
|
||||
}
|
||||
],
|
||||
"speed": {
|
||||
"units": "km/hr",
|
||||
"qty": 1.8553272323937848
|
||||
},
|
||||
"distance": {
|
||||
"qty": 0.45535883774475302,
|
||||
"units": "km"
|
||||
},
|
||||
"location": "На улице",
|
||||
"stepCount": [
|
||||
{
|
||||
"date": "2025-06-06 10:04:28 +0300",
|
||||
"units": "count",
|
||||
"qty": 578.41514946060714,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"qty": 888.32265222984684,
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"units": "count"
|
||||
},
|
||||
{
|
||||
"units": "count",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"qty": 389.76300874239161,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"qty": 856.17715741799695,
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-06 10:11:28 +0300",
|
||||
"units": "count"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:12:28 +0300",
|
||||
"source": "Device A |Device B",
|
||||
"units": "count",
|
||||
"qty": 139.21282675041696
|
||||
},
|
||||
{
|
||||
"qty": 35.029304391445824,
|
||||
"date": "2025-06-06 10:13:28 +0300",
|
||||
"units": "count",
|
||||
"source": "Device A |Device B"
|
||||
}
|
||||
],
|
||||
"duration": 914.68731011451610,
|
||||
"name": "На улице Ходьба",
|
||||
"start": "2025-06-06 10:04:28 +0300",
|
||||
"activeEnergyBurned": {
|
||||
"qty": 103.57106764020185,
|
||||
"units": "kJ"
|
||||
},
|
||||
"walkingAndRunningDistance": [
|
||||
{
|
||||
"date": "2025-06-06 10:04:28 +0300",
|
||||
"qty": 0.013796342030844388,
|
||||
"source": "Device A ",
|
||||
"units": "km"
|
||||
},
|
||||
{
|
||||
"qty": 0.074232043027597205,
|
||||
"units": "km",
|
||||
"date": "2025-06-06 10:05:28 +0300",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"qty": 0.026811024049784521,
|
||||
"units": "km",
|
||||
"date": "2025-06-06 10:06:28 +0300",
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:11:28 +0300",
|
||||
"units": "km",
|
||||
"source": "Device A |Device B",
|
||||
"qty": 0.014294089476468883
|
||||
},
|
||||
{
|
||||
"units": "km",
|
||||
"qty": 0.01426458841320012,
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-06 10:12:28 +0300"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-06 10:13:28 +0300",
|
||||
"source": "Device A |Device B",
|
||||
"qty": 0.050652946782496564,
|
||||
"units": "km"
|
||||
}
|
||||
],
|
||||
"stepCadence": {
|
||||
"units": "count/min",
|
||||
"qty": 494.09651343334029
|
||||
},
|
||||
"maxHeartRate": {
|
||||
"units": "count/min",
|
||||
"qty": 149
|
||||
}
|
||||
},
|
||||
{
|
||||
"distance": {
|
||||
"qty": 0.097916506499049191,
|
||||
"units": "km"
|
||||
},
|
||||
"name": "В помещении Ходьба",
|
||||
"activeEnergyBurned": {
|
||||
"units": "kJ",
|
||||
"qty": 21.260499065517783
|
||||
},
|
||||
"temperature": {
|
||||
"units": "degC",
|
||||
"qty": 25.642900109344569
|
||||
},
|
||||
"stepCount": [
|
||||
{
|
||||
"qty": 36.176779283023652,
|
||||
"units": "count",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:07:25 +0300"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"qty": 41.246641544770624,
|
||||
"units": "count",
|
||||
"source": "Device A "
|
||||
}
|
||||
],
|
||||
"stepCadence": {
|
||||
"qty": 67.312908402573276,
|
||||
"units": "count/min"
|
||||
},
|
||||
"isIndoor": true,
|
||||
"basalEnergy": [
|
||||
{
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"units": "kJ",
|
||||
"qty": 8.118214886809112,
|
||||
"source": "Device A "
|
||||
},
|
||||
{
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"units": "kJ",
|
||||
"qty": 4.9653503151527592
|
||||
}
|
||||
],
|
||||
"activeEnergy": [
|
||||
{
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"qty": 49.484435766191329,
|
||||
"source": "Device A ",
|
||||
"units": "kJ"
|
||||
},
|
||||
{
|
||||
"qty": 8.3710606151472032,
|
||||
"units": "kJ",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:08:25 +0300"
|
||||
}
|
||||
],
|
||||
"id": "19336133-5198-0770-0219-503343909539",
|
||||
"intensity": {
|
||||
"units": "kcal/hr·kg",
|
||||
"qty": 1.1101433306047975
|
||||
},
|
||||
"heartRateData": [
|
||||
{
|
||||
"units": "count/min",
|
||||
"Min": 68,
|
||||
"source": "Device A ",
|
||||
"Max": 199,
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"Avg": 86.126023892474565
|
||||
},
|
||||
{
|
||||
"units": "count/min",
|
||||
"source": "Device A ",
|
||||
"Max": 11,
|
||||
"Min": 62,
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"Avg": 42.420241306988357
|
||||
}
|
||||
],
|
||||
"end": "2025-06-05 21:08:56 +0300",
|
||||
"location": "В помещении",
|
||||
"speed": {
|
||||
"qty": 3.842565481147650,
|
||||
"units": "km/hr"
|
||||
},
|
||||
"totalEnergy": {
|
||||
"qty": 67.010279114793536,
|
||||
"units": "kJ"
|
||||
},
|
||||
"maxHeartRate": {
|
||||
"units": "count/min",
|
||||
"qty": 199
|
||||
},
|
||||
"avgHeartRate": {
|
||||
"qty": 47.271142352144859,
|
||||
"units": "count/min"
|
||||
},
|
||||
"heartRateRecovery": [
|
||||
{
|
||||
"Min": 18,
|
||||
"Avg": 18,
|
||||
"source": "Device A ",
|
||||
"Max": 18,
|
||||
"units": "count/min",
|
||||
"date": "2025-06-05 21:09:01 +0300"
|
||||
},
|
||||
{
|
||||
"date": "2025-06-05 21:09:05 +0300",
|
||||
"source": "Device A ",
|
||||
"Avg": 18,
|
||||
"units": "count/min",
|
||||
"Min": 18,
|
||||
"Max": 18
|
||||
},
|
||||
{
|
||||
"Min": 18,
|
||||
"source": "Device A ",
|
||||
"units": "count/min",
|
||||
"Max": 18,
|
||||
"date": "2025-06-05 21:09:07 +0300",
|
||||
"Avg": 18
|
||||
},
|
||||
{
|
||||
"Max": 64,
|
||||
"Min": 64,
|
||||
"Avg": 64,
|
||||
"units": "count/min",
|
||||
"source": "Device A |Device B",
|
||||
"date": "2025-06-05 21:10:44 +0300"
|
||||
},
|
||||
{
|
||||
"Min": 84,
|
||||
"units": "count/min",
|
||||
"date": "2025-06-05 21:10:49 +0300",
|
||||
"Max": 84,
|
||||
"Avg": 84,
|
||||
"source": "Device A |Device B"
|
||||
},
|
||||
{
|
||||
"units": "count/min",
|
||||
"source": "Device A |Device B",
|
||||
"Min": 16,
|
||||
"Avg": 16,
|
||||
"Max": 16,
|
||||
"date": "2025-06-05 21:10:54 +0300"
|
||||
}
|
||||
],
|
||||
"heartRate": {
|
||||
"avg": {
|
||||
"units": "count/min",
|
||||
"qty": 47.271142352144859
|
||||
},
|
||||
"max": {
|
||||
"units": "count/min",
|
||||
"qty": 199
|
||||
},
|
||||
"min": {
|
||||
"units": "count/min",
|
||||
"qty": 68
|
||||
}
|
||||
},
|
||||
"humidity": {
|
||||
"units": "%",
|
||||
"qty": 49
|
||||
},
|
||||
"duration": 11.104378534563007,
|
||||
"walkingAndRunningDistance": [
|
||||
{
|
||||
"date": "2025-06-05 21:07:25 +0300",
|
||||
"source": "Device A ",
|
||||
"units": "km",
|
||||
"qty": 0.051178653222924688
|
||||
},
|
||||
{
|
||||
"units": "km",
|
||||
"source": "Device A ",
|
||||
"date": "2025-06-05 21:08:25 +0300",
|
||||
"qty": 0.0022744856624713684
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"start": "2025-06-05 21:07:25 +0300"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user