Add SPC convective outlook briefing module
This commit is contained in:
@@ -28,7 +28,8 @@ Outputs:
|
||||
supported reports, fact requirements, missing-data behavior, and builder
|
||||
- `module.Output` values for source-oriented stanzas:
|
||||
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
|
||||
`alert_digest`, `area_forecast_discussion`, and `weather_story`
|
||||
`alert_digest`, `spc_convective_outlooks`,
|
||||
`area_forecast_discussion`, and `weather_story`
|
||||
- `module.Output` values for derived stanzas:
|
||||
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
|
||||
`outdoor_windows`, and `tomorrow_planning`
|
||||
@@ -86,6 +87,9 @@ return an error for invalid required inputs.
|
||||
forecast discussion, and weather story stanzas.
|
||||
- Alert digest output distinguishes checked empty alert data from missing alert
|
||||
source data.
|
||||
- SPC convective outlook output distinguishes checked empty outlook data from
|
||||
missing outlook source data and omits GeoJSON geometry from prompt-facing
|
||||
fields.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ The registry recognizes these IDs:
|
||||
- `derived_daypart_summaries`
|
||||
- `precip_timing`
|
||||
- `alert_digest`
|
||||
- `spc_convective_outlooks`
|
||||
- `area_forecast_discussion`
|
||||
- `weather_story`
|
||||
- `outdoor_windows`
|
||||
@@ -45,9 +46,9 @@ The registry recognizes these IDs:
|
||||
Every registered module has a builder. Report composition entries that refer to
|
||||
unknown or unimplemented module IDs fail validation instead of being skipped.
|
||||
|
||||
The package also defines `spc_convective_outlooks` and
|
||||
`spc_convective_discussion` IDs and empty option structs for the collected
|
||||
contract. They are not registered modules in the briefing registry.
|
||||
The package also defines the `spc_convective_discussion` ID and empty option
|
||||
struct for the collected contract. It is not a registered module in the
|
||||
briefing registry until its builder exists.
|
||||
|
||||
## Options
|
||||
|
||||
|
||||
@@ -327,6 +327,16 @@ func defaultModuleDefinitions() []ModuleDefinition {
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildAlertDigestModule,
|
||||
},
|
||||
{
|
||||
ID: module.SPCConvectiveOutlooks,
|
||||
StanzaName: string(module.SPCConvectiveOutlooks),
|
||||
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
|
||||
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
|
||||
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
|
||||
SupportedReports: allReports,
|
||||
MissingData: module.MissingDataEmpty,
|
||||
Builder: buildSPCConvectiveOutlooksModule,
|
||||
},
|
||||
{
|
||||
ID: module.AreaForecastDiscussion,
|
||||
StanzaName: "area_forecast_discussion",
|
||||
|
||||
93
internal/briefing/spc_convective_outlooks_module.go
Normal file
93
internal/briefing/spc_convective_outlooks_module.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
type SPCConvectiveOutlooksModule struct {
|
||||
Checked bool `json:"checked"`
|
||||
AsOf string `json:"as_of,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
LocationID string `json:"location_id,omitempty"`
|
||||
LocationName string `json:"location_name,omitempty"`
|
||||
OutlookCount int `json:"outlook_count"`
|
||||
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
|
||||
}
|
||||
|
||||
type SPCConvectiveOutlookRecord struct {
|
||||
Day int `json:"day,omitempty"`
|
||||
OutlookType string `json:"outlook_type,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LabelText string `json:"label_text,omitempty"`
|
||||
SeverityRank *int `json:"severity_rank,omitempty"`
|
||||
ValidStart string `json:"valid_start,omitempty"`
|
||||
ValidEnd string `json:"valid_end,omitempty"`
|
||||
IssuedAt string `json:"issued_at,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ContainsLocation bool `json:"contains_location"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
|
||||
value := SPCConvectiveOutlooksModule{}
|
||||
run := ctx.Collected.SPCConvectiveOutlooks
|
||||
if run != nil {
|
||||
value.Checked = true
|
||||
value.AsOf = friendlyOptionalTime(run.AsOf, ctx.Timezone)
|
||||
value.IssuedAt = friendlyOptionalTime(run.IssuedAt, ctx.Timezone)
|
||||
value.LocationID = run.LocationID
|
||||
value.LocationName = run.LocationName
|
||||
}
|
||||
source, ok := sourceByName(ctx.Collected.SourceProvenance, string(module.SPCConvectiveOutlooks))
|
||||
if value.Checked && value.AsOf == "" && ok && !source.FetchedAt.IsZero() {
|
||||
value.AsOf = friendlyDateTimeLabel(source.FetchedAt, ctx.Timezone)
|
||||
}
|
||||
if value.Checked && value.IssuedAt == "" && ok {
|
||||
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
|
||||
}
|
||||
|
||||
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Timezone)
|
||||
value.OutlookCount = len(value.Outlooks)
|
||||
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
|
||||
}
|
||||
|
||||
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, timezone string) []SPCConvectiveOutlookRecord {
|
||||
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
|
||||
for _, outlook := range outlooks {
|
||||
records = append(records, SPCConvectiveOutlookRecord{
|
||||
Day: outlook.Day,
|
||||
OutlookType: outlook.OutlookType,
|
||||
Label: outlook.Label,
|
||||
LabelText: outlook.LabelText,
|
||||
SeverityRank: copyInt(outlook.SeverityRank),
|
||||
ValidStart: friendlyDateTimeLabel(outlook.ValidFrom, timezone),
|
||||
ValidEnd: friendlyDateTimeLabel(outlook.ValidTo, timezone),
|
||||
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
|
||||
ExpiresAt: friendlyOptionalTime(outlook.ExpiresAt, timezone),
|
||||
ContainsLocation: outlook.ContainsLocation,
|
||||
SourceURL: outlook.SourceURL,
|
||||
ImageURL: outlook.ImageURL,
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func friendlyOptionalTime(value *time.Time, timezone string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return friendlyDateTimeLabel(*value, timezone)
|
||||
}
|
||||
|
||||
func sourceByName(sources []weatherdata.Source, name string) (weatherdata.Source, bool) {
|
||||
for _, source := range sources {
|
||||
if source.Name == name {
|
||||
return source, true
|
||||
}
|
||||
}
|
||||
return weatherdata.Source{}, false
|
||||
}
|
||||
141
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
141
internal/briefing/spc_convective_outlooks_module_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
|
||||
)
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
rank := 3
|
||||
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||
issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z")
|
||||
expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00")
|
||||
outlook := weatherdata.ConvectiveOutlook{
|
||||
ID: "day1-categorical-slight",
|
||||
Provider: "spc",
|
||||
Product: "convective_outlook",
|
||||
Day: 1,
|
||||
OutlookType: "categorical",
|
||||
Label: "SLGT",
|
||||
LabelText: "Slight Risk",
|
||||
Forecaster: "Smith",
|
||||
SeverityRank: &rank,
|
||||
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
|
||||
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
|
||||
IssuedAt: &issuedAt,
|
||||
ExpiresAt: &expiresAt,
|
||||
SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
|
||||
ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
|
||||
ContainsLocation: true,
|
||||
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`),
|
||||
}
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
LocationID: "nws-lsx-grid-90-74",
|
||||
LocationName: "St. Louis, MO",
|
||||
AsOf: &asOf,
|
||||
IssuedAt: &issuedAt,
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" {
|
||||
t.Fatalf("output = %#v, want spc convective outlook output", output)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" {
|
||||
t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value)
|
||||
}
|
||||
if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" {
|
||||
t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName)
|
||||
}
|
||||
if len(value.Outlooks) != 1 {
|
||||
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
|
||||
}
|
||||
got := value.Outlooks[0]
|
||||
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
|
||||
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
|
||||
}
|
||||
if got.SeverityRank == nil || *got.SeverityRank != 3 {
|
||||
t.Fatalf("SeverityRank = %#v, want 3", got.SeverityRank)
|
||||
}
|
||||
if got.ValidStart != "2026-05-29 at 11:00 AM" || got.ValidEnd != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" || got.ExpiresAt != "2026-05-30 at 7:00 AM" {
|
||||
t.Fatalf("outlook times = %#v, want friendly local labels", got)
|
||||
}
|
||||
if !got.ContainsLocation || got.SourceURL == "" || got.ImageURL == "" {
|
||||
t.Fatalf("outlook = %#v, want location flag and source/image URLs", got)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "valid_start", "valid_end", "contains_location", "source_url", "image_url"} {
|
||||
if !strings.Contains(text, field) {
|
||||
t.Fatalf("json = %s, want field %s", text, field)
|
||||
}
|
||||
}
|
||||
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider"} {
|
||||
if strings.Contains(text, omitted) {
|
||||
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
|
||||
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
|
||||
LocationID: "nws-lsx-grid-90-74",
|
||||
LocationName: "St. Louis, MO",
|
||||
AsOf: &asOf,
|
||||
Outlooks: []weatherdata.ConvectiveOutlook{},
|
||||
}
|
||||
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{}
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 {
|
||||
t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value)
|
||||
}
|
||||
data, err := json.Marshal(output.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(data), "outlooks") {
|
||||
t.Fatalf("json = %s, want empty outlook list omitted", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) {
|
||||
registry := MustDefaultModuleRegistry()
|
||||
ctx := testModuleContext()
|
||||
ctx.Collected.SPCConvectiveOutlooks = nil
|
||||
ctx.Collected.SourceProvenance = []weatherdata.Source{{
|
||||
Name: string(module.SPCConvectiveOutlooks),
|
||||
Missing: true,
|
||||
}}
|
||||
ctx.Derived.SPCConvectiveOutlooks = nil
|
||||
|
||||
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModule() error = %v", err)
|
||||
}
|
||||
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
|
||||
if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" {
|
||||
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user