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

134 lines
4.4 KiB
Go

// FILE: ./internal/normalizers/nws/observation.go
package nws
import (
"context"
"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"
)
// ObservationNormalizer converts:
//
// standards.SchemaRawNWSObservationV1 -> standards.SchemaWeatherObservationV1
//
// It interprets NWS GeoJSON station observations and maps them into the
// canonical model.WeatherObservation representation.
//
// Precedence for determining ConditionCode (WMO):
// 1. presentWeather (METAR phenomena objects) — strongest signal
// 2. textDescription keyword fallback — reusable across providers
// 3. cloudLayers sky-only fallback — NWS/METAR-specific
type ObservationNormalizer struct{}
func (ObservationNormalizer) Match(e event.Event) bool {
return strings.TrimSpace(e.Schema) == standards.SchemaRawNWSObservationV1
}
func (ObservationNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) {
_ = ctx // normalization is pure/CPU; keep ctx for future expensive steps
return normcommon.NormalizeJSON(
in,
"nws observation",
standards.SchemaWeatherObservationV1,
buildObservation,
)
}
// buildObservation contains the domain mapping logic (provider -> canonical model).
func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation, time.Time, error) {
// Timestamp (RFC3339)
var ts time.Time
if s := strings.TrimSpace(parsed.Properties.Timestamp); s != "" {
t, err := nwscommon.ParseTime(s)
if err != nil {
return model.WeatherObservation{}, time.Time{}, fmt.Errorf("invalid timestamp %q: %w", s, err)
}
ts = t.UTC()
}
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 raw presentWeather objects (for troubleshooting / drift analysis).
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 typed METAR phenomena for mapping.
phenomena := decodeMetarPhenomena(parsed.Properties.PresentWeather)
providerDesc := strings.TrimSpace(parsed.Properties.TextDescription)
// Determine canonical WMO condition code.
wmo := mapNWSToWMO(providerDesc, cloudLayers, phenomena)
var isDay *bool
if lat, lon := observationLatLon(parsed.Geometry.Coordinates); lat != nil && lon != nil {
isDay = isDayFromLatLonTime(*lat, *lon, ts)
}
// Canonical condition text comes from our WMO table.
canonicalText := standards.WMOText(wmo, isDay)
// Apparent temperature: prefer wind chill when both are supplied.
var apparentC *float64
if parsed.Properties.WindChill.Value != nil {
apparentC = parsed.Properties.WindChill.Value
} else if parsed.Properties.HeatIndex.Value != nil {
apparentC = parsed.Properties.HeatIndex.Value
}
obs := model.WeatherObservation{
StationID: parsed.Properties.StationID,
StationName: parsed.Properties.StationName,
Timestamp: ts,
ConditionCode: wmo,
ConditionText: canonicalText,
IsDay: isDay,
ProviderRawDescription: providerDesc,
// Transitional / human-facing:
// keep output consistent by populating TextDescription from canonical text.
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,
ApparentTemperatureC: apparentC,
ElevationMeters: parsed.Properties.Elevation.Value,
RawMessage: parsed.Properties.RawMessage,
PresentWeather: present,
CloudLayers: cloudLayers,
}
return obs, ts, nil
}