diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 707fee8..380232e 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -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 diff --git a/docs/internal/module.md b/docs/internal/module.md index 0385d83..5492298 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -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 diff --git a/internal/briefing/modules.go b/internal/briefing/modules.go index bf0f740..50f5f52 100644 --- a/internal/briefing/modules.go +++ b/internal/briefing/modules.go @@ -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", diff --git a/internal/briefing/spc_convective_outlooks_module.go b/internal/briefing/spc_convective_outlooks_module.go new file mode 100644 index 0000000..6fe7931 --- /dev/null +++ b/internal/briefing/spc_convective_outlooks_module.go @@ -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 +} diff --git a/internal/briefing/spc_convective_outlooks_module_test.go b/internal/briefing/spc_convective_outlooks_module_test.go new file mode 100644 index 0000000..63e65e5 --- /dev/null +++ b/internal/briefing/spc_convective_outlooks_module_test.go @@ -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) + } +}