// FILE: ./internal/normalizers/openweather/observation.go package openweather 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.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 rawBytes, err := normcommon.PayloadBytes(in) if err != nil { return nil, fmt.Errorf("openweather observation normalize: %w", err) } var parsed owmResponse if err := json.Unmarshal(rawBytes, &parsed); err != nil { return nil, fmt.Errorf("openweather 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 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 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, WindDirectionDegrees: parsed.Wind.Deg, WindSpeedKmh: &wsKmh, WindGustKmh: wgKmh, BarometricPressurePa: &surfacePa, SeaLevelPressurePa: seaLevelPa, VisibilityMeters: visM, RelativeHumidityPercent: &rh, } return obs, ts, nil } func primaryCondition(list []owmWeather) (id int, desc string, icon string) { if len(list) == 0 { return 0, "", "" } w := list[0] return w.ID, strings.TrimSpace(w.Description), strings.TrimSpace(w.Icon) } func inferIsDay(icon string, dt, sunrise, sunset int64) *bool { // Prefer icon suffix. icon = strings.TrimSpace(icon) if icon != "" { last := icon[len(icon)-1] switch last { case 'd': v := true return &v case 'n': v := false return &v } } // Fall back to sunrise/sunset bounds if provided. if dt > 0 && sunrise > 0 && sunset > 0 { v := dt >= sunrise && dt < sunset return &v } return nil } func openWeatherIconURL(icon string) string { icon = strings.TrimSpace(icon) if icon == "" { return "" } return fmt.Sprintf("https://openweathermap.org/img/wn/%s@2x.png", icon) } func openWeatherStationID(parsed owmResponse) string { if parsed.ID != 0 { return fmt.Sprintf("OPENWEATHER(%d)", parsed.ID) } // Fallback: synthesize from coordinates. return fmt.Sprintf("OPENWEATHER(%.5f,%.5f)", parsed.Coord.Lat, parsed.Coord.Lon) }