Add outlook v2 model contract

This commit is contained in:
2026-06-12 04:19:56 +00:00
parent 819ac24aed
commit 435d1ade07
11 changed files with 109 additions and 42 deletions

View File

@@ -38,6 +38,7 @@ Related child types include:
- `WeatherAlert`
- `WeatherAlertReference`
- `WeatherOutlook`
- `WeatherOutlookDiscussion`
- `WMOCode`
## Wire And Compatibility Rules

View File

@@ -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:

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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 ] ] ] }`),

View File

@@ -26,6 +26,7 @@ func TestDocumentedConsumerModelTypes(t *testing.T) {
"WeatherAlert",
"WeatherAlertReference",
"WeatherOutlookRun",
"WeatherOutlookDiscussion",
"WeatherOutlook",
"WMOCode",
}

View File

@@ -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"`

60
model/outlook_test.go Normal file
View 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)
}
}
}

View File

@@ -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
}
}

View File

@@ -33,4 +33,5 @@ const (
SchemaWeatherStoryV1 = "weather.weather_story.v1"
SchemaWeatherAlertV1 = "weather.alert.v1"
SchemaWeatherOutlookV1 = "weather.outlook.v1"
SchemaWeatherOutlookV2 = "weather.outlook.v2"
)