Emit location-filtered SPC outlook v2 runs

This commit is contained in:
2026-06-12 04:24:12 +00:00
parent 435d1ade07
commit 21a35a5205
2 changed files with 191 additions and 29 deletions

View File

@@ -29,10 +29,10 @@ var idTokenRE = regexp.MustCompile(`[^a-z0-9]+`)
// ConvectiveOutlookNormalizer converts:
//
// standards.SchemaRawSPCConvectiveOutlookV1 -> standards.SchemaWeatherOutlookV1
// standards.SchemaRawSPCConvectiveOutlookV1 -> standards.SchemaWeatherOutlookV2
//
// It maps SPC GeoJSON outlook features into canonical outlook polygons and
// enriches each day with the matching required print-page discussion.
// It maps SPC GeoJSON outlook features containing the configured location into
// canonical outlook polygons and adds matching day-level print-page discussions.
type ConvectiveOutlookNormalizer struct{}
func (ConvectiveOutlookNormalizer) Match(e event.Event) bool {
@@ -50,7 +50,7 @@ func (ConvectiveOutlookNormalizer) Normalize(ctx context.Context, in event.Event
return normcommon.NormalizeJSON(
in,
outlookNormalizer,
standards.SchemaWeatherOutlookV1,
standards.SchemaWeatherOutlookV2,
func(parsed spcprovider.RawConvectiveOutlookBundle) (model.WeatherOutlookRun, time.Time, error) {
return buildConvectiveOutlook(parsed, fallbackAsOf)
},
@@ -104,10 +104,18 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb
if latestIssue.IsZero() || outlook.IssuedAt.After(latestIssue) {
latestIssue = outlook.IssuedAt
}
if !outlook.ContainsLocation {
continue
}
outlooks = append(outlooks, outlook)
}
}
runDiscussions, err := buildOutlookDiscussions(outlooks, discussions)
if err != nil {
return model.WeatherOutlookRun{}, time.Time{}, err
}
asOf := latestIssue
if asOf.IsZero() {
asOf = latestDiscussionUpdated
@@ -132,6 +140,7 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb
AsOf: asOf.UTC(),
IssuedAt: issuedAt,
Outlooks: outlooks,
Discussions: runDiscussions,
}
return run, run.AsOf, nil
}
@@ -174,6 +183,43 @@ func parseDiscussions(pages []spcprovider.RawDiscussionPage) (map[int]parsedDisc
return out, latestUpdated, nil
}
func buildOutlookDiscussions(outlooks []model.WeatherOutlook, discussions map[int]parsedDiscussion) ([]model.WeatherOutlookDiscussion, error) {
daysWithOutlooks := map[int]bool{}
for _, outlook := range outlooks {
daysWithOutlooks[outlook.Day] = true
}
days := make([]int, 0, len(daysWithOutlooks))
for day := range daysWithOutlooks {
days = append(days, day)
}
sort.Ints(days)
out := make([]model.WeatherOutlookDiscussion, 0, len(days))
for _, day := range days {
disc, ok := discussions[day]
if !ok {
return nil, fmt.Errorf("discussion for retained day %d is required", day)
}
out = append(out, model.WeatherOutlookDiscussion{
Day: day,
Headline: disc.Headline,
Summary: disc.Summary,
Discussion: disc.Discussion,
UpdatedAt: utcTimePtr(disc.UpdatedAt),
})
}
return out, nil
}
func utcTimePtr(t *time.Time) *time.Time {
if t == nil {
return nil
}
tt := t.UTC()
return &tt
}
func orderedProducts(products []spcprovider.RawOutlookProduct) []spcprovider.RawOutlookProduct {
out := make([]spcprovider.RawOutlookProduct, len(products))
copy(out, products)

View File

@@ -27,8 +27,8 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
if out.Schema != standards.SchemaWeatherOutlookV1 {
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherOutlookV1)
if out.Schema != standards.SchemaWeatherOutlookV2 {
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherOutlookV2)
}
if out.Kind != event.Kind(standards.KindOutlook) {
t.Fatalf("Kind = %q, want outlook", out.Kind)
@@ -54,8 +54,23 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
if run.Latitude == nil || *run.Latitude != 38.5 || run.Longitude == nil || *run.Longitude != -90.5 {
t.Fatalf("coordinates = %v,%v", run.Latitude, run.Longitude)
}
if len(run.Outlooks) != 9 {
t.Fatalf("Outlooks length = %d, want 9", len(run.Outlooks))
if len(run.Outlooks) != 4 {
t.Fatalf("Outlooks length = %d, want 4", len(run.Outlooks))
}
assertAllOutlooksContainLocation(t, run.Outlooks)
assertDiscussionDays(t, run.Discussions, 1)
day1Discussion := run.Discussions[0]
if day1Discussion.Headline != "Day 1 Convective Outlook" {
t.Fatalf("day 1 Headline = %q", day1Discussion.Headline)
}
if !strings.Contains(day1Discussion.Summary, "central Plains") {
t.Fatalf("day 1 Summary = %q", day1Discussion.Summary)
}
if !strings.Contains(day1Discussion.Discussion, "...DISCUSSION...") {
t.Fatalf("day 1 Discussion missing product text: %q", day1Discussion.Discussion)
}
if !strings.HasPrefix(day1Discussion.Discussion, "SPC AC 111234") {
t.Fatalf("day 1 Discussion = %q, want SPC product code prefix", day1Discussion.Discussion)
}
got := run.Outlooks[0]
@@ -103,13 +118,39 @@ func TestConvectiveOutlookNormalizerAcceptsTypedSourcePayload(t *testing.T) {
t.Fatalf("Normalize() error = %v", err)
}
run := out.Payload.(model.WeatherOutlookRun)
if len(run.Outlooks) != 9 {
t.Fatalf("Outlooks length = %d, want 9", len(run.Outlooks))
if len(run.Outlooks) != 4 {
t.Fatalf("Outlooks length = %d, want 4", len(run.Outlooks))
}
assertAllOutlooksContainLocation(t, run.Outlooks)
assertDiscussionDays(t, run.Discussions, 1)
}
func TestConvectiveOutlookNormalizerEmitsEmptyLocalRunOutsidePolygons(t *testing.T) {
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0)))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
run := out.Payload.(model.WeatherOutlookRun)
if len(run.Outlooks) != 0 {
t.Fatalf("Outlooks length = %d, want 0", len(run.Outlooks))
}
if len(run.Discussions) != 0 {
t.Fatalf("Discussions length = %d, want 0", len(run.Discussions))
}
wantAsOf := time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC)
if !run.AsOf.Equal(wantAsOf) {
t.Fatalf("AsOf = %s, want latest product issue time %s", run.AsOf, wantAsOf)
}
if run.IssuedAt == nil || !run.IssuedAt.Equal(wantAsOf) {
t.Fatalf("IssuedAt = %v, want latest product issue time %s", run.IssuedAt, wantAsOf)
}
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(run.AsOf) {
t.Fatalf("EffectiveAt = %v, want run AsOf %s", out.EffectiveAt, run.AsOf)
}
}
func TestConvectiveOutlookNormalizerOrdersProductsByDayAndType(t *testing.T) {
bundle := spcBundle(t, 0, 0)
bundle := spcBundle(t, 38.5, -90.5)
for i, j := 0, len(bundle.Products)-1; i < j; i, j = i+1, j-1 {
bundle.Products[i], bundle.Products[j] = bundle.Products[j], bundle.Products[i]
}
@@ -139,11 +180,12 @@ func TestConvectiveOutlookNormalizerOrdersProductsByDayAndType(t *testing.T) {
}
func TestConvectiveOutlookNormalizerMapsProbabilisticOutlookTypes(t *testing.T) {
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0)))
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5)))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
run := out.Payload.(model.WeatherOutlookRun)
assertAllOutlooksContainLocation(t, run.Outlooks)
for _, outlookType := range []string{
spcprovider.OutlookTypeTornado,
spcprovider.OutlookTypeHail,
@@ -156,7 +198,7 @@ func TestConvectiveOutlookNormalizerMapsProbabilisticOutlookTypes(t *testing.T)
}
func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *testing.T) {
bundle := spcBundle(t, 0, 0)
bundle := spcBundle(t, 36, -99)
replaced := false
for i := range bundle.Products {
if bundle.Products[i].Day == 2 && bundle.Products[i].OutlookType == spcprovider.OutlookTypeTornado {
@@ -173,9 +215,11 @@ func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *t
t.Fatalf("Normalize() error = %v", err)
}
run := out.Payload.(model.WeatherOutlookRun)
if len(run.Outlooks) != 8 {
t.Fatalf("Outlooks length = %d, want 8", len(run.Outlooks))
if len(run.Outlooks) != 3 {
t.Fatalf("Outlooks length = %d, want 3", len(run.Outlooks))
}
assertAllOutlooksContainLocation(t, run.Outlooks)
assertDiscussionDays(t, run.Discussions, 2)
if got := findOutlook(run.Outlooks, 2, spcprovider.OutlookTypeTornado); got != nil {
t.Fatalf("day 2 tornado outlook = %+v, want nil placeholder skipped", *got)
}
@@ -188,19 +232,39 @@ func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *t
}
}
func TestConvectiveOutlookNormalizerContainsLocationFalseOutsidePolygon(t *testing.T) {
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0)))
func TestConvectiveOutlookNormalizerIncludesOnlyDayWithContainingPolygons(t *testing.T) {
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 36, -99)))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
run := out.Payload.(model.WeatherOutlookRun)
if run.Outlooks[0].ContainsLocation {
t.Fatalf("ContainsLocation = true, want false")
if len(run.Outlooks) == 0 {
t.Fatalf("Outlooks length = 0, want retained day 2 outlooks")
}
assertAllOutlooksContainLocation(t, run.Outlooks)
for i, outlook := range run.Outlooks {
if outlook.Day != 2 {
t.Fatalf("outlook[%d].Day = %d, want 2", i, outlook.Day)
}
}
assertDiscussionDays(t, run.Discussions, 2)
}
func TestConvectiveOutlookNormalizerUsesOneDiscussionForMultipleSameDayOutlooks(t *testing.T) {
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5)))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
run := out.Payload.(model.WeatherOutlookRun)
if got := countOutlooksByDay(run.Outlooks, 1); got < 2 {
t.Fatalf("day 1 outlook count = %d, want multiple", got)
}
assertAllOutlooksContainLocation(t, run.Outlooks)
assertDiscussionDays(t, run.Discussions, 1)
}
func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) {
bundle := spcBundle(t, 0, 0)
bundle := spcBundle(t, 36, -99)
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle))
if err != nil {
t.Fatalf("Normalize() error = %v", err)
@@ -210,15 +274,12 @@ func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) {
if got == nil {
t.Fatalf("missing day 2 tornado outlook")
}
discussions, _, err := parseDiscussions(bundle.Discussions)
if err != nil {
t.Fatalf("parseDiscussions() error = %v", err)
assertDiscussionDays(t, run.Discussions, 2)
if !strings.Contains(run.Discussions[0].Headline, "CORR 1") {
t.Fatalf("day 2 headline = %q, want correction marker", run.Discussions[0].Headline)
}
if !strings.Contains(discussions[2].Headline, "CORR 1") {
t.Fatalf("day 2 headline = %q, want correction marker", discussions[2].Headline)
}
if !strings.Contains(discussions[2].Discussion, "CORR 1") {
t.Fatalf("day 2 discussion = %q, want correction marker", discussions[2].Discussion)
if !strings.Contains(run.Discussions[0].Discussion, "CORR 1") {
t.Fatalf("day 2 discussion = %q, want correction marker", run.Discussions[0].Discussion)
}
}
@@ -284,6 +345,19 @@ func TestConvectiveOutlookNormalizerRejectsMissingLabel(t *testing.T) {
}
}
func TestConvectiveOutlookNormalizerRejectsMissingDiscussion(t *testing.T) {
bundle := spcBundle(t, 38.5, -90.5)
bundle.Discussions = bundle.Discussions[1:]
_, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle))
if err == nil {
t.Fatalf("Normalize() error = nil, want error")
}
if !strings.Contains(err.Error(), "product day1_categorical: discussion for day 1 is required") {
t.Fatalf("error = %q, want missing discussion context", err)
}
}
func TestConvectiveOutlookNormalizerOutputJSONShape(t *testing.T) {
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 38.5, -90.5)))
if err != nil {
@@ -294,11 +368,22 @@ func TestConvectiveOutlookNormalizerOutputJSONShape(t *testing.T) {
t.Fatalf("Marshal(payload) error = %v", err)
}
got := string(raw)
for _, want := range []string{`"asOf"`, `"outlooks"`, `"containsLocation"`, `"geometry"`} {
for _, want := range []string{`"asOf"`, `"outlooks"`, `"discussions"`, `"containsLocation"`, `"geometry"`} {
if !strings.Contains(got, want) {
t.Fatalf("payload JSON missing %s: %s", want, got)
}
}
outlookStart := strings.Index(got, `"outlooks"`)
discussionStart := strings.Index(got, `"discussions"`)
if outlookStart == -1 || discussionStart == -1 || discussionStart <= outlookStart {
t.Fatalf("payload JSON has unexpected outlook/discussion order: %s", got)
}
outlookJSON := got[outlookStart:discussionStart]
for _, unwanted := range []string{`"headline"`, `"summary"`, `"discussion"`} {
if strings.Contains(outlookJSON, unwanted) {
t.Fatalf("outlook JSON exposed polygon-level prose key %s: %s", unwanted, got)
}
}
for _, unwanted := range []string{`"products"`, `"fetchedAt"`, `"body"`} {
if strings.Contains(got, unwanted) {
t.Fatalf("payload JSON exposed raw key %s: %s", unwanted, got)
@@ -401,6 +486,37 @@ func findOutlook(outlooks []model.WeatherOutlook, day int, outlookType string) *
return nil
}
func countOutlooksByDay(outlooks []model.WeatherOutlook, day int) int {
count := 0
for _, outlook := range outlooks {
if outlook.Day == day {
count++
}
}
return count
}
func assertAllOutlooksContainLocation(t *testing.T, outlooks []model.WeatherOutlook) {
t.Helper()
for i, outlook := range outlooks {
if !outlook.ContainsLocation {
t.Fatalf("outlook[%d].ContainsLocation = false, want true", i)
}
}
}
func assertDiscussionDays(t *testing.T, discussions []model.WeatherOutlookDiscussion, want ...int) {
t.Helper()
if len(discussions) != len(want) {
t.Fatalf("Discussions length = %d, want %d", len(discussions), len(want))
}
for i, day := range want {
if discussions[i].Day != day {
t.Fatalf("Discussions[%d].Day = %d, want %d", i, discussions[i].Day, day)
}
}
}
func assertTime(t *testing.T, name string, got time.Time, year int, month time.Month, day int, hour int, minute int, second int) {
t.Helper()
want := time.Date(year, month, day, hour, minute, second, 0, time.UTC)