Add background definitions for SPC convective outlook risk products
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
37
internal/briefing/spc_convective_outlook_definitions.go
Normal file
37
internal/briefing/spc_convective_outlook_definitions.go
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user