Update the alert digest wording
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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."},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user