From fdddb5f08d6b7ad8a2d9033b9d68feedd62959a7 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 21 Jun 2026 14:30:08 -0500 Subject: [PATCH] Add background definitions for SPC convective outlook risk products --- docs/internal/briefing.md | 10 +- docs/internal/module.md | 8 +- docs/templates.md | 4 + internal/app/app_test.go | 4 + .../spc_convective_outlook_definitions.json | 72 +++++++++++++ .../spc_convective_outlook_definitions.go | 37 +++++++ .../spc_convective_outlooks_module.go | 44 ++++---- .../spc_convective_outlooks_module_test.go | 100 +++++++++++++++++- 8 files changed, 253 insertions(+), 26 deletions(-) create mode 100644 internal/briefing/assets/spc_convective_outlook_definitions.json create mode 100644 internal/briefing/spc_convective_outlook_definitions.go diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index f614287..b893776 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -98,10 +98,12 @@ subset of discussion fields. Hourly Report defaults this module to report-period outlooks. It emits `checked: true` for a successfully fetched empty run, reports `outlook_count`, and includes prompt-facing outlook fields such as risk label, `period_begins`, `period_ends`, image URL, and whether the -outlook contains the configured location. It also emits a curated `risk_digest` -for categorical outlooks that overlap the report period, contain the location, -and meet the configured-in-code minimum severity for report rendering. It does -not emit GeoJSON geometry, source URL, expiration time, or severity rank. +outlook contains the configured location. It enriches matching outlooks with +embedded background definitions owned by this package. It also emits a curated +`risk_digest` for categorical outlooks that overlap the report period, contain +the location, and meet the configured-in-code minimum severity for report +rendering. It does not emit GeoJSON geometry, source URL, expiration time, or +severity rank. Prompt-facing module intervals use friendly local `period_begins` and `period_ends` labels. Canonical report metadata, source provenance, diff --git a/docs/internal/module.md b/docs/internal/module.md index 6e3aae1..c31723a 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -229,9 +229,11 @@ validation. - `risk_digest` Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`, -`period_begins`, `period_ends`, `issued_at`, `contains_location`, and -`image_url`. It omits GeoJSON geometry, source URL, expiration time, and -severity rank. +`background_definition`, `period_begins`, `period_ends`, `issued_at`, +`contains_location`, and `image_url`. `background_definition` is embedded +briefing reference content for known outlook type/label pairs and may include +`plain_language`, `official_description`, and `relative_level`. It omits GeoJSON +geometry, source URL, expiration time, and severity rank. The optional `risk_digest` list is a curated report-rendering subset of categorical outlooks that overlap the report period, contain the configured diff --git a/docs/templates.md b/docs/templates.md index 99a7fab..0ff379a 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -367,6 +367,10 @@ YAML. | `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].BackgroundDefinition` | *briefing.SPCOutlookBackgroundDefinition | Embedded background context for known SPC outlook products. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].BackgroundDefinition.PlainLanguage` | string | Plain-language outlook definition. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].BackgroundDefinition.OfficialDescription` | string | Official or source-aligned outlook definition. | +| `.Modules.SPCConvectiveOutlooks.Outlooks[].BackgroundDefinition.RelativeLevel` | string | Relative categorical risk level, when defined. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. | diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 1e22fa8..a4f8875 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -576,6 +576,10 @@ func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) { " spc_convective_discussion:", " included_because: categorical severity_rank >= 3", " label_text: Slight Risk", + "background_definition:", + "plain_language: Scattered severe storms possible.", + "official_description: Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.", + "relative_level: 2 of 5", " period_begins:", " period_ends:", " discussion: Severe thunderstorms may produce damaging winds during the afternoon.", diff --git a/internal/briefing/assets/spc_convective_outlook_definitions.json b/internal/briefing/assets/spc_convective_outlook_definitions.json new file mode 100644 index 0000000..fee3b7a --- /dev/null +++ b/internal/briefing/assets/spc_convective_outlook_definitions.json @@ -0,0 +1,72 @@ +{ + "categorical:TSTM": { + "plain_language": "General or non-severe thunderstorms.", + "official_description": "No severe thunderstorms expected.", + "relative_level": "0 of 5" + }, + "categorical:MRGL": { + "plain_language": "Isolated severe storms possible.", + "official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.", + "relative_level": "1 of 5" + }, + "categorical:SLGT": { + "plain_language": "Scattered severe storms possible.", + "official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.", + "relative_level": "2 of 5" + }, + "categorical:ENH": { + "plain_language": "Numerous severe storms possible.", + "official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.", + "relative_level": "3 of 5" + }, + "categorical:MDT": { + "plain_language": "Widespread severe storms likely.", + "official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.", + "relative_level": "4 of 5" + }, + "categorical:HIGH": { + "plain_language": "Major severe outbreak expected.", + "official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).", + "relative_level": "5 of 5" + }, + "tornado:CIG1": { + "plain_language": "Conditional potential for significant tornadoes.", + "official_description": "Intensity Level 1: Reasonable Max EF2. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.", + "relative_level": "1 of 3" + }, + "tornado:CIG2": { + "plain_language": "Conditional potential for strong tornadoes.", + "official_description": "Intensity Level 2: Reasonable Max EF3. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.", + "relative_level": "2 of 3" + }, + "tornado:CIG3": { + "plain_language": "Conditional potential for violent tornadoes.", + "official_description": "Intensity Level 3: Reasonable Max EF4 or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.", + "relative_level": "3 of 3" + }, + "wind:CIG1": { + "plain_language": "Conditional potential for significant severe wind.", + "official_description": "Intensity Level 1: Reasonable Max wind gusts around 65 kt / 75 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.", + "relative_level": "1 of 3" + }, + "wind:CIG2": { + "plain_language": "Conditional potential for intense severe wind.", + "official_description": "Intensity Level 2: Reasonable Max wind gusts around 75 kt / 85 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.", + "relative_level": "2 of 3" + }, + "wind:CIG3": { + "plain_language": "Conditional potential for extreme severe wind.", + "official_description": "Intensity Level 3: Reasonable Max wind gusts around 100 kt / 115 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.", + "relative_level": "3 of 3" + }, + "hail:CIG1": { + "plain_language": "Conditional potential for significant hail.", + "official_description": "Intensity Level 1: Reasonable Max hail size around 2.00 to 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.", + "relative_level": "1 of 2" + }, + "hail:CIG2": { + "plain_language": "Conditional potential for giant hail.", + "official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.", + "relative_level": "2 of 2" + } +} diff --git a/internal/briefing/spc_convective_outlook_definitions.go b/internal/briefing/spc_convective_outlook_definitions.go new file mode 100644 index 0000000..b4b0b01 --- /dev/null +++ b/internal/briefing/spc_convective_outlook_definitions.go @@ -0,0 +1,37 @@ +package briefing + +import ( + "embed" + "encoding/json" + "fmt" + "strings" +) + +//go:embed assets/spc_convective_outlook_definitions.json +var spcConvectiveOutlookDefinitionAssets embed.FS + +var spcOutlookBackgroundDefinitions = mustLoadSPCOutlookBackgroundDefinitions() + +func mustLoadSPCOutlookBackgroundDefinitions() map[string]SPCOutlookBackgroundDefinition { + data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json") + if err != nil { + panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err)) + } + var definitions map[string]SPCOutlookBackgroundDefinition + if err := json.Unmarshal(data, &definitions); err != nil { + panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err)) + } + return definitions +} + +func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition { + definition, ok := spcOutlookBackgroundDefinitions[spcOutlookDefinitionKey(outlookType, label)] + if !ok { + return nil + } + return &definition +} + +func spcOutlookDefinitionKey(outlookType string, label string) string { + return strings.ToLower(strings.TrimSpace(outlookType)) + ":" + strings.ToUpper(strings.TrimSpace(label)) +} diff --git a/internal/briefing/spc_convective_outlooks_module.go b/internal/briefing/spc_convective_outlooks_module.go index f247ca3..d5339d8 100644 --- a/internal/briefing/spc_convective_outlooks_module.go +++ b/internal/briefing/spc_convective_outlooks_module.go @@ -25,15 +25,22 @@ type SPCConvectiveOutlooksModule struct { } 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"` - PeriodBegins string `json:"period_begins,omitempty"` - PeriodEnds string `json:"period_ends,omitempty"` - IssuedAt string `json:"issued_at,omitempty"` - ContainsLocation bool `json:"contains_location"` - ImageURL string `json:"image_url,omitempty"` + Day int `json:"day,omitempty"` + OutlookType string `json:"outlook_type,omitempty"` + Label string `json:"label,omitempty"` + LabelText string `json:"label_text,omitempty"` + BackgroundDefinition *SPCOutlookBackgroundDefinition `json:"background_definition,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` + IssuedAt string `json:"issued_at,omitempty"` + ContainsLocation bool `json:"contains_location"` + ImageURL string `json:"image_url,omitempty"` +} + +type SPCOutlookBackgroundDefinition struct { + PlainLanguage string `json:"plain_language,omitempty"` + OfficialDescription string `json:"official_description,omitempty"` + RelativeLevel string `json:"relative_level,omitempty"` } type SPCConvectiveOutlookDigest struct { @@ -87,15 +94,16 @@ func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, repor continue } records = append(records, SPCConvectiveOutlookRecord{ - Day: outlook.Day, - OutlookType: outlook.OutlookType, - Label: outlook.Label, - LabelText: outlook.LabelText, - PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone), - PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone), - IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone), - ContainsLocation: outlook.ContainsLocation, - ImageURL: outlook.ImageURL, + Day: outlook.Day, + OutlookType: outlook.OutlookType, + Label: outlook.Label, + LabelText: outlook.LabelText, + BackgroundDefinition: spcOutlookBackgroundDefinition(outlook.OutlookType, outlook.Label), + PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone), + PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone), + IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone), + ContainsLocation: outlook.ContainsLocation, + ImageURL: outlook.ImageURL, }) } return records diff --git a/internal/briefing/spc_convective_outlooks_module_test.go b/internal/briefing/spc_convective_outlooks_module_test.go index ddf075e..daabe17 100644 --- a/internal/briefing/spc_convective_outlooks_module_test.go +++ b/internal/briefing/spc_convective_outlooks_module_test.go @@ -65,6 +65,12 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) { 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.BackgroundDefinition == nil || + got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." || + got.BackgroundDefinition.OfficialDescription != "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread." || + got.BackgroundDefinition.RelativeLevel != "2 of 5" { + t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition) + } if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" { t.Fatalf("outlook times = %#v, want friendly local labels", got) } @@ -83,7 +89,7 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) { 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", "risk_digest", "period_begins", "period_ends", "contains_location", "image_url"} { + for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "background_definition", "plain_language", "official_description", "relative_level", "period_begins", "period_ends", "contains_location", "image_url"} { if !strings.Contains(text, field) { t.Fatalf("json = %s, want field %s", text, field) } @@ -95,6 +101,98 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) { } } +func TestSPCOutlookBackgroundDefinitionLookup(t *testing.T) { + tests := []struct { + name string + outlookType string + label string + want bool + }{ + {name: "exact slight risk", outlookType: "categorical", label: "SLGT", want: true}, + {name: "normalized slight risk", outlookType: " Categorical ", label: "slgt", want: true}, + {name: "expanded marginal risk", outlookType: "categorical", label: "MRGL", want: true}, + {name: "expanded conditional tornado risk", outlookType: "tornado", label: "CIG3", want: true}, + {name: "unknown risk", outlookType: "categorical", label: "FOO"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + definition := spcOutlookBackgroundDefinition(tt.outlookType, tt.label) + if tt.want && definition == nil { + t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = nil, want definition", tt.outlookType, tt.label) + } + if !tt.want && definition != nil { + t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = %#v, want nil", tt.outlookType, tt.label, definition) + } + }) + } +} + +func TestSPCConvectiveOutlooksModuleOmitsUnknownBackgroundDefinition(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + outlook := spcRiskDigestTestOutlook("categorical", "Unknown Risk", 2, true, + "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00") + outlook.Label = "FOO" + ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ + 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) + } + value := moduleValue[SPCConvectiveOutlooksModule](t, output) + if len(value.Outlooks) != 1 { + t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks)) + } + if value.Outlooks[0].BackgroundDefinition != nil { + t.Fatalf("BackgroundDefinition = %#v, want nil for undefined risk", value.Outlooks[0].BackgroundDefinition) + } +} + +func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) { + wantKeys := []string{ + "categorical:TSTM", + "categorical:MRGL", + "categorical:SLGT", + "categorical:ENH", + "categorical:MDT", + "categorical:HIGH", + "tornado:CIG1", + "tornado:CIG2", + "tornado:CIG3", + "wind:CIG1", + "wind:CIG2", + "wind:CIG3", + "hail:CIG1", + "hail:CIG2", + } + if len(spcOutlookBackgroundDefinitions) != len(wantKeys) { + t.Fatalf("embedded SPC outlook background definitions length = %d, want %d", len(spcOutlookBackgroundDefinitions), len(wantKeys)) + } + for _, key := range wantKeys { + if _, ok := spcOutlookBackgroundDefinitions[key]; !ok { + t.Fatalf("embedded SPC outlook background definitions missing %q", key) + } + } + for key, definition := range spcOutlookBackgroundDefinitions { + if strings.TrimSpace(key) == "" { + t.Fatal("embedded SPC outlook background definitions contain empty key") + } + if definition.PlainLanguage == "" || definition.OfficialDescription == "" || definition.RelativeLevel == "" { + t.Fatalf("embedded SPC outlook background definition %q is incomplete: %#v", key, definition) + } + if strings.Contains(definition.OfficialDescription, ".Note") || strings.Contains(definition.OfficialDescription, "higher.Note") { + t.Fatalf("embedded SPC outlook background definition %q has missing sentence spacing: %q", key, definition.OfficialDescription) + } + if strings.Contains(definition.OfficialDescription, "by themselves") { + t.Fatalf("embedded SPC outlook background definition %q has singular grammar issue: %q", key, definition.OfficialDescription) + } + } +} + func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) { if defaultSPCRiskDigestOutlookType != "categorical" { t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)