Files
MrAlders0n 98006a9364 feat(stats): serve historical stats from hourly rollups
Stats endpoints read hourly rollup tables filled once per closed hour by one
advisory-locked task, instead of materialized views that re-scanned every
observation on each refresh. IATA-set tables give exact distinct counts for any
IATA set or region. Cleanup holds raw rows for unrolled hours (up to 24h).

Adds /stats/series; ingest keeps packet observation counts, observer last IATA,
channel message counts and trace tag summaries. 001_baseline.sql is rewritten,
so pre-release 2.0 databases must be recreated. See docs/historical-stats.md.
2026-10-02 07:43:56 -04:00

69 lines
2.2 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"context"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) GetSignalStats(ctx context.Context, since, until time.Time, iatas []string) (*api.SignalStats, error) {
rows, err := s.q.GetSignalStats(ctx, sqlc.GetSignalStatsParams{
Since: pgtype.Timestamptz{Time: since, Valid: true}, Until: pgtype.Timestamptz{Time: until, Valid: true}, Iatas: iatas,
})
if err != nil {
return nil, err
}
stats := &api.SignalStats{Since: since.UnixMilli(), Until: until.UnixMilli(),
SNR: api.SignalMetric{Histogram: signalBins(-30, 5, 12)}, RSSI: api.SignalMetric{Histogram: signalBins(-140, 10, 14)}, Hourly: []api.SignalHour{}}
for _, row := range rows {
// GROUPING bits distinguish real null buckets from dimensions not in the set.
switch row.Kind {
case 7:
stats.Receptions = row.Receptions
stats.SNR.Samples, stats.SNR.Average = row.SnrSamples, signalAverage(row.SnrAverage, row.SnrSamples)
stats.RSSI.Samples, stats.RSSI.Average = row.RssiSamples, signalAverage(row.RssiAverage, row.RssiSamples)
case 3:
stats.Hourly = append(stats.Hourly, api.SignalHour{Hour: row.Hour.Time.UnixMilli(), Receptions: row.Receptions,
SNRSamples: row.SnrSamples, SNRAverage: signalAverage(row.SnrAverage, row.SnrSamples),
RSSISamples: row.RssiSamples, RSSIAverage: signalAverage(row.RssiAverage, row.RssiSamples)})
case 5:
if row.SnrBin >= 0 && int(row.SnrBin) < len(stats.SNR.Histogram) {
stats.SNR.Histogram[row.SnrBin].Count = row.Receptions
}
case 6:
if row.RssiBin >= 0 && int(row.RssiBin) < len(stats.RSSI.Histogram) {
stats.RSSI.Histogram[row.RssiBin].Count = row.Receptions
}
}
}
return stats, nil
}
func signalAverage(value float64, count int64) *float64 {
if count == 0 {
return nil
}
return &value
}
func signalBins(lower, width float64, count int) []api.SignalBin {
bins := make([]api.SignalBin, count+2)
for i := range bins {
if i > 0 {
v := lower + float64(i-1)*width
bins[i].Lower = &v
}
if i <= count {
v := lower + float64(i)*width
bins[i].Upper = &v
}
}
return bins
}