Files
weatherfeeder/internal/normalizers/nws/alerts.go
2026-02-08 08:56:16 -06:00

239 lines
6.7 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"
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"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
// 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
// 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()
}
return normcommon.NormalizeJSON(
in,
"nws alerts",
standards.SchemaWeatherAlertV1,
func(parsed nwsAlertsResponse) (model.WeatherAlertRun, time.Time, error) {
return buildAlerts(parsed, fallbackAsOf)
},
)
}
// 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 := nwscommon.ParseTimeBestEffort(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 := nwscommon.ParseTimePtr(p.Sent)
effective := nwscommon.ParseTimePtr(p.Effective)
onset := nwscommon.ParseTimePtr(p.Onset)
// Expires: prefer "expires"; fall back to "ends" if present.
expires := nwscommon.ParseTimePtr(p.Expires)
if expires == nil {
expires = nwscommon.ParseTimePtr(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 := nwscommon.ParseTimeBestEffort(s)
if t.IsZero() {
continue
}
if latest.IsZero() || t.After(latest) {
latest = t
}
}
}
return latest
}
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: nwscommon.ParseTimePtr(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
}