// FILE: ./internal/normalizers/nws/observation.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" "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 rawBytes, err := normcommon.PayloadBytes(in) if err != nil { return nil, fmt.Errorf("nws observation normalize: %w", err) } var parsed nwsObservationResponse if err := json.Unmarshal(rawBytes, &parsed); err != nil { return nil, fmt.Errorf("nws observation normalize: decode raw payload: %w", err) } obs, effectiveAt, err := buildObservation(parsed) if err != nil { return nil, err } out := in out.Schema = standards.SchemaWeatherObservationV1 out.Payload = obs // EffectiveAt is optional; for observations it is naturally the observation timestamp. if !effectiveAt.IsZero() { t := effectiveAt.UTC() out.EffectiveAt = &t } if err := out.Validate(); err != nil { return nil, err } return &out, nil } // 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 := time.Parse(time.RFC3339, s) if err != nil { return model.WeatherObservation{}, time.Time{}, fmt.Errorf("nws observation normalize: invalid timestamp %q: %w", s, 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 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) // Canonical condition text comes from our WMO table. // NWS observation responses typically do not include a day/night flag -> nil. canonicalText := standards.WMOText(wmo, nil) obs := model.WeatherObservation{ StationID: parsed.Properties.StationID, StationName: parsed.Properties.StationName, Timestamp: ts, ConditionCode: wmo, ConditionText: canonicalText, IsDay: nil, 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, WindChillC: parsed.Properties.WindChill.Value, HeatIndexC: parsed.Properties.HeatIndex.Value, ElevationMeters: parsed.Properties.Elevation.Value, RawMessage: parsed.Properties.RawMessage, PresentWeather: present, CloudLayers: cloudLayers, } return obs, ts, nil }