// FILE: internal/normalizers/nws/forecast.go package nws 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" nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards" ) // ForecastNormalizer converts: // // standards.SchemaRawNWSHourlyForecastV1 -> standards.SchemaWeatherForecastV1 // // It interprets NWS GeoJSON gridpoint *hourly* forecast responses and maps them into // the canonical model.WeatherForecastRun representation. // // Caveats / policy: // 1. NWS forecast periods do not include METAR presentWeather phenomena, so ConditionCode // is inferred from period.shortForecast (with a conservative icon-based fallback). // 2. Temperature is converted to °C when NWS supplies °F. // 3. WindSpeed is parsed from strings like "9 mph" / "10 to 15 mph" and converted to km/h. type ForecastNormalizer struct{} func (ForecastNormalizer) Match(e event.Event) bool { s := strings.TrimSpace(e.Schema) return s == standards.SchemaRawNWSHourlyForecastV1 } func (ForecastNormalizer) 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, "nws hourly forecast", standards.SchemaWeatherForecastV1, buildForecast, ) } // buildForecast contains the domain mapping logic (provider -> canonical model). func buildForecast(parsed nwsForecastResponse) (model.WeatherForecastRun, time.Time, error) { // IssuedAt is required by the canonical model. issuedStr := strings.TrimSpace(parsed.Properties.GeneratedAt) if issuedStr == "" { return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("missing properties.generatedAt") } issuedAt, err := nwscommon.ParseTime(issuedStr) if err != nil { return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("invalid properties.generatedAt %q: %w", issuedStr, err) } issuedAt = issuedAt.UTC() // UpdatedAt is optional. var updatedAt *time.Time if s := strings.TrimSpace(parsed.Properties.UpdateTime); s != "" { if t, err := nwscommon.ParseTime(s); err == nil { tt := t.UTC() updatedAt = &tt } } // Best-effort location centroid from the GeoJSON polygon (optional). lat, lon := centroidLatLon(parsed.Geometry.Coordinates) // Schema is explicitly hourly, so product is not a heuristic. run := model.WeatherForecastRun{ LocationID: "", LocationName: "", IssuedAt: issuedAt, UpdatedAt: updatedAt, Product: model.ForecastProductHourly, Latitude: lat, Longitude: lon, ElevationMeters: parsed.Properties.Elevation.Value, Periods: nil, } periods := make([]model.WeatherForecastPeriod, 0, len(parsed.Properties.Periods)) for i, p := range parsed.Properties.Periods { startStr := strings.TrimSpace(p.StartTime) endStr := strings.TrimSpace(p.EndTime) if startStr == "" || endStr == "" { return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("periods[%d]: missing startTime/endTime", i) } start, err := nwscommon.ParseTime(startStr) if err != nil { return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("periods[%d].startTime invalid %q: %w", i, startStr, err) } end, err := nwscommon.ParseTime(endStr) if err != nil { return model.WeatherForecastRun{}, time.Time{}, fmt.Errorf("periods[%d].endTime invalid %q: %w", i, endStr, err) } start = start.UTC() end = end.UTC() // NWS hourly supplies isDaytime; make it a pointer to match the canonical model. var isDay *bool if p.IsDaytime != nil { b := *p.IsDaytime isDay = &b } tempC := tempCFromNWS(p.Temperature, p.TemperatureUnit) // Infer WMO from shortForecast (and fall back to icon token). providerDesc := strings.TrimSpace(p.ShortForecast) wmo := wmoFromNWSForecast(providerDesc, p.Icon, tempC) canonicalText := standards.WMOText(wmo, isDay) period := model.WeatherForecastPeriod{ StartTime: start, EndTime: end, Name: strings.TrimSpace(p.Name), IsDay: isDay, ConditionCode: wmo, ConditionText: canonicalText, ProviderRawDescription: providerDesc, // For forecasts, keep provider text as the human-facing description. TextDescription: strings.TrimSpace(p.ShortForecast), DetailedText: strings.TrimSpace(p.DetailedForecast), IconURL: strings.TrimSpace(p.Icon), TemperatureC: tempC, DewpointC: p.Dewpoint.Value, RelativeHumidityPercent: p.RelativeHumidity.Value, WindDirectionDegrees: parseNWSWindDirectionDegrees(p.WindDirection), WindSpeedKmh: parseNWSWindSpeedKmh(p.WindSpeed), ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitation.Value, } periods = append(periods, period) } run.Periods = periods // EffectiveAt policy for forecasts: treat IssuedAt as the effective time (dedupe-friendly). return run, issuedAt, nil }