// FILE: internal/normalizers/nws/forecast.go package nws import ( "context" "fmt" "strings" "time" "gitea.maximumdirect.net/ejr/feedkit/event" normcommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/normalizers/common" nwscommon "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/nws" "gitea.maximumdirect.net/ejr/weatherfeeder/model" "gitea.maximumdirect.net/ejr/weatherfeeder/standards" ) // ForecastNormalizer converts: // // standards.SchemaRawNWSHourlyForecastV1 -> standards.SchemaWeatherForecastV1 // standards.SchemaRawNWSNarrativeForecastV1 -> standards.SchemaWeatherForecastV1 // // It keeps one NWS forecast normalization entrypoint and dispatches to product-specific // builders by raw schema. // // 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 { switch strings.TrimSpace(e.Schema) { case standards.SchemaRawNWSHourlyForecastV1: return true case standards.SchemaRawNWSNarrativeForecastV1: return true default: return false } } func (ForecastNormalizer) Normalize(ctx context.Context, in event.Event) (*event.Event, error) { _ = ctx // normalization is pure/CPU; keep ctx for future expensive steps return normalizeForecastEventBySchema(in) } func normalizeForecastEventBySchema(in event.Event) (*event.Event, error) { switch strings.TrimSpace(in.Schema) { case standards.SchemaRawNWSHourlyForecastV1: return normalizeHourlyForecastEvent(in) case standards.SchemaRawNWSNarrativeForecastV1: return normalizeNarrativeForecastEvent(in) default: return nil, fmt.Errorf("unsupported nws forecast schema %q", strings.TrimSpace(in.Schema)) } } func normalizeHourlyForecastEvent(in event.Event) (*event.Event, error) { return normcommon.NormalizeJSON( in, "nws hourly forecast", standards.SchemaWeatherForecastV1, buildHourlyForecast, ) } func normalizeNarrativeForecastEvent(in event.Event) (*event.Event, error) { return normcommon.NormalizeJSON( in, "nws narrative forecast", standards.SchemaWeatherForecastV1, buildNarrativeForecast, ) } // buildHourlyForecast contains hourly forecast mapping logic (provider -> canonical model). func buildHourlyForecast(parsed nwsHourlyForecastResponse) (model.WeatherForecastRun, time.Time, error) { issuedAt, updatedAt, err := parseForecastRunTimes(parsed.Properties.GeneratedAt, parsed.Properties.UpdateTime) if err != nil { return model.WeatherForecastRun{}, time.Time{}, err } // Best-effort location centroid from the GeoJSON polygon (optional). lat, lon := centroidLatLon(parsed.Geometry.Coordinates) run := newForecastRunBase( issuedAt, updatedAt, model.ForecastProductHourly, lat, lon, parsed.Properties.Elevation.Value, ) periods := make([]model.WeatherForecastPeriod, 0, len(parsed.Properties.Periods)) for i, p := range parsed.Properties.Periods { period, err := mapHourlyForecastPeriod(i, p) if err != nil { return model.WeatherForecastRun{}, time.Time{}, err } periods = append(periods, period) } run.Periods = periods // EffectiveAt policy for forecasts: treat IssuedAt as the effective time (dedupe-friendly). return run, issuedAt, nil } // buildNarrativeForecast contains narrative forecast mapping logic (provider -> canonical model). func buildNarrativeForecast(parsed nwsNarrativeForecastResponse) (model.WeatherForecastRun, time.Time, error) { issuedAt, updatedAt, err := parseForecastRunTimes(parsed.Properties.GeneratedAt, parsed.Properties.UpdateTime) if err != nil { return model.WeatherForecastRun{}, time.Time{}, err } // Best-effort location centroid from the GeoJSON polygon (optional). lat, lon := centroidLatLon(parsed.Geometry.Coordinates) run := newForecastRunBase( issuedAt, updatedAt, model.ForecastProductNarrative, lat, lon, parsed.Properties.Elevation.Value, ) periods := make([]model.WeatherForecastPeriod, 0, len(parsed.Properties.Periods)) for i, p := range parsed.Properties.Periods { period, err := mapNarrativeForecastPeriod(i, p) if err != nil { return model.WeatherForecastRun{}, time.Time{}, err } periods = append(periods, period) } run.Periods = periods // EffectiveAt policy for forecasts: treat IssuedAt as the effective time (dedupe-friendly). return run, issuedAt, nil } func parseForecastRunTimes(generatedAt, updateTime string) (time.Time, *time.Time, error) { issuedStr := strings.TrimSpace(generatedAt) if issuedStr == "" { return time.Time{}, nil, fmt.Errorf("missing properties.generatedAt") } issuedAt, err := nwscommon.ParseTime(issuedStr) if err != nil { return time.Time{}, nil, fmt.Errorf("invalid properties.generatedAt %q: %w", issuedStr, err) } issuedAt = issuedAt.UTC() var updatedAt *time.Time if s := strings.TrimSpace(updateTime); s != "" { if t, err := nwscommon.ParseTime(s); err == nil { tt := t.UTC() updatedAt = &tt } } return issuedAt, updatedAt, nil } func newForecastRunBase( issuedAt time.Time, updatedAt *time.Time, product model.ForecastProduct, lat, lon, elevation *float64, ) model.WeatherForecastRun { return model.WeatherForecastRun{ LocationID: "", LocationName: "", IssuedAt: issuedAt, UpdatedAt: updatedAt, Product: product, Latitude: lat, Longitude: lon, ElevationMeters: elevation, Periods: nil, } } func parseForecastPeriodWindow(startStr, endStr string, idx int) (time.Time, time.Time, error) { startStr = strings.TrimSpace(startStr) endStr = strings.TrimSpace(endStr) if startStr == "" || endStr == "" { return time.Time{}, time.Time{}, fmt.Errorf("periods[%d]: missing startTime/endTime", idx) } start, err := nwscommon.ParseTime(startStr) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("periods[%d].startTime invalid %q: %w", idx, startStr, err) } end, err := nwscommon.ParseTime(endStr) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("periods[%d].endTime invalid %q: %w", idx, endStr, err) } return start.UTC(), end.UTC(), nil } func mapHourlyForecastPeriod(idx int, p nwsHourlyForecastPeriod) (model.WeatherForecastPeriod, error) { start, end, err := parseForecastPeriodWindow(p.StartTime, p.EndTime, idx) if err != nil { return model.WeatherForecastPeriod{}, err } // 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) return model.WeatherForecastPeriod{ StartTime: start, EndTime: end, Name: strings.TrimSpace(p.Name), IsDay: isDay, ConditionCode: wmo, // For forecasts, keep provider short forecast text as the human-facing description. TextDescription: providerDesc, TemperatureC: tempC, DewpointC: p.Dewpoint.Value, RelativeHumidityPercent: p.RelativeHumidity.Value, WindDirectionDegrees: parseNWSWindDirectionDegrees(p.WindDirection), WindSpeedKmh: parseNWSWindSpeedKmh(p.WindSpeed), ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitation.Value, }, nil } func mapNarrativeForecastPeriod(idx int, p nwsNarrativeForecastPeriod) (model.WeatherForecastPeriod, error) { start, end, err := parseForecastPeriodWindow(p.StartTime, p.EndTime, idx) if err != nil { return model.WeatherForecastPeriod{}, err } // NWS narrative 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). shortForecast := strings.TrimSpace(p.ShortForecast) wmo := wmoFromNWSForecast(shortForecast, p.Icon, tempC) textDescription := strings.TrimSpace(p.DetailedForecast) if textDescription == "" { textDescription = shortForecast } return model.WeatherForecastPeriod{ StartTime: start, EndTime: end, Name: strings.TrimSpace(p.Name), IsDay: isDay, ConditionCode: wmo, TextDescription: textDescription, TemperatureC: tempC, WindDirectionDegrees: parseNWSWindDirectionDegrees(p.WindDirection), WindSpeedKmh: parseNWSWindSpeedKmh(p.WindSpeed), ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitation.Value, }, nil }