Files
weatherfeeder/internal/sources/nws/observation.go
Eric Rakestraw aa4774e0dd weatherfeeder: split the former maximumdirect.net/weatherd project in two.
feedkit now contains a reusable core, while weatherfeeder is a concrete implementation that includes weather-specific functions.
2026-01-13 18:14:21 -06:00

710 lines
18 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.
package nws
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"gitea.maximumdirect.net/ejr/feedkit/config"
"gitea.maximumdirect.net/ejr/feedkit/event"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/model"
"gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards"
)
// ObservationSource polls an NWS station observation endpoint and emits a single Observation Event.
//
// This corresponds to URLs like:
//
// https://api.weather.gov/stations/KSTL/observations/latest
type ObservationSource struct {
name string
url string
userAgent string
client *http.Client
}
func NewObservationSource(cfg config.SourceConfig) (*ObservationSource, error) {
if strings.TrimSpace(cfg.Name) == "" {
return nil, fmt.Errorf("nws_observation: name is required")
}
if cfg.Params == nil {
return nil, fmt.Errorf("nws_observation %q: params are required (need params.url and params.user_agent)", cfg.Name)
}
// feedkit keeps config domain-agnostic by storing driver-specific settings in Params.
// Use ParamString so we don't have to type-assert cfg.Params["url"] everywhere.
url, ok := cfg.ParamString("url", "URL")
if !ok {
return nil, fmt.Errorf("nws_observation %q: params.url is required", cfg.Name)
}
ua, ok := cfg.ParamString("user_agent", "userAgent")
if !ok {
return nil, fmt.Errorf("nws_observation %q: params.user_agent is required", cfg.Name)
}
// A small timeout is good hygiene for daemons: you want polls to fail fast,
// not hang forever and block subsequent ticks.
client := &http.Client{
Timeout: 10 * time.Second,
}
return &ObservationSource{
name: cfg.Name,
url: url,
userAgent: ua,
client: client,
}, nil
}
func (s *ObservationSource) Name() string { return s.name }
// Kind is used for routing/policy.
func (s *ObservationSource) Kind() event.Kind { return event.Kind("observation") }
// Poll fetches "current conditions" and emits exactly one Event (under normal conditions).
func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) {
obs, eventID, err := s.fetchAndParse(ctx)
if err != nil {
return nil, err
}
// EffectiveAt is optional.
// For observations, the natural effective time is the observation timestamp.
var effectiveAt *time.Time
if !obs.Timestamp.IsZero() {
t := obs.Timestamp
effectiveAt = &t
}
e := event.Event{
ID: eventID,
Kind: s.Kind(),
Source: s.name,
EmittedAt: time.Now().UTC(),
EffectiveAt: effectiveAt,
// Optional: makes downstream decoding/inspection easier.
Schema: "weather.observation.v1",
// Payload remains domain-specific for now.
Payload: obs,
}
if err := e.Validate(); err != nil {
return nil, err
}
return []event.Event{e}, nil
}
// --- JSON parsing (minimal model of NWS observation payload) ---
type nwsObservationResponse struct {
ID string `json:"id"` // a stable unique identifier URL in the payload you pasted
Properties struct {
StationID string `json:"stationId"`
StationName string `json:"stationName"`
Timestamp string `json:"timestamp"`
TextDescription string `json:"textDescription"`
Icon string `json:"icon"`
RawMessage string `json:"rawMessage"`
Elevation struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"elevation"`
Temperature struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"temperature"`
Dewpoint struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"dewpoint"`
WindDirection struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"windDirection"`
WindSpeed struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"windSpeed"`
WindGust struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"windGust"`
BarometricPressure struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"barometricPressure"`
SeaLevelPressure struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"seaLevelPressure"`
Visibility struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"visibility"`
RelativeHumidity struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"relativeHumidity"`
WindChill struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"windChill"`
HeatIndex struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"heatIndex"`
// NWS returns "presentWeather" as decoded METAR phenomena objects.
// We decode these initially as generic maps so we can:
// 1) preserve the raw objects in model.PresentWeather{Raw: ...}
// 2) also decode them into a typed struct for our WMO mapping logic.
PresentWeather []map[string]any `json:"presentWeather"`
CloudLayers []struct {
Base struct {
UnitCode string `json:"unitCode"`
Value *float64 `json:"value"`
} `json:"base"`
Amount string `json:"amount"`
} `json:"cloudLayers"`
} `json:"properties"`
}
// metarPhenomenon is a typed view of NWS presentWeather objects.
// You provided the schema for these values (intensity/modifier/weather/rawString).
type metarPhenomenon struct {
Intensity *string `json:"intensity"` // "light", "heavy", or null
Modifier *string `json:"modifier"` // "freezing", "showers", etc., or null
Weather string `json:"weather"` // e.g., "rain", "snow", "fog_mist", ...
RawString string `json:"rawString"`
// InVicinity exists in the schema; we ignore it for now because WMO codes
// don't directly represent "in vicinity" semantics.
InVicinity *bool `json:"inVicinity"`
}
func (s *ObservationSource) fetchAndParse(ctx context.Context) (model.WeatherObservation, string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", s.url, nil)
if err != nil {
return model.WeatherObservation{}, "", err
}
// NWS requests: a real User-Agent with contact info is strongly recommended.
req.Header.Set("User-Agent", s.userAgent)
req.Header.Set("Accept", "application/geo+json, application/json")
res, err := s.client.Do(req)
if err != nil {
return model.WeatherObservation{}, "", err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return model.WeatherObservation{}, "", fmt.Errorf("nws_observation %q: HTTP %s", s.name, res.Status)
}
var parsed nwsObservationResponse
if err := json.NewDecoder(res.Body).Decode(&parsed); err != nil {
return model.WeatherObservation{}, "", err
}
// Parse timestamp (RFC3339)
var ts time.Time
if strings.TrimSpace(parsed.Properties.Timestamp) != "" {
t, err := time.Parse(time.RFC3339, parsed.Properties.Timestamp)
if err != nil {
return model.WeatherObservation{}, "", fmt.Errorf("nws_observation %q: invalid timestamp %q: %w",
s.name, parsed.Properties.Timestamp, err)
}
ts = t
}
cloudLayers := make([]model.CloudLayer, 0, len(parsed.Properties.CloudLayers))
for _, cl := range parsed.Properties.CloudLayers {
cloudLayers = append(cloudLayers, model.CloudLayer{
BaseMeters: cl.Base.Value,
Amount: cl.Amount,
})
}
// Preserve the raw presentWeather objects (as before) in the domain model.
present := make([]model.PresentWeather, 0, len(parsed.Properties.PresentWeather))
for _, pw := range parsed.Properties.PresentWeather {
present = append(present, model.PresentWeather{Raw: pw})
}
// Decode presentWeather into a typed slice for improved mapping.
phenomena := decodeMetarPhenomena(parsed.Properties.PresentWeather)
// Provider description (NWS vocabulary). We store this for troubleshooting only.
providerDesc := strings.TrimSpace(parsed.Properties.TextDescription)
// Map NWS -> canonical WMO code using best-effort heuristics:
// 1) presentWeather (METAR phenomena) if present
// 2) provider textDescription keywords
// 3) cloud layers fallback
wmo := mapNWSToWMO(providerDesc, cloudLayers, phenomena)
// Canonical text comes from our shared WMO table.
// NWS does not give us an explicit day/night flag here, so we leave it nil.
canonicalText := standards.WMOText(wmo, nil)
obs := model.WeatherObservation{
StationID: parsed.Properties.StationID,
StationName: parsed.Properties.StationName,
Timestamp: ts,
// Canonical conditions
ConditionCode: wmo,
ConditionText: canonicalText,
IsDay: nil,
// Provider evidence (for troubleshooting mapping)
ProviderRawDescription: providerDesc,
// Human-facing fields:
// Populate TextDescription with canonical text so downstream output stays consistent.
TextDescription: canonicalText,
IconURL: parsed.Properties.Icon,
TemperatureC: parsed.Properties.Temperature.Value,
DewpointC: parsed.Properties.Dewpoint.Value,
WindDirectionDegrees: parsed.Properties.WindDirection.Value,
WindSpeedKmh: parsed.Properties.WindSpeed.Value,
WindGustKmh: parsed.Properties.WindGust.Value,
BarometricPressurePa: parsed.Properties.BarometricPressure.Value,
SeaLevelPressurePa: parsed.Properties.SeaLevelPressure.Value,
VisibilityMeters: parsed.Properties.Visibility.Value,
RelativeHumidityPercent: parsed.Properties.RelativeHumidity.Value,
WindChillC: parsed.Properties.WindChill.Value,
HeatIndexC: parsed.Properties.HeatIndex.Value,
ElevationMeters: parsed.Properties.Elevation.Value,
RawMessage: parsed.Properties.RawMessage,
PresentWeather: present,
CloudLayers: cloudLayers,
}
// Event ID: prefer the NWS-provided "id" (stable unique URL), else fall back to computed.
eventID := strings.TrimSpace(parsed.ID)
if eventID == "" {
eventID = fmt.Sprintf("observation:%s:%s:%s",
s.name,
obs.StationID,
obs.Timestamp.UTC().Format(time.RFC3339Nano),
)
}
return obs, eventID, nil
}
func decodeMetarPhenomena(raw []map[string]any) []metarPhenomenon {
if len(raw) == 0 {
return nil
}
out := make([]metarPhenomenon, 0, len(raw))
for _, m := range raw {
// Encode/decode is slightly inefficient, but it's simple and very readable.
// presentWeather payloads are small; this is fine for a polling daemon.
b, err := json.Marshal(m)
if err != nil {
continue
}
var p metarPhenomenon
if err := json.Unmarshal(b, &p); err != nil {
continue
}
p.Weather = strings.ToLower(strings.TrimSpace(p.Weather))
p.RawString = strings.TrimSpace(p.RawString)
out = append(out, p)
}
return out
}
// mapNWSToWMO maps NWS signals into a canonical WMO code.
//
// Precedence:
// 1. METAR phenomena (presentWeather) — most reliable for precip/hazards
// 2. textDescription keywords — weaker, but still useful
// 3. cloud layers fallback — only for sky-only conditions
func mapNWSToWMO(providerDesc string, cloudLayers []model.CloudLayer, phenomena []metarPhenomenon) model.WMOCode {
// 1) Prefer METAR phenomena if present.
if code := wmoFromPhenomena(phenomena); code != model.WMOUnknown {
return code
}
// 2) Fall back to provider textDescription keywords.
if code := wmoFromTextDescription(providerDesc); code != model.WMOUnknown {
return code
}
// 3) Fall back to cloud layers.
if code := wmoFromCloudLayers(cloudLayers); code != model.WMOUnknown {
return code
}
return model.WMOUnknown
}
func wmoFromPhenomena(phenomena []metarPhenomenon) model.WMOCode {
if len(phenomena) == 0 {
return model.WMOUnknown
}
// Helper accessors (avoid repeating nil checks everywhere).
intensityOf := func(p metarPhenomenon) string {
if p.Intensity == nil {
return ""
}
return strings.ToLower(strings.TrimSpace(*p.Intensity))
}
modifierOf := func(p metarPhenomenon) string {
if p.Modifier == nil {
return ""
}
return strings.ToLower(strings.TrimSpace(*p.Modifier))
}
// Pass 1: thunder + hail overrides everything (hazard).
//
// WMO provides:
// 95 = thunderstorm
// 96 = light thunderstorms with hail
// 99 = thunderstorms with hail
hasThunder := false
hailIntensity := ""
for _, p := range phenomena {
switch p.Weather {
case "thunderstorms":
hasThunder = true
case "hail":
if hailIntensity == "" {
hailIntensity = intensityOf(p)
}
}
}
if hasThunder {
if hailIntensity != "" || containsWeather(phenomena, "hail") {
if hailIntensity == "heavy" {
return 99
}
// Default to "light" hail when unknown
return 96
}
return 95
}
// Pass 2: freezing hazards.
//
// Modifier includes "freezing".
for _, p := range phenomena {
if modifierOf(p) != "freezing" {
continue
}
switch p.Weather {
case "rain":
if intensityOf(p) == "light" {
return 66
}
// Default to freezing rain when unknown/heavy.
return 67
case "drizzle":
if intensityOf(p) == "light" {
return 56
}
return 57
case "fog", "fog_mist":
// "Freezing fog" isn't a perfect match for "Rime Fog",
// but within our current WMO subset, 48 is the closest.
return 48
}
}
// Pass 3: fog / obscuration.
for _, p := range phenomena {
switch p.Weather {
case "fog", "fog_mist":
return 45
case "haze", "smoke", "dust", "sand", "spray", "volcanic_ash":
// Our current WMO table subset doesn't include haze/smoke/dust codes.
// "Foggy" (45) is a reasonable umbrella for "visibility obscured".
return 45
}
}
// Pass 4: precip families.
for _, p := range phenomena {
inten := intensityOf(p)
mod := modifierOf(p)
// Handle "showers" modifier explicitly (rain vs snow showers).
if mod == "showers" {
switch p.Weather {
case "rain":
if inten == "light" {
return 80
}
if inten == "heavy" {
return 82
}
return 81
case "snow":
if inten == "light" {
return 85
}
return 86
}
}
switch p.Weather {
// Drizzle
case "drizzle":
if inten == "heavy" {
return 55
}
if inten == "light" {
return 51
}
return 53
// Rain
case "rain":
if inten == "heavy" {
return 65
}
if inten == "light" {
return 61
}
return 63
// Snow
case "snow":
if inten == "heavy" {
return 75
}
if inten == "light" {
return 71
}
return 73
// Snow grains
case "snow_grains":
return 77
// We dont currently have sleet/ice pellet codes in our shared WMO subset.
// We make conservative choices within the available codes.
case "ice_pellets", "snow_pellets":
// Closest within our subset is "Snow" (73). If you later expand the WMO table
// to include sleet/ice pellet codes, update this mapping.
return 73
}
}
return model.WMOUnknown
}
func containsWeather(phenomena []metarPhenomenon, weather string) bool {
weather = strings.ToLower(strings.TrimSpace(weather))
for _, p := range phenomena {
if p.Weather == weather {
return true
}
}
return false
}
func wmoFromTextDescription(providerDesc string) model.WMOCode {
s := strings.ToLower(strings.TrimSpace(providerDesc))
if s == "" {
return model.WMOUnknown
}
// Thunder / hail
if strings.Contains(s, "thunder") {
if strings.Contains(s, "hail") {
return 99
}
return 95
}
// Freezing hazards
if strings.Contains(s, "freezing rain") {
if strings.Contains(s, "light") {
return 66
}
return 67
}
if strings.Contains(s, "freezing drizzle") {
if strings.Contains(s, "light") {
return 56
}
return 57
}
// Drizzle
if strings.Contains(s, "drizzle") {
if strings.Contains(s, "heavy") || strings.Contains(s, "dense") {
return 55
}
if strings.Contains(s, "light") {
return 51
}
return 53
}
// Showers
if strings.Contains(s, "showers") {
if strings.Contains(s, "heavy") {
return 82
}
if strings.Contains(s, "light") {
return 80
}
return 81
}
// Rain
if strings.Contains(s, "rain") {
if strings.Contains(s, "heavy") {
return 65
}
if strings.Contains(s, "light") {
return 61
}
return 63
}
// Snow
if strings.Contains(s, "snow showers") {
if strings.Contains(s, "light") {
return 85
}
return 86
}
if strings.Contains(s, "snow grains") {
return 77
}
if strings.Contains(s, "snow") {
if strings.Contains(s, "heavy") {
return 75
}
if strings.Contains(s, "light") {
return 71
}
return 73
}
// Fog
if strings.Contains(s, "rime fog") {
return 48
}
if strings.Contains(s, "fog") || strings.Contains(s, "mist") {
return 45
}
// Sky-only
if strings.Contains(s, "overcast") {
return 3
}
if strings.Contains(s, "cloudy") {
return 3
}
if strings.Contains(s, "partly cloudy") {
return 2
}
if strings.Contains(s, "mostly sunny") || strings.Contains(s, "mostly clear") ||
strings.Contains(s, "mainly sunny") || strings.Contains(s, "mainly clear") {
return 1
}
if strings.Contains(s, "clear") || strings.Contains(s, "sunny") {
return 0
}
return model.WMOUnknown
}
func wmoFromCloudLayers(cloudLayers []model.CloudLayer) model.WMOCode {
// NWS cloud layer amount values commonly include:
// OVC, BKN, SCT, FEW, SKC, CLR, VV (vertical visibility / obscured sky)
//
// We interpret these conservatively:
// - OVC / BKN / VV => Cloudy (3)
// - SCT => Partly Cloudy (2)
// - FEW => Mainly Sunny/Clear (1)
// - CLR / SKC => Sunny/Clear (0)
//
// If multiple layers exist, we bias toward the "most cloudy" layer.
mostCloudy := ""
for _, cl := range cloudLayers {
a := strings.ToUpper(strings.TrimSpace(cl.Amount))
if a == "" {
continue
}
switch a {
case "OVC":
return 3
case "BKN", "VV":
if mostCloudy != "OVC" {
mostCloudy = a
}
case "SCT":
if mostCloudy == "" {
mostCloudy = "SCT"
}
case "FEW":
if mostCloudy == "" {
mostCloudy = "FEW"
}
case "CLR", "SKC":
if mostCloudy == "" {
mostCloudy = "CLR"
}
}
}
switch mostCloudy {
case "BKN", "VV":
return 3
case "SCT":
return 2
case "FEW":
return 1
case "CLR":
return 0
default:
return model.WMOUnknown
}
}