feat(monitor/collectors/swpc): five-endpoint NOAA SWPC client (plasma/mag/kp/xray/alerts)
This commit is contained in:
parent
fc3cfb523b
commit
608a7937e7
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
]
|
||||
]
|
||||
|
|
@ -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"
|
||||
]
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
Loading…
Reference in New Issue