// FILE: ./internal/normalizers/openmeteo/observation.go package openmeteo import ( "context" "fmt" "strings" "time" "gitea.maximumdirect.net/ejr/feedkit/event" normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common" omcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo" "gitea.maximumdirect.net/ejr/weatherfeeder/model" "gitea.maximumdirect.net/ejr/weatherfeeder/standards" ) // ObservationNormalizer converts: // // standards.SchemaRawOpenMeteoCurrentV1 -> standards.SchemaWeatherObservationV1 // // It interprets Open-Meteo "current weather" JSON and maps it into the canonical // model.WeatherObservation representation. // // Caveats / assumptions: // // - Open-Meteo is not a station feed; StationID is synthesized from lat/lon. // - Open-Meteo provides WMO weather_code directly; we treat it as authoritative. // - Day/night handling uses Open-Meteo's is_day flag when present. // - Pressure fields are typically hPa; we convert to Pa to match our model. // // Timestamp handling: // // - Open-Meteo "current.time" is often "YYYY-MM-DDTHH:MM" with timezone specified // separately; we interpret it using the returned timezone / utc_offset_seconds. // - If parsing fails, Timestamp may be zero and EffectiveAt will be omitted. type ObservationNormalizer struct{} func (ObservationNormalizer) Match(e event.Event) bool { return strings.TrimSpace(e.Schema) == standards.SchemaRawOpenMeteoCurrentV1 } 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, "openmeteo observation", standards.SchemaWeatherObservationV1, buildObservation, ) } // buildObservation contains the domain mapping logic (provider -> canonical model). func buildObservation(parsed omResponse) (model.WeatherObservation, time.Time, error) { // Parse current.time. var ts time.Time if s := strings.TrimSpace(parsed.Current.Time); s != "" { t, err := omcommon.ParseTime(s, parsed.Timezone, parsed.UTCOffsetSeconds) if err != nil { return model.WeatherObservation{}, time.Time{}, fmt.Errorf("parse time %q: %w", s, err) } ts = t.UTC() } // Day/night: optional. var isDay *bool if parsed.Current.IsDay != nil { v := *parsed.Current.IsDay == 1 isDay = &v } // WMO weather code: optional. wmo := model.WMOUnknown if parsed.Current.WeatherCode != nil { wmo = model.WMOCode(*parsed.Current.WeatherCode) } canonicalText := standards.WMOText(wmo, isDay) // Station identity: Open-Meteo is not a station feed; synthesize from coordinates. // Require BOTH lat/lon to avoid misleading OPENMETEO(0.00000,...) IDs. stationID := normcommon.SynthStationIDPtr("OPENMETEO", parsed.Latitude, parsed.Longitude) obs := model.WeatherObservation{ StationID: stationID, StationName: "Open-Meteo", Timestamp: ts, ConditionCode: wmo, ConditionText: canonicalText, IsDay: isDay, // Open-Meteo does not provide a separate human text description for "current" // when using weather_code; we leave provider evidence empty. ProviderRawDescription: "", // Transitional / human-facing: // keep output consistent by populating TextDescription from canonical text. TextDescription: canonicalText, // IconURL: Open-Meteo does not provide an icon URL in this endpoint. IconURL: "", } // Measurements (all optional; only set when present). if parsed.Current.Temperature2m != nil { v := *parsed.Current.Temperature2m obs.TemperatureC = &v } if parsed.Current.ApparentTemperature != nil { v := *parsed.Current.ApparentTemperature obs.ApparentTemperatureC = &v } if parsed.Current.RelativeHumidity2m != nil { v := *parsed.Current.RelativeHumidity2m obs.RelativeHumidityPercent = &v } if parsed.Current.WindDirection10m != nil { v := *parsed.Current.WindDirection10m obs.WindDirectionDegrees = &v } if parsed.Current.WindSpeed10m != nil { v := *parsed.Current.WindSpeed10m // Open-Meteo returns km/h for wind_speed_10m obs.WindSpeedKmh = &v } if parsed.Current.WindGusts10m != nil { v := *parsed.Current.WindGusts10m // Open-Meteo returns km/h for wind_gusts_10m obs.WindGustKmh = &v } if parsed.Current.SurfacePressure != nil { v := normcommon.PressurePaFromHPa(*parsed.Current.SurfacePressure) obs.BarometricPressurePa = &v } if parsed.Current.PressureMSL != nil { v := normcommon.PressurePaFromHPa(*parsed.Current.PressureMSL) obs.SeaLevelPressurePa = &v } if parsed.Elevation != nil { v := *parsed.Elevation obs.ElevationMeters = &v } return obs, ts, nil }