Files
weatherfeeder/internal/normalizers/nws/alerts.go
Eric Rakestraw 00e811f8f7 normalizers/nws: add NWS alerts normalizer and canonical alert mapping
- Introduce AlertsNormalizer to convert Raw NWS Alerts (SchemaRawNWSAlertsV1)
  into canonical WeatherAlert runs (SchemaWeatherAlertV1)
- Add minimal NWS alerts response/types to support GeoJSON FeatureCollection parsing
- Map NWS alert properties (event, headline, severity, timing, area, references)
  into model.WeatherAlert with best-effort timestamp handling
- Establish clear AsOf / EffectiveAt policy for alert runs to support stable
  deduplication and snapshot semantics
- Register the new alerts normalizer alongside existing NWS observation and
  forecast normalizers
2026-01-16 21:40:20 -06:00

269 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FILE: internal/normalizers/nws/alerts.go
package nws
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/ejr/feedkit/event"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/model"
normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common"
nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
)
// AlertsNormalizer converts:
//
// standards.SchemaRawNWSAlertsV1 -> standards.SchemaWeatherAlertV1
//
// It interprets NWS /alerts FeatureCollection payloads (GeoJSON-ish) and maps them into
// the canonical model.WeatherAlertRun representation.
//
// Caveats / policy:
// 1. Run.AsOf prefers the collection-level "updated" timestamp. If missing/unparseable,
// we fall back to the latest per-alert timestamp, and then to the input events
// EffectiveAt/EmittedAt.
// 2. Alert timing fields are best-effort parsed; invalid timestamps do not fail the
// entire normalization (they are left nil).
// 3. Some fields are intentionally passed through as strings (severity/urgency/etc.)
// since canonical vocabularies may evolve later.
type AlertsNormalizer struct{}
func (AlertsNormalizer) Match(e event.Event) bool {
return strings.TrimSpace(e.Schema) == standards.SchemaRawNWSAlertsV1
}
func (AlertsNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
_ = ctx // normalization is pure/CPU; keep ctx for future expensive steps
parsed, err := normcommon.DecodeJSONPayload[nwsAlertsResponse](in)
if err != nil {
return nil, fmt.Errorf("nws alerts normalize: %w", err)
}
// If we can't derive AsOf from the payload, fall back to the existing event envelope.
fallbackAsOf := in.EmittedAt.UTC()
if in.EffectiveAt != nil && !in.EffectiveAt.IsZero() {
fallbackAsOf = in.EffectiveAt.UTC()
}
payload, effectiveAt, err := buildAlerts(parsed, fallbackAsOf)
if err != nil {
return nil, fmt.Errorf("nws alerts normalize: build: %w", err)
}
out, err := normcommon.Finalize(in, standards.SchemaWeatherAlertV1, payload, effectiveAt)
if err != nil {
return nil, fmt.Errorf("nws alerts normalize: %w", err)
}
return out, nil
}
// buildAlerts contains the domain mapping logic (provider -> canonical model).
func buildAlerts(parsed nwsAlertsResponse, fallbackAsOf time.Time) (model.WeatherAlertRun, time.Time, error) {
// 1) Determine AsOf (required by canonical model; also used as EffectiveAt).
asOf := parseNWSTimeUTC(parsed.Updated)
if asOf.IsZero() {
asOf = latestAlertTimestamp(parsed.Features)
}
if asOf.IsZero() {
asOf = fallbackAsOf.UTC()
}
run := model.WeatherAlertRun{
LocationID: "",
LocationName: strings.TrimSpace(parsed.Title),
AsOf: asOf,
Latitude: nil,
Longitude: nil,
Alerts: nil,
}
// 2) Map each feature into a canonical WeatherAlert.
alerts := make([]model.WeatherAlert, 0, len(parsed.Features))
for i, f := range parsed.Features {
p := f.Properties
id := firstNonEmpty(strings.TrimSpace(f.ID), strings.TrimSpace(p.ID), strings.TrimSpace(p.Identifier))
if id == "" {
// NWS usually supplies an ID, but be defensive. Prefer a stable-ish synth ID.
// Include the run as-of time to reduce collisions across snapshots.
id = fmt.Sprintf("nws:alert:%s:%d", asOf.UTC().Format(time.RFC3339Nano), i)
}
sent := parseNWSTimePtr(p.Sent)
effective := parseNWSTimePtr(p.Effective)
onset := parseNWSTimePtr(p.Onset)
// Expires: prefer "expires"; fall back to "ends" if present.
expires := parseNWSTimePtr(p.Expires)
if expires == nil {
expires = parseNWSTimePtr(p.Ends)
}
refs := parseNWSAlertReferences(p.References)
alert := model.WeatherAlert{
ID: id,
Event: strings.TrimSpace(p.Event),
Headline: strings.TrimSpace(p.Headline),
Severity: strings.TrimSpace(p.Severity),
Urgency: strings.TrimSpace(p.Urgency),
Certainty: strings.TrimSpace(p.Certainty),
Status: strings.TrimSpace(p.Status),
MessageType: strings.TrimSpace(p.MessageType),
Category: strings.TrimSpace(p.Category),
Response: strings.TrimSpace(p.Response),
Description: strings.TrimSpace(p.Description),
Instruction: strings.TrimSpace(p.Instruction),
Sent: sent,
Effective: effective,
Onset: onset,
Expires: expires,
AreaDescription: strings.TrimSpace(p.AreaDesc),
SenderName: firstNonEmpty(strings.TrimSpace(p.SenderName), strings.TrimSpace(p.Sender)),
References: refs,
}
// Headline fallback (NWS commonly provides it, but not guaranteed).
if alert.Headline == "" {
alert.Headline = alert.Event
}
alerts = append(alerts, alert)
}
run.Alerts = alerts
// EffectiveAt policy for alerts: treat AsOf as the effective time (dedupe-friendly).
return run, asOf, nil
}
// latestAlertTimestamp scans alert features for the most recent timestamp.
// It prefers Sent/Effective, and falls back to Expires/Ends when needed.
func latestAlertTimestamp(features []nwsAlertFeature) time.Time {
var latest time.Time
for _, f := range features {
p := f.Properties
candidates := []string{
p.Sent,
p.Effective,
p.Expires,
p.Ends,
p.Onset,
}
for _, s := range candidates {
t := parseNWSTimeUTC(s)
if t.IsZero() {
continue
}
if latest.IsZero() || t.After(latest) {
latest = t
}
}
}
return latest
}
// parseNWSTimeUTC parses an NWS timestamp string into UTC time.Time.
// Returns zero time on empty/unparseable input (best-effort; alerts should be resilient).
func parseNWSTimeUTC(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
t, err := nwscommon.ParseTime(s)
if err != nil {
return time.Time{}
}
return t.UTC()
}
func parseNWSTimePtr(s string) *time.Time {
t := parseNWSTimeUTC(s)
if t.IsZero() {
return nil
}
return &t
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
// parseNWSAlertReferences tries to interpret the NWS "references" field, which may
// vary by endpoint/version. We accept the common object-array form and a few
// degraded shapes (string array / single string).
func parseNWSAlertReferences(raw json.RawMessage) []model.AlertReference {
if len(raw) == 0 {
return nil
}
// Most common: array of objects.
var objs []nwsAlertReference
if err := json.Unmarshal(raw, &objs); err == nil && len(objs) > 0 {
out := make([]model.AlertReference, 0, len(objs))
for _, r := range objs {
ref := model.AlertReference{
ID: strings.TrimSpace(r.ID),
Identifier: strings.TrimSpace(r.Identifier),
Sender: strings.TrimSpace(r.Sender),
Sent: parseNWSTimePtr(r.Sent),
}
// If only Identifier is present, preserve it as ID too (useful downstream).
if ref.ID == "" && ref.Identifier != "" {
ref.ID = ref.Identifier
}
out = append(out, ref)
}
return out
}
// Sometimes: array of strings.
var strs []string
if err := json.Unmarshal(raw, &strs); err == nil && len(strs) > 0 {
out := make([]model.AlertReference, 0, len(strs))
for _, s := range strs {
id := strings.TrimSpace(s)
if id == "" {
continue
}
out = append(out, model.AlertReference{ID: id})
}
if len(out) > 0 {
return out
}
}
// Rare: single string.
var single string
if err := json.Unmarshal(raw, &single); err == nil {
id := strings.TrimSpace(single)
if id != "" {
return []model.AlertReference{{ID: id}}
}
}
return nil
}