Add SPC convective outlook data contracts

This commit is contained in:
2026-06-12 14:47:42 +00:00
parent 2aba52f552
commit 3bcccb4a7b
11 changed files with 242 additions and 59 deletions

View File

@@ -162,6 +162,8 @@ func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContex
return ctx.Collected.Discussion != nil
case module.CollectedWeatherStory:
return ctx.Collected.WeatherStory != nil
case module.CollectedSPCConvectiveOutlooks:
return ctx.Collected.SPCConvectiveOutlooks != nil
case module.CollectedSourceMetadata:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default:

View File

@@ -4,8 +4,10 @@ import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
@@ -128,6 +130,18 @@ func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
}
}
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = true, want false without source")
}
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = false, want true with checked source")
}
}
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
}

View File

@@ -12,15 +12,16 @@ import (
)
type CollectedFacts struct {
FetchedAt time.Time
Observation *weatherdata.Observation
Current *weatherdata.Current
Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun
Alerts *weatherdata.AlertRun
Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory
FetchedAt time.Time
Observation *weatherdata.Observation
Current *weatherdata.Current
Hourly *weatherdata.ForecastRun
Narrative *weatherdata.ForecastRun
Alerts *weatherdata.AlertRun
Discussion *weatherdata.Discussion
Daily *weatherdata.ForecastRun
WeatherStory *weatherdata.WeatherStory
SPCConvectiveOutlooks *weatherdata.ConvectiveOutlookRun
SourceProvenance []weatherdata.Source
SourceWarnings []weatherdata.SourceWarning
@@ -31,33 +32,35 @@ func BuildCollected(bundle *weatherdata.Bundle) CollectedFacts {
return CollectedFacts{}
}
return CollectedFacts{
FetchedAt: bundle.FetchedAt,
Observation: bundle.Observation,
Current: bundle.Current,
Hourly: bundle.Hourly,
Narrative: bundle.Narrative,
Alerts: bundle.Alerts,
Discussion: bundle.Discussion,
Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
FetchedAt: bundle.FetchedAt,
Observation: bundle.Observation,
Current: bundle.Current,
Hourly: bundle.Hourly,
Narrative: bundle.Narrative,
Alerts: bundle.Alerts,
Discussion: bundle.Discussion,
Daily: bundle.Daily,
WeatherStory: bundle.WeatherStory,
SPCConvectiveOutlooks: bundle.SPCConvectiveOutlooks,
SourceProvenance: append([]weatherdata.Source(nil), bundle.Sources...),
SourceWarnings: append([]weatherdata.SourceWarning(nil), bundle.Warnings...),
}
}
func (f CollectedFacts) Bundle() *weatherdata.Bundle {
return &weatherdata.Bundle{
FetchedAt: f.FetchedAt,
Observation: f.Observation,
Current: f.Current,
Hourly: f.Hourly,
Narrative: f.Narrative,
Alerts: f.Alerts,
Discussion: f.Discussion,
Daily: f.Daily,
WeatherStory: f.WeatherStory,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
FetchedAt: f.FetchedAt,
Observation: f.Observation,
Current: f.Current,
Hourly: f.Hourly,
Narrative: f.Narrative,
Alerts: f.Alerts,
Discussion: f.Discussion,
Daily: f.Daily,
WeatherStory: f.WeatherStory,
SPCConvectiveOutlooks: f.SPCConvectiveOutlooks,
Sources: append([]weatherdata.Source(nil), f.SourceProvenance...),
Warnings: append([]weatherdata.SourceWarning(nil), f.SourceWarnings...),
}
}

View File

@@ -16,14 +16,24 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
FetchedAt: fetchedAt,
Current: &weatherdata.Current{ConditionText: "Clear"},
Hourly: &weatherdata.ForecastRun{Product: "hourly"},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{
Product: "convective_outlook",
Outlooks: []weatherdata.ConvectiveOutlook{{
ID: "day1-categorical-slight",
Label: "SLGT",
}},
},
Sources: []weatherdata.Source{{Name: "hourly"}},
Warnings: []weatherdata.SourceWarning{{Source: "discussion", Code: "missing_source"}},
}
collected := BuildCollected(bundle)
if collected.FetchedAt != fetchedAt || collected.Current.ConditionText != "Clear" || collected.Hourly.Product != "hourly" {
t.Fatalf("CollectedFacts = %#v, want source facts copied from bundle", collected)
}
if collected.SPCConvectiveOutlooks == nil || collected.SPCConvectiveOutlooks.Outlooks[0].Label != "SLGT" {
t.Fatalf("SPCConvectiveOutlooks = %#v, want source copied from bundle", collected.SPCConvectiveOutlooks)
}
if len(collected.SourceProvenance) != 1 || collected.SourceProvenance[0].Name != "hourly" {
t.Fatalf("SourceProvenance = %#v, want hourly source", collected.SourceProvenance)
}
@@ -36,6 +46,11 @@ func TestBuildCollectedCopiesBundleFactsAndKeepsSourcesSeparate(t *testing.T) {
if collected.SourceProvenance[0].Name != "hourly" || collected.SourceWarnings[0].Source != "discussion" {
t.Fatalf("collected source slices changed after bundle mutation: %#v %#v", collected.SourceProvenance, collected.SourceWarnings)
}
roundTrip := collected.Bundle()
if roundTrip.SPCConvectiveOutlooks == nil || roundTrip.SPCConvectiveOutlooks.Outlooks[0].ID != "day1-categorical-slight" {
t.Fatalf("Bundle().SPCConvectiveOutlooks = %#v, want collected source restored", roundTrip.SPCConvectiveOutlooks)
}
}
func TestBuildDerivedDailySlicesDaypartsAndAlerts(t *testing.T) {

View File

@@ -21,6 +21,8 @@ const (
AlertDigest ID = "alert_digest"
AreaForecastDiscussion ID = "area_forecast_discussion"
WeatherStory ID = "weather_story"
SPCConvectiveOutlooks ID = "spc_convective_outlooks"
SPCConvectiveDiscussion ID = "spc_convective_discussion"
OutdoorWindows ID = "outdoor_windows"
TomorrowPlanning ID = "tomorrow_planning"
)
@@ -105,19 +107,21 @@ func StanzaValue[T any](s Snapshot, name string) (T, bool, error) {
type FactRequirement string
const (
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story"
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
CollectedCurrentConditions FactRequirement = "collected.current_conditions"
CollectedNarrativeForecast FactRequirement = "collected.narrative_forecast"
CollectedHourlyForecast FactRequirement = "collected.hourly_forecast"
CollectedAlerts FactRequirement = "collected.alerts"
CollectedDiscussion FactRequirement = "collected.discussion"
CollectedWeatherStory FactRequirement = "collected.weather_story"
CollectedSPCConvectiveOutlooks FactRequirement = "collected.spc_convective_outlooks"
CollectedSourceMetadata FactRequirement = "collected.source_metadata"
RequiresDerivedHourlyPeriods FactRequirement = "derived.hourly_periods"
RequiresDerivedNarrativePeriods FactRequirement = "derived.narrative_periods"
RequiresDerivedAlertOverlaps FactRequirement = "derived.alert_overlaps"
RequiresDerivedDailySummaries FactRequirement = "derived.daily_summaries"
RequiresDerivedDaypartSummaries FactRequirement = "derived.daypart_summaries"
RequiresDerivedPrecipTiming FactRequirement = "derived.precip_timing"
RequiresDerivedSPCConvectiveOutlooks FactRequirement = "derived.spc_convective_outlooks"
)
type MissingDataBehavior string
@@ -141,5 +145,7 @@ type AreaForecastDiscussionOptions struct {
Sections []string `json:"sections,omitempty" yaml:"sections,omitempty"`
}
type WeatherStoryOptions struct{}
type SPCConvectiveOutlooksOptions struct{}
type SPCConvectiveDiscussionOptions struct{}
type OutdoorWindowsOptions struct{}
type TomorrowPlanningOptions struct{}

View File

@@ -82,3 +82,21 @@ func TestStanzaValueDecodesTypedOutput(t *testing.T) {
t.Fatal("StanzaValue(missing) found = true, want false")
}
}
func TestSPCConvectiveModuleContractsAreStable(t *testing.T) {
if SPCConvectiveOutlooks != ID("spc_convective_outlooks") {
t.Fatalf("SPCConvectiveOutlooks = %q, want stable source/module ID", SPCConvectiveOutlooks)
}
if SPCConvectiveDiscussion != ID("spc_convective_discussion") {
t.Fatalf("SPCConvectiveDiscussion = %q, want stable discussion module ID", SPCConvectiveDiscussion)
}
if CollectedSPCConvectiveOutlooks != FactRequirement("collected.spc_convective_outlooks") {
t.Fatalf("CollectedSPCConvectiveOutlooks = %q, want collected requirement", CollectedSPCConvectiveOutlooks)
}
if RequiresDerivedSPCConvectiveOutlooks != FactRequirement("derived.spc_convective_outlooks") {
t.Fatalf("RequiresDerivedSPCConvectiveOutlooks = %q, want derived requirement", RequiresDerivedSPCConvectiveOutlooks)
}
_ = SPCConvectiveOutlooksOptions{}
_ = SPCConvectiveDiscussionOptions{}
}

View File

@@ -7,17 +7,18 @@ import (
)
type Bundle struct {
FetchedAt time.Time `json:"fetchedAt"`
Observation *Observation `json:"observation,omitempty"`
Current *Current `json:"current,omitempty"`
Hourly *ForecastRun `json:"hourly,omitempty"`
Narrative *ForecastRun `json:"narrative,omitempty"`
Alerts *AlertRun `json:"alerts,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"`
Daily *ForecastRun `json:"daily,omitempty"`
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
Sources []Source `json:"sources"`
Warnings []SourceWarning `json:"warnings,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
Observation *Observation `json:"observation,omitempty"`
Current *Current `json:"current,omitempty"`
Hourly *ForecastRun `json:"hourly,omitempty"`
Narrative *ForecastRun `json:"narrative,omitempty"`
Alerts *AlertRun `json:"alerts,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"`
Daily *ForecastRun `json:"daily,omitempty"`
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
SPCConvectiveOutlooks *ConvectiveOutlookRun `json:"spcConvectiveOutlooks,omitempty"`
Sources []Source `json:"sources"`
Warnings []SourceWarning `json:"warnings,omitempty"`
}
type Source struct {
@@ -166,3 +167,42 @@ type WeatherStory struct {
Order int `json:"order"`
DownloadURL string `json:"downloadUrl,omitempty"`
}
type ConvectiveOutlookRun struct {
LocationID string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"`
AsOf *time.Time `json:"asOf,omitempty"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Product string `json:"product,omitempty"`
Outlooks []ConvectiveOutlook `json:"outlooks,omitempty"`
Discussions []ConvectiveOutlookDiscussion `json:"discussions,omitempty"`
}
type ConvectiveOutlook struct {
ID string `json:"id,omitempty"`
Provider string `json:"provider,omitempty"`
Product string `json:"product,omitempty"`
Day int `json:"day,omitempty"`
OutlookType string `json:"outlookType,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"labelText,omitempty"`
Forecaster string `json:"forecaster,omitempty"`
SeverityRank *int `json:"severityRank,omitempty"`
ValidFrom time.Time `json:"validFrom"`
ValidTo time.Time `json:"validTo"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
ContainsLocation bool `json:"containsLocation"`
Geometry json.RawMessage `json:"geometry,omitempty"`
}
type ConvectiveOutlookDiscussion struct {
Day int `json:"day,omitempty"`
Headline string `json:"headline,omitempty"`
Summary string `json:"summary,omitempty"`
Discussion string `json:"discussion,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}

View File

@@ -0,0 +1,78 @@
package weatherdata
import (
"encoding/json"
"testing"
"time"
)
func TestConvectiveOutlookRunJSONPreservesGeometry(t *testing.T) {
severity := 3
validFrom := mustParseBundleTime("2026-06-12T13:00:00Z")
validTo := mustParseBundleTime("2026-06-13T12:00:00Z")
issuedAt := mustParseBundleTime("2026-06-12T12:30:00Z")
updatedAt := mustParseBundleTime("2026-06-12T12:45:00Z")
run := ConvectiveOutlookRun{
LocationID: "test-grid",
LocationName: "Brentwood",
AsOf: &updatedAt,
IssuedAt: &issuedAt,
Product: "convective_outlook",
Outlooks: []ConvectiveOutlook{{
ID: "day1-categorical-slight",
Provider: "spc",
Product: "convective_outlook",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
Forecaster: "Smith",
SeverityRank: &severity,
ValidFrom: validFrom,
ValidTo: validTo,
IssuedAt: &issuedAt,
ExpiresAt: &validTo,
SourceURL: "https://example.test/source",
ImageURL: "https://example.test/image.png",
ContainsLocation: true,
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}`),
}},
Discussions: []ConvectiveOutlookDiscussion{{
Day: 1,
Headline: "Severe storms possible",
Summary: "Scattered severe storms are possible.",
Discussion: "Discussion text.",
UpdatedAt: &updatedAt,
}},
}
data, err := json.Marshal(run)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var decoded ConvectiveOutlookRun
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(decoded.Outlooks) != 1 || string(decoded.Outlooks[0].Geometry) == "" {
t.Fatalf("decoded outlooks = %#v, want preserved geometry", decoded.Outlooks)
}
if got := string(decoded.Outlooks[0].Geometry); got != `{"type":"Polygon","coordinates":[[[-90.1,38.1],[-90.0,38.2],[-90.1,38.1]]]}` {
t.Fatalf("Geometry = %s, want preserved GeoJSON coordinates", got)
}
if decoded.Outlooks[0].SeverityRank == nil || *decoded.Outlooks[0].SeverityRank != severity {
t.Fatalf("SeverityRank = %#v, want %d", decoded.Outlooks[0].SeverityRank, severity)
}
if len(decoded.Discussions) != 1 || decoded.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("Discussions = %#v, want decoded discussion", decoded.Discussions)
}
}
func mustParseBundleTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}