// FILE: ./internal/normalizers/nws/observation.go package nws import ( "context" "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" ) // 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 }