package openmeteo 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" omcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards" ) // ForecastNormalizer converts: // // standards.SchemaRawOpenMeteoHourlyForecastV1 -> standards.SchemaWeatherForecastV1 // // It interprets Open-Meteo hourly forecast JSON and maps it into the canonical // model.WeatherForecastRun representation. // // Caveats / assumptions: // - Open-Meteo does not provide a true "issued at" timestamp; IssuedAt uses // Event.EmittedAt when present, otherwise the first hourly time. // - Hourly payloads are array-oriented; missing fields are treated as nil per-period. // - Snowfall is provided in centimeters and is converted to millimeters. // - apparent_temperature is mapped to ApparentTemperatureC when present. type ForecastNormalizer struct{} func (ForecastNormalizer) Match(e event.Event) bool { return strings.TrimSpace(e.Schema) == standards.SchemaRawOpenMeteoHourlyForecastV1 } func (ForecastNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) { _ = ctx // normalization is pure/CPU; keep ctx for future expensive steps // If present, prefer the existing event EmittedAt as IssuedAt. var fallbackIssued time.Time if !in.EmittedAt.IsZero() { fallbackIssued = in.EmittedAt.UTC() } return normcommon.NormalizeJSON( in, "openmeteo hourly forecast", standards.SchemaWeatherForecastV1, func(parsed omForecastResponse) (model.WeatherForecastRun, time.Time, error) { return buildForecast(parsed, fallbackIssued) }, ) } // buildForecast contains the domain mapping logic (provider -> canonical model). func buildForecast(parsed omForecastResponse, fallbackIssued time.Time) (model.WeatherForecastRun, time.Time, error) { times := parsed.Hourly.Time if len(times) == 0 { return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("missing hourly.time") } issuedAt := fallbackIssued.UTC() run := model.WeatherForecastRun{ LocationID: normcommon.SynthStationIDPtr("OPENMETEO", parsed.Latitude, parsed.Longitude), LocationName: "Open-Meteo", IssuedAt: time.Time{}, UpdatedAt: nil, Product: model.ForecastProductHourly, Latitude: floatCopy(parsed.Latitude), Longitude: floatCopy(parsed.Longitude), ElevationMeters: floatCopy(parsed.Elevation), Periods: nil, } periods := make([]model.WeatherForecastPeriod, 0, len(times)) var prevStart time.Time for i := range times { start, err := parseHourlyTime(parsed, i) if err != nil { return model.WeatherForecastRun{}, time.Time{}, err } if issuedAt.IsZero() && i == 0 { issuedAt = start } end, err := parsePeriodEnd(parsed, i, start, prevStart) if err != nil { return model.WeatherForecastRun{}, time.Time{}, err } prevStart = start var isDay *bool if v := intAt(parsed.Hourly.IsDay, i); v != nil { b := *v == 1 isDay = &b } wmo := wmoAt(parsed.Hourly.WeatherCode, i) canonicalText := standards.WMOText(wmo, isDay) period := model.WeatherForecastPeriod{ StartTime: start, EndTime: end, Name: "", IsDay: isDay, ConditionCode: wmo, ConditionText: canonicalText, ProviderRawDescription: "", TextDescription: canonicalText, DetailedText: "", IconURL: "", } if v := floatAt(parsed.Hourly.Temperature2m, i); v != nil { period.TemperatureC = v } if v := floatAt(parsed.Hourly.ApparentTemp, i); v != nil { period.ApparentTemperatureC = v } if v := floatAt(parsed.Hourly.DewPoint2m, i); v != nil { period.DewpointC = v } if v := floatAt(parsed.Hourly.RelativeHumidity2m, i); v != nil { period.RelativeHumidityPercent = v } if v := floatAt(parsed.Hourly.WindDirection10m, i); v != nil { period.WindDirectionDegrees = v } if v := floatAt(parsed.Hourly.WindSpeed10m, i); v != nil { period.WindSpeedKmh = v } if v := floatAt(parsed.Hourly.WindGusts10m, i); v != nil { period.WindGustKmh = v } if v := floatAt(parsed.Hourly.Visibility, i); v != nil { period.VisibilityMeters = v } if v := floatAt(parsed.Hourly.CloudCover, i); v != nil { period.CloudCoverPercent = v } if v := floatAt(parsed.Hourly.UVIndex, i); v != nil { period.UVIndex = v } if v := floatAt(parsed.Hourly.PrecipProbability, i); v != nil { period.ProbabilityOfPrecipitationPercent = v } if v := floatAt(parsed.Hourly.Precipitation, i); v != nil { period.PrecipitationAmountMm = v } if v := floatAt(parsed.Hourly.Snowfall, i); v != nil { mm := *v * 10.0 period.SnowfallDepthMM = &mm } if v := floatAt(parsed.Hourly.SurfacePressure, i); v != nil { pa := normcommon.PressurePaFromHPa(*v) period.BarometricPressurePa = &pa } else if v := floatAt(parsed.Hourly.PressureMSL, i); v != nil { pa := normcommon.PressurePaFromHPa(*v) period.BarometricPressurePa = &pa } periods = append(periods, period) } run.Periods = periods if issuedAt.IsZero() { return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("missing issuedAt") } run.IssuedAt = issuedAt // EffectiveAt policy for forecasts: treat IssuedAt as the effective time (dedupe-friendly). return run, issuedAt, nil } func parseHourlyTime(parsed omForecastResponse, idx int) (time.Time, error) { if idx < 0 || idx >= len(parsed.Hourly.Time) { return time.Time{}, fmt.Errorf("hourly.time[%d] missing", idx) } raw := strings.TrimSpace(parsed.Hourly.Time[idx]) if raw == "" { return time.Time{}, fmt.Errorf("hourly.time[%d] empty", idx) } t, err := omcommon.ParseTime(raw, parsed.Timezone, parsed.UTCOffsetSeconds) if err != nil { return time.Time{}, fmt.Errorf("hourly.time[%d] invalid %q: %w", idx, raw, err) } return t.UTC(), nil } func parsePeriodEnd(parsed omForecastResponse, idx int, start, prevStart time.Time) (time.Time, error) { if idx+1 < len(parsed.Hourly.Time) { return parseHourlyTime(parsed, idx+1) } step := time.Hour if !prevStart.IsZero() { if d := start.Sub(prevStart); d > 0 { step = d } } return start.Add(step), nil } func floatCopy(v *float64) *float64 { if v == nil { return nil } out := *v return &out } func floatAt(vals []*float64, idx int) *float64 { if idx < 0 || idx >= len(vals) { return nil } return floatCopy(vals[idx]) } func intAt(vals []*int, idx int) *int { if idx < 0 || idx >= len(vals) { return nil } if vals[idx] == nil { return nil } out := *vals[idx] return &out } func wmoAt(vals []*int, idx int) model.WMOCode { if v := intAt(vals, idx); v != nil { return model.WMOCode(*v) } return model.WMOUnknown }