feat(monitor/collectors/swpc): five-endpoint NOAA SWPC client (plasma/mag/kp/xray/alerts)

This commit is contained in:
CarterPerez-dev 2026-05-02 04:25:17 -04:00
parent fc3cfb523b
commit 608a7937e7
7 changed files with 541 additions and 0 deletions

View File

@ -0,0 +1,268 @@
// ©AngelaMos | 2026
// client.go
package swpc
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"golang.org/x/time/rate"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
)
const (
defaultSWPCBaseURL = "https://services.swpc.noaa.gov"
pathPlasma = "/products/solar-wind/plasma-5-minute.json"
pathMag = "/products/solar-wind/mag-5-minute.json"
pathKp = "/products/noaa-planetary-k-index.json"
pathXray = "/json/goes/primary/xrays-1-day.json"
pathAlerts = "/products/alerts.json"
defaultSWPCRate = 200 * time.Millisecond
defaultSWPCBurst = 5
defaultSWPCBudget = 5
defaultSWPCBreaker = 60 * time.Second
)
type ClientConfig struct {
BaseURL string
}
type Client struct {
hx *httpx.Client
}
func NewClient(cfg ClientConfig) *Client {
if cfg.BaseURL == "" {
cfg.BaseURL = defaultSWPCBaseURL
}
return &Client{
hx: httpx.New(httpx.Config{
Name: "swpc",
BaseURL: cfg.BaseURL,
Rate: rate.Every(defaultSWPCRate),
Burst: defaultSWPCBurst,
ConsecutiveFailureBudget: defaultSWPCBudget,
BreakerTimeout: defaultSWPCBreaker,
}),
}
}
type PlasmaTick struct {
TimeTag time.Time
Density string
Speed string
Temperature string
}
type MagTick struct {
TimeTag time.Time
BxGSM string
ByGSM string
BzGSM string
LonGSM string
LatGSM string
Bt string
}
type KpTick struct {
TimeTag time.Time
Kp float64
ARunning int
StationCount int
}
type XrayTick struct {
TimeTag time.Time
Satellite int
Flux float64
ObservedFlux float64
Energy string
}
type AlertItem struct {
ProductID string
IssueDatetime time.Time
Message string
}
func (c *Client) FetchPlasma(ctx context.Context) ([]PlasmaTick, error) {
rows, err := c.fetchRowArray(ctx, pathPlasma)
if err != nil {
return nil, err
}
out := make([]PlasmaTick, 0, len(rows))
for _, r := range rows {
ts, _ := ParseTime(r["time_tag"])
out = append(out, PlasmaTick{
TimeTag: ts,
Density: r["density"],
Speed: r["speed"],
Temperature: r["temperature"],
})
}
return out, nil
}
func (c *Client) FetchMag(ctx context.Context) ([]MagTick, error) {
rows, err := c.fetchRowArray(ctx, pathMag)
if err != nil {
return nil, err
}
out := make([]MagTick, 0, len(rows))
for _, r := range rows {
ts, _ := ParseTime(r["time_tag"])
out = append(out, MagTick{
TimeTag: ts,
BxGSM: r["bx_gsm"],
ByGSM: r["by_gsm"],
BzGSM: r["bz_gsm"],
LonGSM: r["lon_gsm"],
LatGSM: r["lat_gsm"],
Bt: r["bt"],
})
}
return out, nil
}
type rawKp struct {
TimeTag string `json:"time_tag"`
Kp float64 `json:"Kp"`
ARunning int `json:"a_running"`
StationCount int `json:"station_count"`
}
func (c *Client) FetchKp(ctx context.Context) ([]KpTick, error) {
var rows []rawKp
if err := c.hx.GetJSON(ctx, pathKp, nil, &rows); err != nil {
return nil, fmt.Errorf("fetch kp: %w", err)
}
out := make([]KpTick, 0, len(rows))
for _, r := range rows {
ts, _ := ParseTime(r.TimeTag)
out = append(out, KpTick{
TimeTag: ts,
Kp: r.Kp,
ARunning: r.ARunning,
StationCount: r.StationCount,
})
}
return out, nil
}
type rawXray struct {
TimeTag string `json:"time_tag"`
Satellite int `json:"satellite"`
Flux float64 `json:"flux"`
ObservedFlux float64 `json:"observed_flux"`
Energy string `json:"energy"`
}
func (c *Client) FetchXray(ctx context.Context) ([]XrayTick, error) {
var rows []rawXray
if err := c.hx.GetJSON(ctx, pathXray, nil, &rows); err != nil {
return nil, fmt.Errorf("fetch xray: %w", err)
}
out := make([]XrayTick, 0, len(rows))
for _, r := range rows {
ts, _ := ParseTime(r.TimeTag)
out = append(out, XrayTick{
TimeTag: ts,
Satellite: r.Satellite,
Flux: r.Flux,
ObservedFlux: r.ObservedFlux,
Energy: r.Energy,
})
}
return out, nil
}
type rawAlert struct {
ProductID string `json:"product_id"`
IssueDatetime string `json:"issue_datetime"`
Message string `json:"message"`
}
func (c *Client) FetchAlerts(ctx context.Context) ([]AlertItem, error) {
var rows []rawAlert
if err := c.hx.GetJSON(ctx, pathAlerts, nil, &rows); err != nil {
return nil, fmt.Errorf("fetch alerts: %w", err)
}
out := make([]AlertItem, 0, len(rows))
for _, r := range rows {
ts, _ := ParseTime(r.IssueDatetime)
out = append(out, AlertItem{
ProductID: r.ProductID,
IssueDatetime: ts,
Message: r.Message,
})
}
return out, nil
}
func (c *Client) fetchRowArray(ctx context.Context, path string) ([]map[string]string, error) {
var raw [][]any
if err := c.hx.GetJSON(ctx, path, nil, &raw); err != nil {
return nil, fmt.Errorf("fetch row-array %s: %w", path, err)
}
if len(raw) < 2 {
return nil, nil
}
headers := make([]string, 0, len(raw[0]))
for _, h := range raw[0] {
if s, ok := h.(string); ok {
headers = append(headers, s)
}
}
out := make([]map[string]string, 0, len(raw)-1)
for _, row := range raw[1:] {
m := make(map[string]string, len(headers))
for i, v := range row {
if i >= len(headers) {
break
}
m[headers[i]] = anyToString(v)
}
out = append(out, m)
}
return out, nil
}
func anyToString(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case json.Number:
return x.String()
case bool:
return strconv.FormatBool(x)
default:
return ""
}
}
var swpcTimeFormats = []string{
time.RFC3339Nano,
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05.000",
"2006-01-02 15:04:05",
}
func ParseTime(s string) (time.Time, error) {
if s == "" {
return time.Time{}, fmt.Errorf("empty swpc time")
}
for _, f := range swpcTimeFormats {
if t, err := time.Parse(f, s); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, fmt.Errorf("unrecognized swpc time: %q", s)
}

View File

@ -0,0 +1,113 @@
// ©AngelaMos | 2026
// client_test.go
package swpc_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/swpc"
)
func newFakeServer(t *testing.T, route string, fixture string) *httptest.Server {
t.Helper()
body, err := os.ReadFile("testdata/" + fixture)
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, route) {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
return srv
}
func TestClient_FetchPlasmaDecodesRowArray(t *testing.T) {
srv := newFakeServer(t, "plasma-5-minute", "plasma.json")
c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
rows, err := c.FetchPlasma(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(rows), 1)
require.False(t, rows[0].TimeTag.IsZero())
require.NotEmpty(t, rows[0].Density)
require.NotEmpty(t, rows[0].Speed)
}
func TestClient_FetchMagDecodesRowArray(t *testing.T) {
srv := newFakeServer(t, "mag-5-minute", "mag.json")
c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
rows, err := c.FetchMag(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(rows), 1)
}
func TestClient_FetchKpDecodesObjectArray(t *testing.T) {
srv := newFakeServer(t, "noaa-planetary-k-index", "kp.json")
c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
rows, err := c.FetchKp(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(rows), 1)
require.False(t, rows[0].TimeTag.IsZero())
require.GreaterOrEqual(t, rows[0].Kp, 0.0)
}
func TestClient_FetchXrayDecodesObjectArray(t *testing.T) {
srv := newFakeServer(t, "xrays-1-day", "xray.json")
c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
rows, err := c.FetchXray(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(rows), 1)
require.False(t, rows[0].TimeTag.IsZero())
require.Greater(t, rows[0].Flux, 0.0)
}
func TestClient_FetchAlertsDecodes(t *testing.T) {
srv := newFakeServer(t, "alerts.json", "alerts.json")
c := swpc.NewClient(swpc.ClientConfig{BaseURL: srv.URL})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
rows, err := c.FetchAlerts(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(rows), 1)
require.False(t, rows[0].IssueDatetime.IsZero())
require.NotEmpty(t, rows[0].ProductID)
require.NotEmpty(t, rows[0].Message)
}
func TestParseSWPCTime_AcceptsAllKnownFormats(t *testing.T) {
cases := []string{
"2026-05-02 08:20:00.000",
"2026-04-25T00:00:00",
"2026-05-01T08:24:00Z",
"2026-05-01 15:50:32.247",
}
for _, s := range cases {
got, err := swpc.ParseTime(s)
require.NoError(t, err, "input %q", s)
require.False(t, got.IsZero())
}
}

View File

@ -0,0 +1,17 @@
[
{
"product_id": "TIIA",
"issue_datetime": "2026-05-01 15:50:32.247",
"message": "Space Weather Message Code: ALTTP2\r\nSerial Number: 1481\r\nIssue Time: 2026 May 01 1550 UTC\r\n\r\nALERT: Type II Radio Emission\r\nBegin Time: 2026 May 01 1535 UTC\r\nEstimated Velocity: 593 km/s\r\n\r\n\r\n\r\nDescription: Type II emissions occur in association with eruptions on the sun and typically indicate a coronal mass ejection is associated with a flare event."
},
{
"product_id": "K05A",
"issue_datetime": "2026-04-30 23:59:26.927",
"message": "Space Weather Message Code: ALTK05\r\nSerial Number: 2013\r\nIssue Time: 2026 Apr 30 2359 UTC\r\n\r\nALERT: Geomagnetic K-index of 5\r\n Threshold Reached: 2026 Apr 30 2359 UTC\r\nSynoptic Period: 2100-2400 UTC\r\n \r\nActive Warning: Yes\r\nNOAA Scale: G1 - Minor\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 60 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nSpacecraft - Minor impact on satellite operations possible.\r\nAurora - Aurora may be visible at high latitudes, i.e., northern tier of the U.S. such as northern Michigan and Maine."
},
{
"product_id": "K04W",
"issue_datetime": "2026-04-30 23:11:27.400",
"message": "Space Weather Message Code: WARK04\r\nSerial Number: 5332\r\nIssue Time: 2026 Apr 30 2311 UTC\r\n\r\nEXTENDED WARNING: Geomagnetic K-index of 4 expected\r\nExtension to Serial Number: 5331\r\nValid From: 2026 Apr 30 2005 UTC\r\nNow Valid Until: 2026 May 01 1500 UTC\r\nWarning Condition: Persistence\r\n\r\nNOAA Space Weather Scale descriptions can be found at\r\nwww.swpc.noaa.gov/noaa-scales-explanation\r\n\r\nPotential Impacts: Area of impact primarily poleward of 65 degrees Geomagnetic Latitude.\r\nInduced Currents - Weak power grid fluctuations can occur.\r\nAurora - Aurora may be visible at high latitudes such as Canada and Alaska."
}
]

View File

@ -0,0 +1,32 @@
[
{
"time_tag": "2026-04-25T00:00:00",
"Kp": 3.33,
"a_running": 18,
"station_count": 8
},
{
"time_tag": "2026-04-25T03:00:00",
"Kp": 1.33,
"a_running": 5,
"station_count": 8
},
{
"time_tag": "2026-04-25T06:00:00",
"Kp": 1.33,
"a_running": 5,
"station_count": 8
},
{
"time_tag": "2026-04-25T09:00:00",
"Kp": 0.67,
"a_running": 3,
"station_count": 8
},
{
"time_tag": "2026-04-25T12:00:00",
"Kp": 1.33,
"a_running": 5,
"station_count": 8
}
]

View File

@ -0,0 +1,38 @@
[
[
"time_tag",
"bx_gsm",
"by_gsm",
"bz_gsm",
"lon_gsm",
"lat_gsm",
"bt"
],
[
"2026-05-02 08:20:00.000",
"0.36",
"0.12",
"4.41",
"18.07",
"85.10",
"4.43"
],
[
"2026-05-02 08:21:00.000",
"-0.32",
"-0.19",
"4.50",
"210.52",
"85.21",
"4.52"
],
[
"2026-05-02 08:22:00.000",
"-0.63",
"0.12",
"4.53",
"168.89",
"81.97",
"4.57"
]
]

View File

@ -0,0 +1,26 @@
[
[
"time_tag",
"density",
"speed",
"temperature"
],
[
"2026-05-02 08:20:00.000",
"2.94",
"450.6",
"93030"
],
[
"2026-05-02 08:21:00.000",
"2.90",
"448.0",
"96988"
],
[
"2026-05-02 08:22:00.000",
"2.65",
"452.1",
"84580"
]
]

View File

@ -0,0 +1,47 @@
[
{
"time_tag": "2026-05-01T08:24:00Z",
"satellite": 18,
"flux": 7.719492600699596E-9,
"observed_flux": 1.051609821445254E-8,
"electron_correction": 2.79660583579755E-9,
"electron_contaminaton": false,
"energy": "0.05-0.4nm"
},
{
"time_tag": "2026-05-01T08:24:00Z",
"satellite": 18,
"flux": 9.1065174956384E-7,
"observed_flux": 9.206618756252283E-7,
"electron_correction": 1.0010085205180985E-8,
"electron_contaminaton": false,
"energy": "0.1-0.8nm"
},
{
"time_tag": "2026-05-01T08:25:00Z",
"satellite": 18,
"flux": 8.121048722387059E-9,
"observed_flux": 1.0973010944326234E-8,
"electron_correction": 2.851962221939175E-9,
"electron_contaminaton": false,
"energy": "0.05-0.4nm"
},
{
"time_tag": "2026-05-01T08:25:00Z",
"satellite": 18,
"flux": 9.191372782879625E-7,
"observed_flux": 9.28974202452082E-7,
"electron_correction": 9.836912617799953E-9,
"electron_contaminaton": false,
"energy": "0.1-0.8nm"
},
{
"time_tag": "2026-05-01T08:26:00Z",
"satellite": 18,
"flux": 6.153450637924607E-9,
"observed_flux": 9.192835825899692E-9,
"electron_correction": 3.0393856320642954E-9,
"electron_contaminaton": false,
"energy": "0.05-0.4nm"
}
]