// FILE: ./internal/normalizers/openweather/observation.go package openweather import ( "context" "strings" "time" "gitea.maximumdirect.net/ejr/feedkit/event" normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) // ObservationNormalizer converts: // // standards.SchemaRawOpenWeatherCurrentV1 -> standards.SchemaWeatherObservationV1 // // It interprets OpenWeatherMap "current weather" JSON and maps it into the canonical // model.WeatherObservation representation. // // Caveats / assumptions: // - Unit system: this normalizer assumes the upstream request used `units=metric`. // The OpenWeather source enforces this invariant (fails fast otherwise). // That means: // - main.temp is °C // - wind.speed and wind.gust are m/s (we convert to km/h) // - pressure fields are hPa (we convert to Pa) // // Day/night handling: // - Prefer the OpenWeather icon suffix ("d" / "n") when available. // - Otherwise fall back to sunrise/sunset bounds (unix seconds). type ObservationNormalizer struct{} func (ObservationNormalizer) Match(e event.Event) bool { return strings.TrimSpace(e.Schema) == standards.SchemaRawOpenWeatherCurrentV1 } 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, "openweather observation", standards.SchemaWeatherObservationV1, buildObservation, ) } // buildObservation contains the domain mapping logic (provider -> canonical model). func buildObservation(parsed owmResponse) (model.WeatherObservation, time.Time, error) { // Timestamp: dt is unix seconds, UTC (OpenWeather contract). var ts time.Time if parsed.Dt > 0 { ts = time.Unix(parsed.Dt, 0).UTC() } // Primary weather condition: OpenWeather returns an array; treat [0] as primary. owmID, rawDesc, icon := primaryCondition(parsed.Weather) // Day/night inference: // 1) icon suffix "d" or "n" // 2) sunrise/sunset bounds isDay := inferIsDay(icon, parsed.Dt, parsed.Sys.Sunrise, parsed.Sys.Sunset) // Unit policy: metric is enforced by the source, so: // - temp is already °C // - wind speed is m/s -> km/h conversion tempC := parsed.Main.Temp rh := parsed.Main.Humidity var apparentC *float64 if parsed.Main.FeelsLike != nil { v := *parsed.Main.FeelsLike apparentC = &v } surfacePa := normcommon.PressurePaFromHPa(parsed.Main.Pressure) var seaLevelPa *float64 if parsed.Main.SeaLevel != nil { v := normcommon.PressurePaFromHPa(*parsed.Main.SeaLevel) seaLevelPa = &v } wsKmh := normcommon.SpeedKmhFromMps(parsed.Wind.Speed) var wgKmh *float64 if parsed.Wind.Gust != nil { v := normcommon.SpeedKmhFromMps(*parsed.Wind.Gust) wgKmh = &v } var visM *float64 if parsed.Visibility != nil { v := *parsed.Visibility visM = &v } // Condition mapping: OpenWeather condition IDs -> canonical WMO code vocabulary. wmo := mapOpenWeatherToWMO(owmID) canonicalText := standards.WMOText(wmo, isDay) iconURL := openWeatherIconURL(icon) stationID := openWeatherStationID(parsed) stationName := strings.TrimSpace(parsed.Name) if stationName == "" { stationName = "OpenWeatherMap" } obs := model.WeatherObservation{ StationID: stationID, StationName: stationName, Timestamp: ts, ConditionCode: wmo, ConditionText: canonicalText, IsDay: isDay, ProviderRawDescription: rawDesc, // Human-facing legacy fields: populate with canonical text for consistency. TextDescription: canonicalText, IconURL: iconURL, TemperatureC: &tempC, ApparentTemperatureC: apparentC, WindDirectionDegrees: parsed.Wind.Deg, WindSpeedKmh: &wsKmh, WindGustKmh: wgKmh, BarometricPressurePa: &surfacePa, SeaLevelPressurePa: seaLevelPa, VisibilityMeters: visM, RelativeHumidityPercent: &rh, } return obs, ts, nil }