Code cleanup and deduplication pass through weatherfeeder
This commit is contained in:
@@ -75,62 +75,63 @@ func normalizeNarrativeForecastEvent(in event.Event) (*event.Event, error) {
|
||||
)
|
||||
}
|
||||
|
||||
type forecastPeriodMapper[T any] func(idx int, period T) (model.WeatherForecastPeriod, error)
|
||||
|
||||
// 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,
|
||||
return buildForecastRun(
|
||||
parsed.Properties.GeneratedAt,
|
||||
parsed.Properties.UpdateTime,
|
||||
parsed.Geometry.Coordinates,
|
||||
parsed.Properties.Elevation.Value,
|
||||
model.ForecastProductHourly,
|
||||
parsed.Properties.Periods,
|
||||
mapHourlyForecastPeriod,
|
||||
)
|
||||
|
||||
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)
|
||||
return buildForecastRun(
|
||||
parsed.Properties.GeneratedAt,
|
||||
parsed.Properties.UpdateTime,
|
||||
parsed.Geometry.Coordinates,
|
||||
parsed.Properties.Elevation.Value,
|
||||
model.ForecastProductNarrative,
|
||||
parsed.Properties.Periods,
|
||||
mapNarrativeForecastPeriod,
|
||||
)
|
||||
}
|
||||
|
||||
func buildForecastRun[T any](
|
||||
generatedAt string,
|
||||
updateTime string,
|
||||
coordinates [][][]float64,
|
||||
elevation *float64,
|
||||
product model.ForecastProduct,
|
||||
srcPeriods []T,
|
||||
mapPeriod forecastPeriodMapper[T],
|
||||
) (model.WeatherForecastRun, time.Time, error) {
|
||||
issuedAt, updatedAt, err := parseForecastRunTimes(generatedAt, 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)
|
||||
lat, lon := centroidLatLon(coordinates)
|
||||
|
||||
run := newForecastRunBase(
|
||||
issuedAt,
|
||||
updatedAt,
|
||||
model.ForecastProductNarrative,
|
||||
product,
|
||||
lat,
|
||||
lon,
|
||||
parsed.Properties.Elevation.Value,
|
||||
elevation,
|
||||
)
|
||||
|
||||
periods := make([]model.WeatherForecastPeriod, 0, len(parsed.Properties.Periods))
|
||||
for i, p := range parsed.Properties.Periods {
|
||||
period, err := mapNarrativeForecastPeriod(i, p)
|
||||
periods := make([]model.WeatherForecastPeriod, 0, len(srcPeriods))
|
||||
for i, p := range srcPeriods {
|
||||
period, err := mapPeriod(i, p)
|
||||
if err != nil {
|
||||
return model.WeatherForecastRun{}, time.Time{}, err
|
||||
}
|
||||
|
||||
@@ -47,6 +47,55 @@ func TestBuildHourlyForecastUsesShortForecastAsTextDescription(t *testing.T) {
|
||||
assertNoLegacyForecastDescriptionKeys(t, run.Periods[0])
|
||||
}
|
||||
|
||||
func TestBuildHourlyForecastPreservesUpdatedAtCentroidAndElevation(t *testing.T) {
|
||||
parsed := nwsHourlyForecastResponse{}
|
||||
parsed.Properties.GeneratedAt = "2026-03-16T18:00:00Z"
|
||||
parsed.Properties.UpdateTime = "2026-03-16T18:30:00Z"
|
||||
elevation := 123.4
|
||||
parsed.Properties.Elevation.Value = &elevation
|
||||
parsed.Geometry.Coordinates = [][][]float64{
|
||||
{
|
||||
{-90.0, 38.0},
|
||||
{-89.0, 38.0},
|
||||
{-89.0, 39.0},
|
||||
{-90.0, 39.0},
|
||||
},
|
||||
}
|
||||
parsed.Properties.Periods = []nwsHourlyForecastPeriod{
|
||||
{
|
||||
StartTime: "2026-03-16T19:00:00Z",
|
||||
EndTime: "2026-03-16T20:00:00Z",
|
||||
ShortForecast: "Cloudy",
|
||||
},
|
||||
}
|
||||
|
||||
run, effectiveAt, err := buildHourlyForecast(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("buildHourlyForecast() error = %v", err)
|
||||
}
|
||||
|
||||
wantIssued := time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC)
|
||||
wantUpdated := time.Date(2026, 3, 16, 18, 30, 0, 0, time.UTC)
|
||||
if !run.IssuedAt.Equal(wantIssued) {
|
||||
t.Fatalf("IssuedAt = %s, want %s", run.IssuedAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
||||
}
|
||||
if run.UpdatedAt == nil || !run.UpdatedAt.Equal(wantUpdated) {
|
||||
t.Fatalf("UpdatedAt = %v, want %s", run.UpdatedAt, wantUpdated.Format(time.RFC3339))
|
||||
}
|
||||
if run.Latitude == nil || math.Abs(*run.Latitude-38.5) > 0.0001 {
|
||||
t.Fatalf("Latitude = %v, want 38.5", run.Latitude)
|
||||
}
|
||||
if run.Longitude == nil || math.Abs(*run.Longitude+89.5) > 0.0001 {
|
||||
t.Fatalf("Longitude = %v, want -89.5", run.Longitude)
|
||||
}
|
||||
if run.ElevationMeters == nil || math.Abs(*run.ElevationMeters-elevation) > 0.0001 {
|
||||
t.Fatalf("ElevationMeters = %v, want %.1f", run.ElevationMeters, elevation)
|
||||
}
|
||||
if !effectiveAt.Equal(wantIssued) {
|
||||
t.Fatalf("effectiveAt = %s, want %s", effectiveAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeForecastEventBySchemaRejectsUnsupportedSchema(t *testing.T) {
|
||||
_, err := normalizeForecastEventBySchema(event.Event{
|
||||
Schema: "raw.nws.daily.forecast.v1",
|
||||
@@ -85,6 +134,70 @@ func TestNormalizeForecastEventBySchemaRoutesNarrative(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeForecastEventBySchemaProducesCanonicalWeatherForecastSchema(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schema string
|
||||
payload map[string]any
|
||||
}{
|
||||
{
|
||||
name: "hourly",
|
||||
schema: standards.SchemaRawNWSHourlyForecastV1,
|
||||
payload: map[string]any{
|
||||
"properties": map[string]any{
|
||||
"generatedAt": "2026-03-16T18:00:00Z",
|
||||
"periods": []map[string]any{
|
||||
{
|
||||
"startTime": "2026-03-16T19:00:00Z",
|
||||
"endTime": "2026-03-16T20:00:00Z",
|
||||
"shortForecast": "Cloudy",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "narrative",
|
||||
schema: standards.SchemaRawNWSNarrativeForecastV1,
|
||||
payload: map[string]any{
|
||||
"properties": map[string]any{
|
||||
"generatedAt": "2026-03-16T18:00:00Z",
|
||||
"periods": []map[string]any{
|
||||
{
|
||||
"startTime": "2026-03-16T19:00:00Z",
|
||||
"endTime": "2026-03-16T20:00:00Z",
|
||||
"shortForecast": "Cloudy",
|
||||
"detailedForecast": "Cloudy",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out, err := normalizeForecastEventBySchema(event.Event{
|
||||
ID: "evt-1",
|
||||
Kind: event.Kind("forecast"),
|
||||
Source: "nws-test",
|
||||
EmittedAt: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
||||
Schema: tt.schema,
|
||||
Payload: tt.payload,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeForecastEventBySchema() error = %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatalf("normalizeForecastEventBySchema() returned nil output")
|
||||
}
|
||||
if out.Schema != standards.SchemaWeatherForecastV1 {
|
||||
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastV1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNarrativeForecastMapsExpectedFields(t *testing.T) {
|
||||
parsed := nwsNarrativeForecastResponse{}
|
||||
parsed.Properties.GeneratedAt = "2026-03-27T15:17:01Z"
|
||||
|
||||
@@ -5,18 +5,13 @@ import (
|
||||
fknormalize "gitea.maximumdirect.net/ejr/feedkit/processors/normalize"
|
||||
)
|
||||
|
||||
var builtins = []fknormalize.Normalizer{
|
||||
ObservationNormalizer{},
|
||||
ForecastNormalizer{},
|
||||
AlertsNormalizer{},
|
||||
}
|
||||
|
||||
// Register appends NWS normalizers in stable order.
|
||||
func Register(in []fknormalize.Normalizer) []fknormalize.Normalizer {
|
||||
out := in
|
||||
|
||||
// Observations
|
||||
out = append(out, ObservationNormalizer{})
|
||||
|
||||
// Forecasts
|
||||
out = append(out, ForecastNormalizer{})
|
||||
|
||||
// Alerts
|
||||
out = append(out, AlertsNormalizer{})
|
||||
|
||||
return out
|
||||
return append(in, builtins...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user