Feature addition to support narrative forecast updates from the NWS
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -16,10 +16,11 @@ import (
|
||||
|
||||
// ForecastNormalizer converts:
|
||||
//
|
||||
// standards.SchemaRawNWSHourlyForecastV1 -> standards.SchemaWeatherForecastV1
|
||||
// standards.SchemaRawNWSHourlyForecastV1 -> standards.SchemaWeatherForecastV1
|
||||
// standards.SchemaRawNWSNarrativeForecastV1 -> standards.SchemaWeatherForecastV1
|
||||
//
|
||||
// It keeps one NWS forecast normalization entrypoint and dispatches to product-specific
|
||||
// builders by raw schema. Today only hourly is implemented.
|
||||
// builders by raw schema.
|
||||
//
|
||||
// Caveats / policy:
|
||||
// 1. NWS forecast periods do not include METAR presentWeather phenomena, so ConditionCode
|
||||
@@ -32,6 +33,8 @@ 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
|
||||
}
|
||||
@@ -47,6 +50,8 @@ 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))
|
||||
}
|
||||
@@ -61,6 +66,15 @@ func normalizeHourlyForecastEvent(in event.Event) (*event.Event, error) {
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -95,6 +109,40 @@ func buildHourlyForecast(parsed nwsHourlyForecastResponse) (model.WeatherForecas
|
||||
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 == "" {
|
||||
@@ -199,3 +247,47 @@ func mapHourlyForecastPeriod(idx int, p nwsHourlyForecastPeriod) (model.WeatherF
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user