feat(monitor/backend): swpc emits rich space_weather payload (kp + bz_gsm + speed/density + xray flux/class)
This commit is contained in:
parent
6f062bf49b
commit
6e35f49d2a
|
|
@ -6,7 +6,10 @@ package swpc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
|
||||||
|
|
@ -22,6 +25,11 @@ const (
|
||||||
keyKp = "swpc:kp"
|
keyKp = "swpc:kp"
|
||||||
keyXray = "swpc:xray"
|
keyXray = "swpc:xray"
|
||||||
keyAlerts = "swpc:alerts"
|
keyAlerts = "swpc:alerts"
|
||||||
|
|
||||||
|
xrayBaseB = 1e-7
|
||||||
|
xrayBaseC = 1e-6
|
||||||
|
xrayBaseM = 1e-5
|
||||||
|
xrayBaseX = 1e-4
|
||||||
)
|
)
|
||||||
|
|
||||||
type Fetcher interface {
|
type Fetcher interface {
|
||||||
|
|
@ -58,6 +66,11 @@ type CollectorConfig struct {
|
||||||
type Collector struct {
|
type Collector struct {
|
||||||
cfg CollectorConfig
|
cfg CollectorConfig
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
|
latestKp *KpTick
|
||||||
|
latestPlasma *PlasmaTick
|
||||||
|
latestMag *MagTick
|
||||||
|
latestXray *XrayTick
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCollector(cfg CollectorConfig) *Collector {
|
func NewCollector(cfg CollectorConfig) *Collector {
|
||||||
|
|
@ -130,16 +143,7 @@ func (c *Collector) tickFast(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if pushed > 0 {
|
if pushed > 0 {
|
||||||
body, _ := json.Marshal(map[string]any{
|
c.emitCombined(pushed)
|
||||||
"ts": time.Now().UTC(),
|
|
||||||
"pushed": pushed,
|
|
||||||
})
|
|
||||||
c.cfg.Emitter.Emit(events.Event{
|
|
||||||
Topic: events.TopicSpaceWeather,
|
|
||||||
Timestamp: time.Now().UTC(),
|
|
||||||
Source: Name,
|
|
||||||
Payload: json.RawMessage(body),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hadError {
|
if !hadError {
|
||||||
|
|
@ -148,10 +152,55 @@ func (c *Collector) tickFast(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Collector) tickSlow(ctx context.Context) {
|
func (c *Collector) tickSlow(ctx context.Context) {
|
||||||
if _, err := c.pushKp(ctx); err != nil {
|
n, err := c.pushKp(ctx)
|
||||||
|
if err != nil {
|
||||||
c.logger.Warn("swpc kp", "err", err)
|
c.logger.Warn("swpc kp", "err", err)
|
||||||
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
_ = c.cfg.State.RecordError(ctx, Name, err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if n > 0 {
|
||||||
|
c.emitCombined(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) emitCombined(pushed int64) {
|
||||||
|
body, _ := json.Marshal(c.buildPayload(pushed))
|
||||||
|
c.cfg.Emitter.Emit(events.Event{
|
||||||
|
Topic: events.TopicSpaceWeather,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Source: Name,
|
||||||
|
Payload: json.RawMessage(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) buildPayload(pushed int64) map[string]any {
|
||||||
|
out := map[string]any{
|
||||||
|
"ts": time.Now().UTC(),
|
||||||
|
"pushed": pushed,
|
||||||
|
}
|
||||||
|
if c.latestKp != nil {
|
||||||
|
out["kp"] = c.latestKp.Kp
|
||||||
|
}
|
||||||
|
if c.latestPlasma != nil {
|
||||||
|
if v, ok := parseSWPCFloat(c.latestPlasma.Speed); ok {
|
||||||
|
out["speed_kms"] = v
|
||||||
|
}
|
||||||
|
if v, ok := parseSWPCFloat(c.latestPlasma.Density); ok {
|
||||||
|
out["density"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.latestMag != nil {
|
||||||
|
if v, ok := parseSWPCFloat(c.latestMag.BzGSM); ok {
|
||||||
|
out["bz_gsm"] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.latestXray != nil && c.latestXray.Flux > 0 {
|
||||||
|
out["xray_flux"] = c.latestXray.Flux
|
||||||
|
if cls := classifyXray(c.latestXray.Flux); cls != "" {
|
||||||
|
out["xray_class"] = cls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Collector) pushPlasma(ctx context.Context) (int64, error) {
|
func (c *Collector) pushPlasma(ctx context.Context) (int64, error) {
|
||||||
|
|
@ -159,6 +208,9 @@ func (c *Collector) pushPlasma(ctx context.Context) (int64, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if latest := lastNonZero(rows, func(r PlasmaTick) bool { return !r.TimeTag.IsZero() }); latest != nil {
|
||||||
|
c.latestPlasma = latest
|
||||||
|
}
|
||||||
return pushAll(ctx, c.cfg.Ring, keyPlasma, rows, func(r PlasmaTick) int64 { return r.TimeTag.UnixMilli() })
|
return pushAll(ctx, c.cfg.Ring, keyPlasma, rows, func(r PlasmaTick) int64 { return r.TimeTag.UnixMilli() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,6 +219,9 @@ func (c *Collector) pushMag(ctx context.Context) (int64, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if latest := lastNonZero(rows, func(r MagTick) bool { return !r.TimeTag.IsZero() }); latest != nil {
|
||||||
|
c.latestMag = latest
|
||||||
|
}
|
||||||
return pushAll(ctx, c.cfg.Ring, keyMag, rows, func(r MagTick) int64 { return r.TimeTag.UnixMilli() })
|
return pushAll(ctx, c.cfg.Ring, keyMag, rows, func(r MagTick) int64 { return r.TimeTag.UnixMilli() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,6 +230,9 @@ func (c *Collector) pushKp(ctx context.Context) (int64, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if latest := lastNonZero(rows, func(r KpTick) bool { return !r.TimeTag.IsZero() }); latest != nil {
|
||||||
|
c.latestKp = latest
|
||||||
|
}
|
||||||
return pushAll(ctx, c.cfg.Ring, keyKp, rows, func(r KpTick) int64 { return r.TimeTag.UnixMilli() })
|
return pushAll(ctx, c.cfg.Ring, keyKp, rows, func(r KpTick) int64 { return r.TimeTag.UnixMilli() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +241,9 @@ func (c *Collector) pushXray(ctx context.Context) (int64, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if latest := lastNonZero(rows, func(r XrayTick) bool { return !r.TimeTag.IsZero() }); latest != nil {
|
||||||
|
c.latestXray = latest
|
||||||
|
}
|
||||||
return pushAll(ctx, c.cfg.Ring, keyXray, rows, func(r XrayTick) int64 { return r.TimeTag.UnixMilli() })
|
return pushAll(ctx, c.cfg.Ring, keyXray, rows, func(r XrayTick) int64 { return r.TimeTag.UnixMilli() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,3 +270,43 @@ func pushAll[T any](ctx context.Context, ring Ring, key string, rows []T, score
|
||||||
}
|
}
|
||||||
return pushed, nil
|
return pushed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lastNonZero[T any](rows []T, ok func(T) bool) *T {
|
||||||
|
for i := len(rows) - 1; i >= 0; i-- {
|
||||||
|
if ok(rows[i]) {
|
||||||
|
r := rows[i]
|
||||||
|
return &r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSWPCFloat(s string) (float64, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || math.IsNaN(f) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyXray(flux float64) string {
|
||||||
|
if flux <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if flux >= xrayBaseX {
|
||||||
|
return fmt.Sprintf("X%.1f", flux/xrayBaseX)
|
||||||
|
}
|
||||||
|
if flux >= xrayBaseM {
|
||||||
|
return fmt.Sprintf("M%.1f", flux/xrayBaseM)
|
||||||
|
}
|
||||||
|
if flux >= xrayBaseC {
|
||||||
|
return fmt.Sprintf("C%.1f", flux/xrayBaseC)
|
||||||
|
}
|
||||||
|
if flux >= xrayBaseB {
|
||||||
|
return fmt.Sprintf("B%.1f", flux/xrayBaseB)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ package swpc_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -135,6 +136,60 @@ func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) {
|
||||||
require.Equal(t, 0, st.failures)
|
require.Equal(t, 0, st.failures)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollector_EmitsRichPayloadWithLatestReadings(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
ftch := &fakeFetcher{
|
||||||
|
plasma: []swpc.PlasmaTick{
|
||||||
|
{TimeTag: now.Add(-2 * time.Minute), Density: "1.0", Speed: "300"},
|
||||||
|
{TimeTag: now, Density: "5.42", Speed: "487"},
|
||||||
|
},
|
||||||
|
mag: []swpc.MagTick{
|
||||||
|
{TimeTag: now.Add(-2 * time.Minute), BzGSM: "1.5"},
|
||||||
|
{TimeTag: now, BzGSM: "-3.21"},
|
||||||
|
},
|
||||||
|
xray: []swpc.XrayTick{
|
||||||
|
{TimeTag: now.Add(-2 * time.Minute), Flux: 1e-7},
|
||||||
|
{TimeTag: now, Flux: 2.5e-5},
|
||||||
|
},
|
||||||
|
alerts: []swpc.AlertItem{},
|
||||||
|
kp: []swpc.KpTick{{TimeTag: now, Kp: 4.0}},
|
||||||
|
}
|
||||||
|
ring := &fakeRing{}
|
||||||
|
emt := &fakeEmitter{}
|
||||||
|
st := &recordingState{}
|
||||||
|
|
||||||
|
c := swpc.NewCollector(swpc.CollectorConfig{
|
||||||
|
FastInterval: 20 * time.Millisecond,
|
||||||
|
SlowInterval: 25 * time.Millisecond,
|
||||||
|
Fetcher: ftch,
|
||||||
|
Ring: ring,
|
||||||
|
Emitter: emt,
|
||||||
|
State: st,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_ = c.Run(ctx)
|
||||||
|
|
||||||
|
require.GreaterOrEqual(t, emt.Count(), 1)
|
||||||
|
|
||||||
|
emt.mu.Lock()
|
||||||
|
last := emt.events[len(emt.events)-1]
|
||||||
|
emt.mu.Unlock()
|
||||||
|
|
||||||
|
raw, ok := last.Payload.(json.RawMessage)
|
||||||
|
require.True(t, ok, "expected payload to be json.RawMessage")
|
||||||
|
var payload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &payload))
|
||||||
|
|
||||||
|
require.InDelta(t, 487.0, payload["speed_kms"], 0.001)
|
||||||
|
require.InDelta(t, 5.42, payload["density"], 0.001)
|
||||||
|
require.InDelta(t, -3.21, payload["bz_gsm"], 0.001)
|
||||||
|
require.InDelta(t, 2.5e-5, payload["xray_flux"], 1e-9)
|
||||||
|
require.Equal(t, "M2.5", payload["xray_class"])
|
||||||
|
require.InDelta(t, 4.0, payload["kp"], 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCollector_FetchErrorsRecordsState(t *testing.T) {
|
func TestCollector_FetchErrorsRecordsState(t *testing.T) {
|
||||||
ftch := &fakeFetcher{err: errors.New("upstream 503")}
|
ftch := &fakeFetcher{err: errors.New("upstream 503")}
|
||||||
ring := &fakeRing{}
|
ring := &fakeRing{}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue