Add outlook v2 model contract
This commit is contained in:
@@ -38,6 +38,7 @@ Related child types include:
|
|||||||
- `WeatherAlert`
|
- `WeatherAlert`
|
||||||
- `WeatherAlertReference`
|
- `WeatherAlertReference`
|
||||||
- `WeatherOutlook`
|
- `WeatherOutlook`
|
||||||
|
- `WeatherOutlookDiscussion`
|
||||||
- `WMOCode`
|
- `WMOCode`
|
||||||
|
|
||||||
## Wire And Compatibility Rules
|
## Wire And Compatibility Rules
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ Canonical schemas emitted after normalization:
|
|||||||
| `SchemaWeatherAlertV1` | `weather.alert.v1` |
|
| `SchemaWeatherAlertV1` | `weather.alert.v1` |
|
||||||
| `SchemaWeatherOutlookV1` | `weather.outlook.v1` |
|
| `SchemaWeatherOutlookV1` | `weather.outlook.v1` |
|
||||||
|
|
||||||
|
Additional canonical schema constant:
|
||||||
|
|
||||||
|
| Constant | Value |
|
||||||
|
|---|---|
|
||||||
|
| `SchemaWeatherOutlookV2` | `weather.outlook.v2` |
|
||||||
|
|
||||||
## Raw Schema Constants
|
## Raw Schema Constants
|
||||||
|
|
||||||
Raw source schemas emitted by current registered sources:
|
Raw source schemas emitted by current registered sources:
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ func buildConvectiveOutlook(bundle spcprovider.RawConvectiveOutlookBundle, fallb
|
|||||||
if err := validateProductMetadata(product); err != nil {
|
if err := validateProductMetadata(product); err != nil {
|
||||||
return model.WeatherOutlookRun{}, time.Time{}, err
|
return model.WeatherOutlookRun{}, time.Time{}, err
|
||||||
}
|
}
|
||||||
discussion, ok := discussions[product.Day]
|
if _, ok := discussions[product.Day]; !ok {
|
||||||
if !ok {
|
|
||||||
return model.WeatherOutlookRun{}, time.Time{}, fmt.Errorf("product %s: discussion for day %d is required", product.Key, product.Day)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
outlook, err := mapFeature(product, feature, i, point, discussion)
|
outlook, err := mapFeature(product, feature, i, point)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.WeatherOutlookRun{}, time.Time{}, err
|
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)
|
fieldPrefix := fmt.Sprintf("product %s feature %d", product.Key, index)
|
||||||
props := feature.Properties
|
props := feature.Properties
|
||||||
|
|
||||||
@@ -264,9 +263,6 @@ func mapFeature(product spcprovider.RawOutlookProduct, feature spcprovider.GeoJS
|
|||||||
IssuedAt: issuedAt,
|
IssuedAt: issuedAt,
|
||||||
ExpiresAt: validTo,
|
ExpiresAt: validTo,
|
||||||
Forecaster: strings.TrimSpace(props.Forecaster),
|
Forecaster: strings.TrimSpace(props.Forecaster),
|
||||||
Headline: discussion.Headline,
|
|
||||||
Summary: discussion.Summary,
|
|
||||||
Discussion: discussion.Discussion,
|
|
||||||
SourceURL: strings.TrimSpace(product.URL),
|
SourceURL: strings.TrimSpace(product.URL),
|
||||||
ImageURL: "",
|
ImageURL: "",
|
||||||
ContainsLocation: containsLocation,
|
ContainsLocation: containsLocation,
|
||||||
|
|||||||
@@ -88,18 +88,6 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
|
|||||||
if !got.ContainsLocation {
|
if !got.ContainsLocation {
|
||||||
t.Fatalf("ContainsLocation = false, want true")
|
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" {
|
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)
|
t.Fatalf("ID = %q", got.ID)
|
||||||
}
|
}
|
||||||
@@ -212,7 +200,8 @@ func TestConvectiveOutlookNormalizerContainsLocationFalseOutsidePolygon(t *testi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Normalize() error = %v", err)
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -221,11 +210,15 @@ func TestConvectiveOutlookNormalizerPreservesCorrectionMarker(t *testing.T) {
|
|||||||
if got == nil {
|
if got == nil {
|
||||||
t.Fatalf("missing day 2 tornado outlook")
|
t.Fatalf("missing day 2 tornado outlook")
|
||||||
}
|
}
|
||||||
if !strings.Contains(got.Headline, "CORR 1") {
|
discussions, _, err := parseDiscussions(bundle.Discussions)
|
||||||
t.Fatalf("Headline = %q, want correction marker", got.Headline)
|
if err != nil {
|
||||||
|
t.Fatalf("parseDiscussions() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got.Discussion, "CORR 1") {
|
if !strings.Contains(discussions[2].Headline, "CORR 1") {
|
||||||
t.Fatalf("Discussion = %q, want correction marker", got.Discussion)
|
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)
|
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) {
|
if strings.Contains(got, unwanted) {
|
||||||
t.Fatalf("payload JSON exposed raw key %s: %s", unwanted, got)
|
t.Fatalf("payload JSON exposed raw key %s: %s", unwanted, got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,9 +381,9 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
"issued_at": outlook.IssuedAt.UTC(),
|
"issued_at": outlook.IssuedAt.UTC(),
|
||||||
"expires_at": outlook.ExpiresAt.UTC(),
|
"expires_at": outlook.ExpiresAt.UTC(),
|
||||||
"forecaster": nullableString(outlook.Forecaster),
|
"forecaster": nullableString(outlook.Forecaster),
|
||||||
"headline": nullableString(outlook.Headline),
|
"headline": nullableString(""),
|
||||||
"summary": nullableString(outlook.Summary),
|
"summary": nullableString(""),
|
||||||
"discussion": nullableString(outlook.Discussion),
|
"discussion": nullableString(""),
|
||||||
"source_url": nullableString(outlook.SourceURL),
|
"source_url": nullableString(outlook.SourceURL),
|
||||||
"image_url": nullableString(outlook.ImageURL),
|
"image_url": nullableString(outlook.ImageURL),
|
||||||
"contains_location": outlook.ContainsLocation,
|
"contains_location": outlook.ContainsLocation,
|
||||||
|
|||||||
@@ -266,9 +266,6 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
|||||||
IssuedAt: issuedAt,
|
IssuedAt: issuedAt,
|
||||||
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
||||||
Forecaster: "SMITH",
|
Forecaster: "SMITH",
|
||||||
Headline: "Day 1 Convective Outlook",
|
|
||||||
Summary: "Severe thunderstorms are possible.",
|
|
||||||
Discussion: "Full discussion text.",
|
|
||||||
SourceURL: "https://example.invalid/day1.geojson",
|
SourceURL: "https://example.invalid/day1.geojson",
|
||||||
ContainsLocation: true,
|
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 ] ] ] }`),
|
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 ] ] ] }`),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func TestDocumentedConsumerModelTypes(t *testing.T) {
|
|||||||
"WeatherAlert",
|
"WeatherAlert",
|
||||||
"WeatherAlertReference",
|
"WeatherAlertReference",
|
||||||
"WeatherOutlookRun",
|
"WeatherOutlookRun",
|
||||||
|
"WeatherOutlookDiscussion",
|
||||||
"WeatherOutlook",
|
"WeatherOutlook",
|
||||||
"WMOCode",
|
"WMOCode",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ type WeatherOutlookRun struct {
|
|||||||
AsOf time.Time `json:"asOf"`
|
AsOf time.Time `json:"asOf"`
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||||
Outlooks []WeatherOutlook `json:"outlooks"`
|
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.
|
// WeatherOutlook is a canonical representation of one outlook polygon.
|
||||||
@@ -32,9 +42,6 @@ type WeatherOutlook struct {
|
|||||||
IssuedAt time.Time `json:"issuedAt"`
|
IssuedAt time.Time `json:"issuedAt"`
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
Forecaster string `json:"forecaster,omitempty"`
|
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"`
|
SourceURL string `json:"sourceUrl,omitempty"`
|
||||||
ImageURL string `json:"imageUrl,omitempty"`
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
ContainsLocation bool `json:"containsLocation"`
|
ContainsLocation bool `json:"containsLocation"`
|
||||||
|
|||||||
60
model/outlook_test.go
Normal file
60
model/outlook_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,5 +97,10 @@ func stringConstantsFromFile(t *testing.T, path string, prefix string, skip func
|
|||||||
}
|
}
|
||||||
|
|
||||||
func schemaConstantNotInCurrentContract(name string) bool {
|
func schemaConstantNotInCurrentContract(name string) bool {
|
||||||
return name == "SchemaRawOpenWeatherHourlyForecastV1"
|
switch name {
|
||||||
|
case "SchemaRawOpenWeatherHourlyForecastV1", "SchemaWeatherOutlookV2":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,5 @@ const (
|
|||||||
SchemaWeatherStoryV1 = "weather.weather_story.v1"
|
SchemaWeatherStoryV1 = "weather.weather_story.v1"
|
||||||
SchemaWeatherAlertV1 = "weather.alert.v1"
|
SchemaWeatherAlertV1 = "weather.alert.v1"
|
||||||
SchemaWeatherOutlookV1 = "weather.outlook.v1"
|
SchemaWeatherOutlookV1 = "weather.outlook.v1"
|
||||||
|
SchemaWeatherOutlookV2 = "weather.outlook.v2"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user