From 608a7937e729782876d4af4f964498a1b5c1aa04 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sat, 2 May 2026 04:25:17 -0400 Subject: [PATCH] feat(monitor/collectors/swpc): five-endpoint NOAA SWPC client (plasma/mag/kp/xray/alerts) --- .../internal/collectors/swpc/client.go | 268 ++++++++++++++++++ .../internal/collectors/swpc/client_test.go | 113 ++++++++ .../collectors/swpc/testdata/alerts.json | 17 ++ .../internal/collectors/swpc/testdata/kp.json | 32 +++ .../collectors/swpc/testdata/mag.json | 38 +++ .../collectors/swpc/testdata/plasma.json | 26 ++ .../collectors/swpc/testdata/xray.json | 47 +++ 7 files changed, 541 insertions(+) create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json create mode 100644 PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go new file mode 100644 index 00000000..5a9104b1 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client.go @@ -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) +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go new file mode 100644 index 00000000..ccf699e8 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/client_test.go @@ -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()) + } +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json new file mode 100644 index 00000000..f8bc3a23 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/alerts.json @@ -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." + } +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json new file mode 100644 index 00000000..f5e0e768 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/kp.json @@ -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 + } +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json new file mode 100644 index 00000000..b594b512 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/mag.json @@ -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" + ] +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json new file mode 100644 index 00000000..4dac31f0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/plasma.json @@ -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" + ] +] diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json new file mode 100644 index 00000000..dbf1971c --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/backend/internal/collectors/swpc/testdata/xray.json @@ -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" + } +]