Update the alert digest wording

This commit is contained in:
2026-06-16 21:09:17 -05:00
parent d321492995
commit 6532e8824a
11 changed files with 97 additions and 27 deletions

View File

@@ -349,6 +349,10 @@ YAML.
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. |
| `.Modules.AlertDigest.Relevant[].PeriodBegins` | string | Friendly local alert applicability start. |
| `.Modules.AlertDigest.Relevant[].PeriodEnds` | string | Friendly local alert applicability end. |
| `.Modules.AlertDigest.Relevant[].Instruction` | string | Alert instruction text, when provided. |
| `.Modules.AlertDigest.Relevant[].Description` | string | Alert description text, when provided. |
### SPC Outlooks And Discussion

View File

@@ -567,7 +567,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
"# Hourly Report",
"Storm chances increase through late morning.",
"## Active Alerts",
"- **Flood Watch**: Flooding possible",
"- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM. Avoid low-water crossings.",
"## Precipitation Timing",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
@@ -2046,7 +2046,7 @@ func hourlyBundleServer(t *testing.T) *httptest.Server {
case "/forecast/narrative":
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T08:00:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Storms are possible today."}]}}`))
case "/alerts/active":
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`))
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","instruction":"Avoid low-water crossings.","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for hourly report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for hourly report."}}}`))
case "/weatherstories/latest":

View File

@@ -16,20 +16,24 @@ type AlertDigestModule struct {
}
type AlertSummary struct {
Event string `json:"event,omitempty"`
Headline string `json:"headline,omitempty"`
Severity string `json:"severity,omitempty"`
Event string `json:"event,omitempty"`
Headline string `json:"headline,omitempty"`
Severity string `json:"severity,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Instruction string `json:"instruction,omitempty"`
Description string `json:"description,omitempty"`
}
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone)
if value == nil {
value = &AlertDigestModule{}
}
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
}
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap, timezone string) *AlertDigestModule {
missing := sourceMissing(collected.SourceProvenance, "alerts")
if collected.Alerts == nil && !missing {
return nil
@@ -42,9 +46,13 @@ func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverla
value.RelevantCount = len(overlaps)
for _, overlap := range overlaps {
value.Relevant = append(value.Relevant, AlertSummary{
Event: overlap.Event,
Headline: overlap.Headline,
Severity: overlap.Severity,
Event: overlap.Event,
Headline: overlap.Headline,
Severity: overlap.Severity,
PeriodBegins: friendlyMonthDayTimeLabel(overlap.Period.Start, timezone),
PeriodEnds: friendlyMonthDayTimeLabel(overlap.Period.End, timezone),
Instruction: overlap.Instruction,
Description: overlap.Description,
})
}
return value

View File

@@ -367,6 +367,39 @@ func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
}
}
func TestAlertDigestIncludesPeriodAndGuidance(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{json.RawMessage(`{"event":"Wind Advisory"}`)}}
ctx.Derived.AlertOverlaps = []forecast.AlertOverlap{{
Event: "Wind Advisory",
Headline: "Wind Advisory until 8 PM",
Severity: "Moderate",
Period: timeutil.Period{Start: mustParseModuleTime("2026-06-17T18:00:00Z"), End: mustParseModuleTime("2026-06-18T01:00:00Z")},
Instruction: "Secure outdoor objects.",
Description: "Gusty winds may blow around unsecured objects.",
}}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(alert digest) error = %v", err)
}
value := moduleValue[AlertDigestModule](t, output)
if len(value.Relevant) != 1 {
t.Fatalf("Relevant length = %d, want 1", len(value.Relevant))
}
alert := value.Relevant[0]
if alert.Event != "Wind Advisory" || alert.Headline != "Wind Advisory until 8 PM" || alert.Severity != "Moderate" {
t.Fatalf("alert identity = %#v, want preserved event/headline/severity", alert)
}
if alert.PeriodBegins != "June 17 at 1:00 PM" || alert.PeriodEnds != "June 17 at 8:00 PM" {
t.Fatalf("alert period = %q/%q, want friendly local labels", alert.PeriodBegins, alert.PeriodEnds)
}
if alert.Instruction != "Secure outdoor objects." || alert.Description != "Gusty winds may blow around unsecured objects." {
t.Fatalf("alert guidance = %#v, want instruction and description preserved", alert)
}
}
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()

View File

@@ -44,7 +44,7 @@ func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps),
Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone),
}
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
}

View File

@@ -122,6 +122,17 @@ func friendlyDateTimeLabel(value time.Time, timezone string) string {
return value.In(location).Format("2006-01-02 at 3:04 PM")
}
func friendlyMonthDayTimeLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("January 2 at 3:04 PM")
}
func friendlyDateLabel(date string, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {

View File

@@ -65,6 +65,7 @@ type AlertOverlap struct {
Period timeutil.Period `json:"period"`
Overlap timeutil.Period `json:"overlap"`
Description string `json:"description,omitempty"`
Instruction string `json:"instruction,omitempty"`
}
type PrecipTiming struct {
@@ -463,6 +464,7 @@ func AlertOverlaps(alertRun *weatherdata.AlertRun, period timeutil.Period) []Ale
Period: alert.Period,
Overlap: intersect(alert.Period, period),
Description: alert.Description,
Instruction: alert.Instruction,
})
}
sort.SliceStable(overlaps, func(i int, j int) bool {
@@ -476,6 +478,7 @@ type parsedAlert struct {
Headline string
Severity string
Description string
Instruction string
Period timeutil.Period
}
@@ -488,7 +491,8 @@ func parseAlert(raw json.RawMessage) (parsedAlert, bool) {
Event: stringField(fields, "event"),
Headline: firstStringField(fields, "headline", "title"),
Severity: stringField(fields, "severity"),
Description: firstStringField(fields, "description", "instruction"),
Description: stringField(fields, "description"),
Instruction: stringField(fields, "instruction"),
}
start, startOK := firstTimeField(fields, "onset", "startsAt", "startTime", "effective", "sent")
end, endOK := firstTimeField(fields, "ends", "endsAt", "endTime", "expires")

View File

@@ -211,7 +211,7 @@ func TestAlertOverlap(t *testing.T) {
}
func TestAlertOverlapUsesOnsetAndEnds(t *testing.T) {
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`)
raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","description":"Southwest winds 20 to 30 mph with gusts up to 45 mph expected.","instruction":"Secure outdoor objects.","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`)
alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}}
period := timeutil.Period{
Start: mustParse("2026-06-17T12:00:00-05:00"),
@@ -234,6 +234,12 @@ func TestAlertOverlapUsesOnsetAndEnds(t *testing.T) {
if overlaps[0].Overlap.End.Format(time.RFC3339) != "2026-06-18T01:00:00Z" {
t.Fatalf("overlap end = %s, want alert ends", overlaps[0].Overlap.End.Format(time.RFC3339))
}
if overlaps[0].Description != "Southwest winds 20 to 30 mph with gusts up to 45 mph expected." {
t.Fatalf("Description = %q, want preserved description", overlaps[0].Description)
}
if overlaps[0].Instruction != "Secure outdoor objects." {
t.Fatalf("Instruction = %q, want preserved instruction", overlaps[0].Instruction)
}
}
func TestAlertOverlapUsesOnsetAndEndsForLocalDateRelevance(t *testing.T) {

View File

@@ -91,7 +91,7 @@ func TestBuildHourlyRenderContext(t *testing.T) {
"**Updated:** Friday, May 29, 2026 at 8:30 AM",
"Storm chances increase through late morning.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- **Flood Watch**: Flood Watch until early afternoon",
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.",
"A cold front is moving into the region.",
"A front will keep the region unsettled.",
} {
@@ -1190,7 +1190,7 @@ func testSnapshot(t *testing.T) module.Snapshot {
ActiveCount: 1,
RelevantCount: 1,
Relevant: []briefing.AlertSummary{
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"},
{Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate", PeriodBegins: "May 29 at 10:00 AM", PeriodEnds: "May 29 at 2:30 PM", Instruction: "Avoid low-water crossings."},
},
},
},

View File

@@ -240,7 +240,7 @@ func TestRenderHourly(t *testing.T) {
},
},
AlertDigest: &testAlertDigest{
Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate"}},
Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate", PeriodBegins: "May 29 at 10:00 AM", PeriodEnds: "May 29 at 2:30 PM", Instruction: "Avoid low-water crossings."}},
},
SPCConvectiveOutlooks: &testSPCOutlooks{
Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}},
@@ -269,7 +269,7 @@ func TestRenderHourly(t *testing.T) {
"Storm chances increase through late morning.",
"Currently, it is 74°F and partly cloudy. It feels like 76°F, with a relative humidity of 71% and winds from the south at 8 mph.",
"## Active Alerts",
"- **Flood Watch**: Flood Watch until 2:30 PM",
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.",
"- **9:00 AM:** 74°F and cloudy.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.",
"- **10:00 AM** to **12:00 PM**: Expect showers. The peak precipitation chance is 70% at 10:00 AM.",
@@ -352,7 +352,7 @@ func TestRenderTomorrow(t *testing.T) {
},
},
AlertDigest: &testAlertDigest{
Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate"}},
Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 1:00 PM", PeriodEnds: "June 15 at 8:00 PM", Instruction: "Secure outdoor objects."}},
},
},
})
@@ -366,7 +366,7 @@ func TestRenderTomorrow(t *testing.T) {
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"Tomorrow starts dry before showers return later in the day.",
"## Active Alerts",
"- **Wind Advisory**: Wind Advisory until 8:00 PM",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.",
"- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.",
@@ -450,7 +450,7 @@ func TestRenderDaily(t *testing.T) {
},
},
AlertDigest: &testAlertDigest{
Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 6:00 PM", Severity: "Moderate"}},
Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 6:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 3:00 PM", PeriodEnds: "June 15 at 6:00 PM", Description: "Monitor creek levels."}},
},
},
})
@@ -464,7 +464,7 @@ func TestRenderDaily(t *testing.T) {
"**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"The selected day starts dry before showers return later in the day.",
"## Active Alerts",
"- **Flood Watch**: Flood Watch until 6:00 PM",
"- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM. Monitor creek levels.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
"- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.",
@@ -550,7 +550,7 @@ func TestRenderToday(t *testing.T) {
},
},
AlertDigest: &testAlertDigest{
Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate"}},
Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 1:00 PM", PeriodEnds: "June 15 at 8:00 PM", Instruction: "Secure outdoor objects."}},
},
TodayPlanning: &testTodayPlanning{
MorningReadiness: []string{"Morning weather looks routine."},
@@ -568,7 +568,7 @@ func TestRenderToday(t *testing.T) {
"**Updated:** Monday, June 15, 2026 at 7:14 AM",
"Today starts dry before showers return later in the day.",
"## Active Alerts",
"- **Wind Advisory**: Wind Advisory until 8:00 PM",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.",
"Currently, it is 58°F and clear. It feels like 57°F, with a relative humidity of 61% and winds from the northwest at 9 mph.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
@@ -1036,9 +1036,13 @@ type testAlertDigest struct {
}
type testAlert struct {
Event string
Headline string
Severity string
Event string
Headline string
Severity string
PeriodBegins string
PeriodEnds string
Instruction string
Description string
}
type testSPCOutlooks struct {

View File

@@ -1,6 +1,6 @@
{{ define "alert_digest" }}{{ if and .Modules.AlertDigest .Modules.AlertDigest.Relevant }}## Active Alerts
{{ range .Modules.AlertDigest.Relevant -}}
- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**{{ with .Headline }}: {{ . }}{{ end }}
- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**: {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }} in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}.{{ with .Instruction }} {{ . }}{{ else }}{{ with .Description }} {{ . }}{{ end }}{{ end }}
{{ end }}
{{ end }}{{ end }}