feat(monitor/backend): swpc emits rich space_weather payload (kp + bz_gsm + speed/density + xray flux/class)

This commit is contained in:
CarterPerez-dev 2026-05-03 19:39:41 -04:00
parent 6f062bf49b
commit 6e35f49d2a
2 changed files with 167 additions and 11 deletions

View File

@ -6,7 +6,10 @@ package swpc
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"strconv"
"time"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/events"
@ -22,6 +25,11 @@ const (
keyKp = "swpc:kp"
keyXray = "swpc:xray"
keyAlerts = "swpc:alerts"
xrayBaseB = 1e-7
xrayBaseC = 1e-6
xrayBaseM = 1e-5
xrayBaseX = 1e-4
)
type Fetcher interface {
@ -58,6 +66,11 @@ type CollectorConfig struct {
type Collector struct {
cfg CollectorConfig
logger *slog.Logger
latestKp *KpTick
latestPlasma *PlasmaTick
latestMag *MagTick
latestXray *XrayTick
}
func NewCollector(cfg CollectorConfig) *Collector {
@ -130,16 +143,7 @@ func (c *Collector) tickFast(ctx context.Context) {
}
if pushed > 0 {
body, _ := json.Marshal(map[string]any{
"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),
})
c.emitCombined(pushed)
}
if !hadError {
@ -148,10 +152,55 @@ func (c *Collector) tickFast(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.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) {
@ -159,6 +208,9 @@ func (c *Collector) pushPlasma(ctx context.Context) (int64, error) {
if err != nil {
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() })
}
@ -167,6 +219,9 @@ func (c *Collector) pushMag(ctx context.Context) (int64, error) {
if err != nil {
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() })
}
@ -175,6 +230,9 @@ func (c *Collector) pushKp(ctx context.Context) (int64, error) {
if err != nil {
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() })
}
@ -183,6 +241,9 @@ func (c *Collector) pushXray(ctx context.Context) (int64, error) {
if err != nil {
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() })
}
@ -209,3 +270,43 @@ func pushAll[T any](ctx context.Context, ring Ring, key string, rows []T, score
}
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 ""
}

View File

@ -5,6 +5,7 @@ package swpc_test
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
@ -135,6 +136,60 @@ func TestCollector_FastTickPushesToRingsAndEmits(t *testing.T) {
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) {
ftch := &fakeFetcher{err: errors.New("upstream 503")}
ring := &fakeRing{}