diff --git a/docs/consumers/pkg-model.md b/docs/consumers/pkg-model.md index 6d3ccc7..c07a6ea 100644 --- a/docs/consumers/pkg-model.md +++ b/docs/consumers/pkg-model.md @@ -38,6 +38,7 @@ Related child types include: - `WeatherAlert` - `WeatherAlertReference` - `WeatherOutlook` +- `WeatherOutlookDiscussion` - `WMOCode` ## Wire And Compatibility Rules diff --git a/docs/consumers/pkg-standards.md b/docs/consumers/pkg-standards.md index 1143fa5..2c19df7 100644 --- a/docs/consumers/pkg-standards.md +++ b/docs/consumers/pkg-standards.md @@ -43,6 +43,12 @@ Canonical schemas emitted after normalization: | `SchemaWeatherAlertV1` | `weather.alert.v1` | | `SchemaWeatherOutlookV1` | `weather.outlook.v1` | +Additional canonical schema constant: + +| Constant | Value | +|---|---| +| `SchemaWeatherOutlookV2` | `weather.outlook.v2` | + ## Raw Schema Constants Raw source schemas emitted by current registered sources: diff --git a/internal/normalizers/spc/convective_outlook.go b/internal/normalizers/spc/convective_outlook.go index 7219917..93eceea 100644 --- a/internal/normalizers/spc/convective_outlook.go +++ b/internal/normalizers/spc/convective_outlook.go @@ -76,8 +76,7 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb if err := validateProductMetadata(product); err != nil { return model.WeatherOutlookRun{}, time.Time{}, err } - discussion, ok := discussions[product.Day] - if !ok { + if _, ok := discussions[product.Day]; !ok { return model.WeatherOutlookRun{}, time.Time{}, fmt.Errorf("product %s: discussion for day %d is required", product.Key, product.Day) } @@ -98,7 +97,7 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb continue } - outlook, err := mapFeature(product, feature, i, point, discussion) + outlook, err := mapFeature(product, feature, i, point) if err != nil { return model.WeatherOutlookRun{}, time.Time{}, err } @@ -219,7 +218,7 @@ func validateProductMetadata(product spcprovider.RawOutlookProduct) error { } } -func mapFeature(product spcprovider.RawOutlookProduct, feature spcprovider.GeoJSONFeature, index int, point geo.Point, discussion parsedDiscussion) (model.WeatherOutlook, error) { +func mapFeature(product spcprovider.RawOutlookProduct, feature spcprovider.GeoJSONFeature, index int, point geo.Point) (model.WeatherOutlook, error) { fieldPrefix := fmt.Sprintf("product %s feature %d", product.Key, index) props := feature.Properties @@ -264,9 +263,6 @@ func mapFeature(product spcprovider.RawOutlookProduct, feature spcprovider.GeoJS IssuedAt: issuedAt, ExpiresAt: validTo, Forecaster: strings.TrimSpace(props.Forecaster), - Headline: discussion.Headline, - Summary: discussion.Summary, - Discussion: discussion.Discussion, SourceURL: strings.TrimSpace(product.URL), ImageURL: "", ContainsLocation: containsLocation, diff --git a/internal/normalizers/spc/convective_outlook_test.go b/internal/normalizers/spc/convective_outlook_test.go index e8877ba..676c6a4 100644 --- a/internal/normalizers/spc/convective_outlook_test.go +++ b/internal/normalizers/spc/convective_outlook_test.go @@ -88,18 +88,6 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test if !got.ContainsLocation { t.Fatalf("ContainsLocation = false, want true") } - if got.Headline != "Day 1 Convective Outlook" { - t.Fatalf("Headline = %q", got.Headline) - } - if !strings.Contains(got.Summary, "central Plains") { - t.Fatalf("Summary = %q", got.Summary) - } - if !strings.Contains(got.Discussion, "...DISCUSSION...") { - t.Fatalf("Discussion missing product text: %q", got.Discussion) - } - if !strings.HasPrefix(got.Discussion, "SPC AC 111234") { - t.Fatalf("Discussion = %q, want SPC product code prefix", got.Discussion) - } if got.ID != "spc-convective-day1-categorical-slgt-2026-06-11T12:34:56Z-2026-06-11T13:00:00Z-0" { t.Fatalf("ID = %q", got.ID) } @@ -212,7 +200,8 @@ func TestConvectiveOutlookNormalizerContainsLocationFalseOutsidePolygon(t *testi } func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) { - out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, spcBundle(t, 0, 0))) + bundle := spcBundle(t, 0, 0) + out, err := (ConvectiveOutlookNormalizer{}).Normalize(nil, spcRawEvent(t, bundle)) if err != nil { t.Fatalf("Normalize() error = %v", err) } @@ -221,11 +210,15 @@ func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) { if got == nil { t.Fatalf("missing day 2 tornado outlook") } - if !strings.Contains(got.Headline, "CORR 1") { - t.Fatalf("Headline = %q, want correction marker", got.Headline) + discussions, _, err := parseDiscussions(bundle.Discussions) + if err != nil { + t.Fatalf("parseDiscussions() error = %v", err) } - if !strings.Contains(got.Discussion, "CORR 1") { - t.Fatalf("Discussion = %q, want correction marker", got.Discussion) + 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) } } @@ -306,7 +299,7 @@ func TestConvectiveOutlookNormalizerOutputJSONShape(t *testing.T) { t.Fatalf("payload JSON missing %s: %s", want, got) } } - for _, unwanted := range []string{`"products"`, `"discussions"`, `"fetchedAt"`, `"body"`} { + for _, unwanted := range []string{`"products"`, `"fetchedAt"`, `"body"`} { if strings.Contains(got, unwanted) { t.Fatalf("payload JSON exposed raw key %s: %s", unwanted, got) } diff --git a/internal/sinks/postgres/map.go b/internal/sinks/postgres/map.go index 2a2613e..303c809 100644 --- a/internal/sinks/postgres/map.go +++ b/internal/sinks/postgres/map.go @@ -381,9 +381,9 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) { "issued_at": outlook.IssuedAt.UTC(), "expires_at": outlook.ExpiresAt.UTC(), "forecaster": nullableString(outlook.Forecaster), - "headline": nullableString(outlook.Headline), - "summary": nullableString(outlook.Summary), - "discussion": nullableString(outlook.Discussion), + "headline": nullableString(""), + "summary": nullableString(""), + "discussion": nullableString(""), "source_url": nullableString(outlook.SourceURL), "image_url": nullableString(outlook.ImageURL), "contains_location": outlook.ContainsLocation, diff --git a/internal/sinks/postgres/map_test.go b/internal/sinks/postgres/map_test.go index 85e0a1d..ae04c92 100644 --- a/internal/sinks/postgres/map_test.go +++ b/internal/sinks/postgres/map_test.go @@ -266,9 +266,6 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) { IssuedAt: issuedAt, ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC), Forecaster: "SMITH", - Headline: "Day 1 Convective Outlook", - Summary: "Severe thunderstorms are possible.", - Discussion: "Full discussion text.", SourceURL: "https://example.invalid/day1.geojson", ContainsLocation: true, Geometry: json.RawMessage(`{ "type" : "Polygon", "coordinates" : [ [ [ -91.0, 38.0 ], [ -90.0, 38.0 ], [ -90.0, 39.0 ], [ -91.0, 39.0 ], [ -91.0, 38.0 ] ] ] }`), diff --git a/model/docs_test.go b/model/docs_test.go index 7fade0c..c5380e6 100644 --- a/model/docs_test.go +++ b/model/docs_test.go @@ -26,6 +26,7 @@ func TestDocumentedConsumerModelTypes(t *testing.T) { "WeatherAlert", "WeatherAlertReference", "WeatherOutlookRun", + "WeatherOutlookDiscussion", "WeatherOutlook", "WMOCode", } diff --git a/model/outlook.go b/model/outlook.go index ab88f84..b58e9f9 100644 --- a/model/outlook.go +++ b/model/outlook.go @@ -8,13 +8,23 @@ import ( // WeatherOutlookRun is a snapshot of convective outlook polygons for a // configured location as-of a provider issue time. type WeatherOutlookRun struct { - LocationID string `json:"locationId,omitempty"` - LocationName string `json:"locationName,omitempty"` - Latitude *float64 `json:"latitude,omitempty"` - Longitude *float64 `json:"longitude,omitempty"` - AsOf time.Time `json:"asOf"` - IssuedAt *time.Time `json:"issuedAt,omitempty"` - Outlooks []WeatherOutlook `json:"outlooks"` + LocationID string `json:"locationId,omitempty"` + LocationName string `json:"locationName,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + AsOf time.Time `json:"asOf"` + IssuedAt *time.Time `json:"issuedAt,omitempty"` + Outlooks []WeatherOutlook `json:"outlooks"` + Discussions []WeatherOutlookDiscussion `json:"discussions"` +} + +// WeatherOutlookDiscussion is run-level SPC outlook prose for one outlook day. +type WeatherOutlookDiscussion struct { + Day int `json:"day"` + Headline string `json:"headline,omitempty"` + Summary string `json:"summary,omitempty"` + Discussion string `json:"discussion,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` } // WeatherOutlook is a canonical representation of one outlook polygon. @@ -32,9 +42,6 @@ type WeatherOutlook struct { IssuedAt time.Time `json:"issuedAt"` ExpiresAt time.Time `json:"expiresAt"` Forecaster string `json:"forecaster,omitempty"` - Headline string `json:"headline,omitempty"` - Summary string `json:"summary,omitempty"` - Discussion string `json:"discussion,omitempty"` SourceURL string `json:"sourceUrl,omitempty"` ImageURL string `json:"imageUrl,omitempty"` ContainsLocation bool `json:"containsLocation"` diff --git a/model/outlook_test.go b/model/outlook_test.go new file mode 100644 index 0000000..ca822fa --- /dev/null +++ b/model/outlook_test.go @@ -0,0 +1,60 @@ +package model + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestWeatherOutlookJSONShape(t *testing.T) { + updatedAt := time.Date(2026, 6, 11, 16, 30, 0, 0, time.UTC) + run := WeatherOutlookRun{ + AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC), + Outlooks: []WeatherOutlook{{ + ID: "outlook-1", + Provider: "spc", + Product: "convective", + Day: 1, + OutlookType: "categorical", + Label: "SLGT", + ValidFrom: time.Date(2026, 6, 11, 13, 0, 0, 0, time.UTC), + ValidTo: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC), + IssuedAt: time.Date(2026, 6, 11, 12, 34, 56, 0, time.UTC), + ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC), + ContainsLocation: true, + Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`), + }}, + Discussions: []WeatherOutlookDiscussion{{ + Day: 1, + Headline: "Day 1 Convective Outlook", + Summary: "Severe thunderstorms are possible.", + Discussion: "Full discussion text.", + UpdatedAt: &updatedAt, + }}, + } + + raw, err := json.Marshal(run) + if err != nil { + t.Fatalf("Marshal(WeatherOutlookRun) error = %v", err) + } + got := string(raw) + + for _, want := range []string{`"outlooks"`, `"discussions"`, `"headline"`, `"summary"`, `"discussion"`, `"updatedAt"`} { + if !strings.Contains(got, want) { + t.Fatalf("WeatherOutlookRun 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("WeatherOutlookRun 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("WeatherOutlook JSON contains polygon-level prose key %s: %s", unwanted, got) + } + } +} diff --git a/standards/docs_test.go b/standards/docs_test.go index e44e7b9..0ee32b2 100644 --- a/standards/docs_test.go +++ b/standards/docs_test.go @@ -97,5 +97,10 @@ func stringConstantsFromFile(t *testing.T, path string, prefix string, skip func } func schemaConstantNotInCurrentContract(name string) bool { - return name == "SchemaRawOpenWeatherHourlyForecastV1" + switch name { + case "SchemaRawOpenWeatherHourlyForecastV1", "SchemaWeatherOutlookV2": + return true + default: + return false + } } diff --git a/standards/schema.go b/standards/schema.go index a4fce73..962e0b3 100644 --- a/standards/schema.go +++ b/standards/schema.go @@ -33,4 +33,5 @@ const ( SchemaWeatherStoryV1 = "weather.weather_story.v1" SchemaWeatherAlertV1 = "weather.alert.v1" SchemaWeatherOutlookV1 = "weather.outlook.v1" + SchemaWeatherOutlookV2 = "weather.outlook.v2" )