Fix a bug in the GeoJSON handling when there are no active polygons
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:
@@ -87,6 +87,17 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, feature := range collection.Features {
|
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)
|
outlook, err := mapFeature(product, feature, i, point, discussion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.WeatherOutlookRun{}, time.Time{}, err
|
return model.WeatherOutlookRun{}, time.Time{}, err
|
||||||
|
|||||||
@@ -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) {
|
func TestConvectiveOutlookNormalizerContainsLocationFalseOutsidePolygon(t *testing.T) {
|
||||||
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0)))
|
out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0)))
|
||||||
if err != nil {
|
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 {
|
func spcRawEvent(t *testing.T, bundle spcprovider.RawConvectiveOutlookBundle) event.Event {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
raw, err := json.Marshal(bundle)
|
raw, err := json.Marshal(bundle)
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ type GeoJSONProperties struct {
|
|||||||
DN *int `json:"DN"`
|
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
|
// DecodeGeoJSON decodes an SPC GeoJSON outlook product and compacts feature
|
||||||
// geometry JSON for stable downstream storage.
|
// geometry JSON for stable downstream storage.
|
||||||
func DecodeGeoJSON(raw []byte) (GeoJSONFeatureCollection, error) {
|
func DecodeGeoJSON(raw []byte) (GeoJSONFeatureCollection, error) {
|
||||||
@@ -50,6 +55,16 @@ func DecodeGeoJSON(raw []byte) (GeoJSONFeatureCollection, error) {
|
|||||||
return collection, nil
|
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 {
|
func (p *GeoJSONProperties) UnmarshalJSON(raw []byte) error {
|
||||||
type alias GeoJSONProperties
|
type alias GeoJSONProperties
|
||||||
var aux struct {
|
var aux struct {
|
||||||
|
|||||||
@@ -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) {
|
func TestParseISOTimestampTrimsAndReturnsUTC(t *testing.T) {
|
||||||
got, err := ParseISOTimestamp(" 2026-06-11T12:34:56Z ")
|
got, err := ParseISOTimestamp(" 2026-06-11T12:34:56Z ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user