diff --git a/internal/normalizers/spc/convective_outlook.go b/internal/normalizers/spc/convective_outlook.go index f858935..7219917 100644 --- a/internal/normalizers/spc/convective_outlook.go +++ b/internal/normalizers/spc/convective_outlook.go @@ -87,6 +87,17 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb } for i, feature := range collection.Features { + if spcprovider.IsEmptyGeometryCollection(feature.Geometry) { + issuedAt, err := parseRequiredSPCTime(feature.Properties.IssueISO, fmt.Sprintf("product %s feature %d.ISSUE_ISO", product.Key, i)) + if err != nil { + return model.WeatherOutlookRun{}, time.Time{}, err + } + if latestIssue.IsZero() || issuedAt.After(latestIssue) { + latestIssue = issuedAt + } + continue + } + outlook, err := mapFeature(product, feature, i, point, discussion) if err != nil { return model.WeatherOutlookRun{}, time.Time{}, err diff --git a/internal/normalizers/spc/convective_outlook_test.go b/internal/normalizers/spc/convective_outlook_test.go index 2d98b54..e8877ba 100644 --- a/internal/normalizers/spc/convective_outlook_test.go +++ b/internal/normalizers/spc/convective_outlook_test.go @@ -167,6 +167,39 @@ func TestConvectiveOutlookNormalizerMapsProbabilisticOutlookTypes(t *testing.T) } } +func TestConvectiveOutlookNormalizerSkipsEmptyGeometryCollectionPlaceholder(t *testing.T) { + bundle := spcBundle(t, 0, 0) + replaced := false + for i := range bundle.Products { + if bundle.Products[i].Day == 2 && bundle.Products[i].OutlookType == spcprovider.OutlookTypeTornado { + bundle.Products[i].Body = json.RawMessage(emptyGeometryCollectionGeoJSON()) + replaced = true + } + } + if !replaced { + t.Fatalf("test setup did not find day 2 tornado product") + } + + out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle)) + if err != nil { + 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 got := findOutlook(run.Outlooks, 2, spcprovider.OutlookTypeTornado); got != nil { + t.Fatalf("day 2 tornado outlook = %+v, want nil placeholder skipped", *got) + } + wantAsOf := time.Date(2026, 6, 12, 10, 0, 0, 0, time.UTC) + if !run.AsOf.Equal(wantAsOf) { + t.Fatalf("AsOf = %s, want placeholder ISSUE_ISO %s", run.AsOf, wantAsOf) + } + if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantAsOf) { + t.Fatalf("EffectiveAt = %v, want placeholder ISSUE_ISO %s", out.EffectiveAt, wantAsOf) + } +} + func TestConvectiveOutlookNormalizerContainsLocationFalseOutsidePolygon(t *testing.T) { out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0))) if err != nil { @@ -280,6 +313,30 @@ func TestConvectiveOutlookNormalizerOutputJSONShape(t *testing.T) { } } +func emptyGeometryCollectionGeoJSON() []byte { + return []byte(`{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "VALID_ISO": "2026-06-12T12:00:00Z", + "EXPIRE_ISO": "2026-06-13T12:00:00Z", + "ISSUE_ISO": "2026-06-12T10:00:00Z", + "FORECASTER": "DOE", + "LABEL": "Less Than 2% All Areas", + "LABEL2": "", + "DN": 0 + }, + "geometry": { + "type": "GeometryCollection", + "geometries": [] + } + } + ] +}`) +} + func spcRawEvent(t *testing.T, bundle spcprovider.RawConvectiveOutlookBundle) event.Event { t.Helper() raw, err := json.Marshal(bundle) diff --git a/internal/providers/spc/geojson.go b/internal/providers/spc/geojson.go index 0d4864c..911527d 100644 --- a/internal/providers/spc/geojson.go +++ b/internal/providers/spc/geojson.go @@ -33,6 +33,11 @@ type GeoJSONProperties struct { DN *int `json:"DN"` } +type geometryMetadata struct { + Type string `json:"type"` + Geometries []json.RawMessage `json:"geometries"` +} + // DecodeGeoJSON decodes an SPC GeoJSON outlook product and compacts feature // geometry JSON for stable downstream storage. func DecodeGeoJSON(raw []byte) (GeoJSONFeatureCollection, error) { @@ -50,6 +55,16 @@ func DecodeGeoJSON(raw []byte) (GeoJSONFeatureCollection, error) { return collection, nil } +// IsEmptyGeometryCollection reports whether raw is SPC's no-polygon placeholder +// geometry shape: a GeometryCollection with no child geometries. +func IsEmptyGeometryCollection(raw json.RawMessage) bool { + var meta geometryMetadata + if err := json.Unmarshal(raw, &meta); err != nil { + return false + } + return meta.Type == "GeometryCollection" && len(meta.Geometries) == 0 +} + func (p *GeoJSONProperties) UnmarshalJSON(raw []byte) error { type alias GeoJSONProperties var aux struct { diff --git a/internal/providers/spc/geojson_test.go b/internal/providers/spc/geojson_test.go index 455b53c..c182004 100644 --- a/internal/providers/spc/geojson_test.go +++ b/internal/providers/spc/geojson_test.go @@ -66,6 +66,43 @@ func TestDecodeGeoJSONParsesSeverityRankString(t *testing.T) { } } +func TestIsEmptyGeometryCollection(t *testing.T) { + tests := []struct { + name string + raw string + want bool + }{ + { + name: "empty geometry collection", + raw: `{"type":"GeometryCollection","geometries":[]}`, + want: true, + }, + { + name: "non-empty geometry collection", + raw: `{"type":"GeometryCollection","geometries":[{"type":"Polygon","coordinates":[]} ]}`, + want: false, + }, + { + name: "polygon", + raw: `{"type":"Polygon","coordinates":[]}`, + want: false, + }, + { + name: "invalid json", + raw: `{`, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsEmptyGeometryCollection([]byte(tt.raw)); got != tt.want { + t.Fatalf("IsEmptyGeometryCollection() = %v, want %v", got, tt.want) + } + }) + } +} + func TestParseISOTimestampTrimsAndReturnsUTC(t *testing.T) { got, err := ParseISOTimestamp(" 2026-06-11T12:34:56Z ") if err != nil {