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
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package nws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/feedkit/event"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
||||
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
||||
)
|
||||
|
||||
@@ -70,6 +72,118 @@ func TestNormalizeForecastEventBySchemaRoutesHourly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeForecastEventBySchemaRoutesNarrative(t *testing.T) {
|
||||
_, err := normalizeForecastEventBySchema(event.Event{
|
||||
Schema: standards.SchemaRawNWSNarrativeForecastV1,
|
||||
Payload: map[string]any{"properties": map[string]any{}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("normalizeForecastEventBySchema() expected build error for missing generatedAt")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing properties.generatedAt") {
|
||||
t.Fatalf("error = %q, want missing properties.generatedAt", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNarrativeForecastMapsExpectedFields(t *testing.T) {
|
||||
parsed := nwsNarrativeForecastResponse{}
|
||||
parsed.Properties.GeneratedAt = "2026-03-27T15:17:01Z"
|
||||
isDay := true
|
||||
tempF := 53.0
|
||||
pop := 20.0
|
||||
|
||||
parsed.Properties.Periods = []nwsNarrativeForecastPeriod{
|
||||
{
|
||||
Name: "Today",
|
||||
StartTime: "2026-03-27T10:00:00-05:00",
|
||||
EndTime: "2026-03-27T18:00:00-05:00",
|
||||
IsDaytime: &isDay,
|
||||
Temperature: &tempF,
|
||||
TemperatureUnit: "F",
|
||||
WindSpeed: "10 to 14 mph",
|
||||
WindDirection: "SW",
|
||||
ShortForecast: "Partly Sunny",
|
||||
DetailedForecast: " Partly sunny, with a high near 53. ",
|
||||
ProbabilityOfPrecipitation: struct {
|
||||
UnitCode string `json:"unitCode"`
|
||||
Value *float64 `json:"value"`
|
||||
}{
|
||||
UnitCode: "wmoUnit:percent",
|
||||
Value: &pop,
|
||||
},
|
||||
Icon: "https://api.weather.gov/icons/land/day/bkn?size=medium",
|
||||
},
|
||||
}
|
||||
|
||||
run, effectiveAt, err := buildNarrativeForecast(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("buildNarrativeForecast() error = %v", err)
|
||||
}
|
||||
if got, want := run.Product, model.ForecastProductNarrative; got != want {
|
||||
t.Fatalf("Product = %q, want %q", got, want)
|
||||
}
|
||||
if len(run.Periods) != 1 {
|
||||
t.Fatalf("periods len = %d, want 1", len(run.Periods))
|
||||
}
|
||||
|
||||
p := run.Periods[0]
|
||||
if got, want := p.TextDescription, "Partly sunny, with a high near 53."; got != want {
|
||||
t.Fatalf("TextDescription = %q, want %q", got, want)
|
||||
}
|
||||
if p.TemperatureC == nil {
|
||||
t.Fatalf("TemperatureC is nil, want converted value")
|
||||
}
|
||||
if math.Abs(*p.TemperatureC-11.6666666667) > 0.0001 {
|
||||
t.Fatalf("TemperatureC = %.6f, want ~11.6667", *p.TemperatureC)
|
||||
}
|
||||
if p.IsDay == nil || !*p.IsDay {
|
||||
t.Fatalf("IsDay = %v, want true", p.IsDay)
|
||||
}
|
||||
if p.WindDirectionDegrees == nil || *p.WindDirectionDegrees != 225 {
|
||||
t.Fatalf("WindDirectionDegrees = %v, want 225", p.WindDirectionDegrees)
|
||||
}
|
||||
if p.WindSpeedKmh == nil || math.Abs(*p.WindSpeedKmh-19.3128) > 0.001 {
|
||||
t.Fatalf("WindSpeedKmh = %.6f, want ~19.3128", derefOrZero(p.WindSpeedKmh))
|
||||
}
|
||||
if p.ProbabilityOfPrecipitationPercent == nil || *p.ProbabilityOfPrecipitationPercent != 20 {
|
||||
t.Fatalf("ProbabilityOfPrecipitationPercent = %v, want 20", p.ProbabilityOfPrecipitationPercent)
|
||||
}
|
||||
|
||||
wantIssued := time.Date(2026, 3, 27, 15, 17, 1, 0, time.UTC)
|
||||
if !run.IssuedAt.Equal(wantIssued) {
|
||||
t.Fatalf("IssuedAt = %s, want %s", run.IssuedAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
||||
}
|
||||
if !effectiveAt.Equal(wantIssued) {
|
||||
t.Fatalf("effectiveAt = %s, want %s", effectiveAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
assertNoLegacyForecastDescriptionKeys(t, p)
|
||||
}
|
||||
|
||||
func TestBuildNarrativeForecastFallsBackToShortForecastDescription(t *testing.T) {
|
||||
parsed := nwsNarrativeForecastResponse{}
|
||||
parsed.Properties.GeneratedAt = "2026-03-27T15:17:01Z"
|
||||
parsed.Properties.Periods = []nwsNarrativeForecastPeriod{
|
||||
{
|
||||
StartTime: "2026-03-27T18:00:00-05:00",
|
||||
EndTime: "2026-03-28T06:00:00-05:00",
|
||||
ShortForecast: " Mostly Clear ",
|
||||
DetailedForecast: " ",
|
||||
},
|
||||
}
|
||||
|
||||
run, _, err := buildNarrativeForecast(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("buildNarrativeForecast() error = %v", err)
|
||||
}
|
||||
if len(run.Periods) != 1 {
|
||||
t.Fatalf("periods len = %d, want 1", len(run.Periods))
|
||||
}
|
||||
if got, want := run.Periods[0].TextDescription, "Mostly Clear"; got != want {
|
||||
t.Fatalf("TextDescription = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoLegacyForecastDescriptionKeys(t *testing.T, period any) {
|
||||
t.Helper()
|
||||
|
||||
@@ -88,3 +202,10 @@ func assertNoLegacyForecastDescriptionKeys(t *testing.T, period any) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func derefOrZero(v *float64) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
@@ -100,8 +100,6 @@ type nwsCloudLayer struct {
|
||||
|
||||
// nwsHourlyForecastResponse is a minimal-but-sufficient representation of the NWS
|
||||
// gridpoint hourly forecast GeoJSON payload needed for mapping into model.WeatherForecastRun.
|
||||
//
|
||||
// Daily and narrative variants should be added as distinct structs in follow-up work.
|
||||
type nwsHourlyForecastResponse struct {
|
||||
Geometry struct {
|
||||
Type string `json:"type"`
|
||||
@@ -160,6 +158,56 @@ type nwsHourlyForecastPeriod struct {
|
||||
DetailedForecast string `json:"detailedForecast"`
|
||||
}
|
||||
|
||||
// nwsNarrativeForecastResponse is a minimal-but-sufficient representation of the NWS
|
||||
// gridpoint narrative forecast GeoJSON payload needed for mapping into model.WeatherForecastRun.
|
||||
type nwsNarrativeForecastResponse struct {
|
||||
Geometry struct {
|
||||
Type string `json:"type"`
|
||||
Coordinates [][][]float64 `json:"coordinates"` // GeoJSON polygon: [ring][point][lon,lat]
|
||||
} `json:"geometry"`
|
||||
|
||||
Properties struct {
|
||||
Units string `json:"units"` // "us" or "si" (often "us" for narrative)
|
||||
ForecastGenerator string `json:"forecastGenerator"` // e.g. "BaselineForecastGenerator"
|
||||
|
||||
GeneratedAt string `json:"generatedAt"` // RFC3339-ish
|
||||
UpdateTime string `json:"updateTime"` // RFC3339-ish
|
||||
ValidTimes string `json:"validTimes"`
|
||||
|
||||
Elevation struct {
|
||||
UnitCode string `json:"unitCode"`
|
||||
Value *float64 `json:"value"`
|
||||
} `json:"elevation"`
|
||||
|
||||
Periods []nwsNarrativeForecastPeriod `json:"periods"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
|
||||
type nwsNarrativeForecastPeriod struct {
|
||||
Number int `json:"number"`
|
||||
Name string `json:"name"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
|
||||
IsDaytime *bool `json:"isDaytime"`
|
||||
|
||||
Temperature *float64 `json:"temperature"`
|
||||
TemperatureUnit string `json:"temperatureUnit"` // "F" or "C"
|
||||
TemperatureTrend any `json:"temperatureTrend"`
|
||||
|
||||
ProbabilityOfPrecipitation struct {
|
||||
UnitCode string `json:"unitCode"`
|
||||
Value *float64 `json:"value"`
|
||||
} `json:"probabilityOfPrecipitation"`
|
||||
|
||||
WindSpeed string `json:"windSpeed"` // e.g. "9 mph", "10 to 15 mph"
|
||||
WindDirection string `json:"windDirection"` // e.g. "W", "NW"
|
||||
|
||||
Icon string `json:"icon"`
|
||||
ShortForecast string `json:"shortForecast"`
|
||||
DetailedForecast string `json:"detailedForecast"`
|
||||
}
|
||||
|
||||
// nwsAlertsResponse is a minimal-but-sufficient representation of the NWS /alerts
|
||||
// FeatureCollection payload needed for mapping into model.WeatherAlertRun.
|
||||
type nwsAlertsResponse struct {
|
||||
|
||||
Reference in New Issue
Block a user