Add SPC Outlook summaries to the alert digest template

This commit is contained in:
2026-06-16 21:42:20 -05:00
parent 5d416cfc4a
commit 3900b3313b
10 changed files with 229 additions and 35 deletions

View File

@@ -96,8 +96,10 @@ subset of discussion fields. Hourly Report defaults this module to
report-period outlooks. It emits `checked: true` for a successfully fetched report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, `period_begins`, `period_ends`, image URL, and whether the such as risk label, `period_begins`, `period_ends`, image URL, and whether the
outlook contains the configured location. It does not emit GeoJSON geometry, outlook contains the configured location. It also emits a curated `risk_digest`
source URL, expiration time, or severity rank. 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 Prompt-facing module intervals use friendly local `period_begins` and
`period_ends` labels. Canonical report metadata, source provenance, `period_ends` labels. Canonical report metadata, source provenance,

View File

@@ -225,12 +225,19 @@ validation.
- `location_name` - `location_name`
- `outlook_count` - `outlook_count`
- `outlooks` - `outlooks`
- `risk_digest`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`, Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and `period_begins`, `period_ends`, `issued_at`, `contains_location`, and
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and `image_url`. It omits GeoJSON geometry, source URL, expiration time, and
severity rank. severity rank.
The optional `risk_digest` list is a curated report-rendering subset of
categorical outlooks that overlap the report period, contain the configured
location, and meet the minimum severity threshold. Entries include `label_text`,
`risk_label`, `period_begins`, and `period_ends`; they do not expose severity
rank.
`spc_convective_discussion` emits a narrative stanza only when a retained `spc_convective_discussion` emits a narrative stanza only when a retained
report-period categorical outlook has severity rank `3` or higher and matching report-period categorical outlook has severity rank `3` or higher and matching
discussion text is available. Its output includes `included_because` and discussion text is available. Its output includes `included_because` and

View File

@@ -79,7 +79,9 @@ Daily and Tomorrow call the shared `daypart_forecast` partial. Today calls
Today, Tomorrow, and Hourly call the shared `alert_digest` and Today, Tomorrow, and Hourly call the shared `alert_digest` and
`precipitation_timing` partials. Partial files are parsed with each top-level `precipitation_timing` partials. Partial files are parsed with each top-level
template at render time and receive the same typed render context as the template at render time and receive the same typed render context as the
caller. caller. The `alert_digest` partial renders the combined Alerts and Risk
Products section from relevant NWS alerts and curated SPC outlook digest
records.
## Schema Contract ## Schema Contract

View File

@@ -16,7 +16,8 @@ are:
Shared named partials live under `internal/reporttemplate/templates/partials/`: Shared named partials live under `internal/reporttemplate/templates/partials/`:
- `alert_digest.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly - `alert_digest.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly for the
combined Alerts and Risk Products section
- `daypart_forecast.md.tmpl`, used by Daily and Tomorrow - `daypart_forecast.md.tmpl`, used by Daily and Tomorrow
- `today_daypart_forecast.md.tmpl`, used by Today - `today_daypart_forecast.md.tmpl`, used by Today
- `precipitation_timing.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly - `precipitation_timing.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly
@@ -369,6 +370,11 @@ YAML.
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest` | []briefing.SPCConvectiveOutlookDigest | Curated categorical outlooks for the shared Alerts and Risk Products section. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].LabelText` | string | Human-readable outlook label. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].RiskLabel` | string | Sentence-style risk label for report rendering. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. | | `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. |
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. | | `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. | | `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |

View File

@@ -566,7 +566,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"# Hourly Report", "# Hourly Report",
"Storm chances increase through late morning.", "Storm chances increase through late morning.",
"## Active Alerts", "## Alert Digest",
"- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM. Avoid low-water crossings.", "- **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", "## Precipitation Timing",
"A cold front is moving into the region.", "A cold front is moving into the region.",

View File

@@ -8,8 +8,8 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3 const defaultSPCConvectiveDiscussionMinimumSeverityRank = defaultSPCRiskDigestMinimumSeverityRank
const spcCategoricalOutlookType = "categorical" const spcCategoricalOutlookType = defaultSPCRiskDigestOutlookType
type SPCConvectiveDiscussionModule struct { type SPCConvectiveDiscussionModule struct {
IncludedBecause string `json:"included_because"` IncludedBecause string `json:"included_because"`

View File

@@ -1,13 +1,18 @@
package briefing package briefing
import ( import (
"strings"
"time" "time"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const defaultSPCRiskDigestOutlookType = "categorical"
const defaultSPCRiskDigestMinimumSeverityRank = 3
type SPCConvectiveOutlooksModule struct { type SPCConvectiveOutlooksModule struct {
Checked bool `json:"checked"` Checked bool `json:"checked"`
AsOf string `json:"as_of,omitempty"` AsOf string `json:"as_of,omitempty"`
@@ -16,6 +21,7 @@ type SPCConvectiveOutlooksModule struct {
LocationName string `json:"location_name,omitempty"` LocationName string `json:"location_name,omitempty"`
OutlookCount int `json:"outlook_count"` OutlookCount int `json:"outlook_count"`
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"` Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
RiskDigest []SPCConvectiveOutlookDigest `json:"risk_digest,omitempty"`
} }
type SPCConvectiveOutlookRecord struct { type SPCConvectiveOutlookRecord struct {
@@ -30,6 +36,13 @@ type SPCConvectiveOutlookRecord struct {
ImageURL string `json:"image_url,omitempty"` ImageURL string `json:"image_url,omitempty"`
} }
type SPCConvectiveOutlookDigest struct {
LabelText string `json:"label_text,omitempty"`
RiskLabel string `json:"risk_label,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
}
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := SPCConvectiveOutlooksModule{} value := SPCConvectiveOutlooksModule{}
run := ctx.Collected.SPCConvectiveOutlooks run := ctx.Collected.SPCConvectiveOutlooks
@@ -49,10 +62,23 @@ func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output,
} }
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone) value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
value.RiskDigest = spcConvectiveOutlookRiskDigest(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone, defaultSPCRiskDigestPolicy())
value.OutlookCount = len(value.Outlooks) value.OutlookCount = len(value.Outlooks)
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
} }
type spcRiskDigestPolicy struct {
OutlookType string
MinimumSeverityRank int
}
func defaultSPCRiskDigestPolicy() spcRiskDigestPolicy {
return spcRiskDigestPolicy{
OutlookType: defaultSPCRiskDigestOutlookType,
MinimumSeverityRank: defaultSPCRiskDigestMinimumSeverityRank,
}
}
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord { func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks)) records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
for _, outlook := range outlooks { for _, outlook := range outlooks {
@@ -75,6 +101,42 @@ func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, repor
return records return records
} }
func spcConvectiveOutlookRiskDigest(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string, policy spcRiskDigestPolicy) []SPCConvectiveOutlookDigest {
records := make([]SPCConvectiveOutlookDigest, 0, len(outlooks))
for _, outlook := range outlooks {
if outlook.OutlookType != policy.OutlookType {
continue
}
if outlook.SeverityRank == nil || *outlook.SeverityRank < policy.MinimumSeverityRank {
continue
}
if !outlook.ContainsLocation {
continue
}
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
continue
}
records = append(records, SPCConvectiveOutlookDigest{
LabelText: outlook.LabelText,
RiskLabel: spcRiskDigestLabel(outlook.LabelText),
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
})
}
return records
}
func spcRiskDigestLabel(labelText string) string {
label := strings.TrimSpace(labelText)
if label == "" {
return ""
}
runes := []rune(strings.ToLower(label))
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func friendlyOptionalTime(value *time.Time, timezone string) string { func friendlyOptionalTime(value *time.Time, timezone string) string {
if value == nil { if value == nil {
return "" return ""

View File

@@ -71,12 +71,19 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
if !got.ContainsLocation || got.ImageURL == "" { if !got.ContainsLocation || got.ImageURL == "" {
t.Fatalf("outlook = %#v, want location flag and image URL", got) t.Fatalf("outlook = %#v, want location flag and image URL", got)
} }
if len(value.RiskDigest) != 1 {
t.Fatalf("RiskDigest length = %d, want 1", len(value.RiskDigest))
}
digest := value.RiskDigest[0]
if digest.LabelText != "Slight Risk" || digest.RiskLabel != "Slight risk" || digest.PeriodBegins != "2026-05-29 at 11:00 AM" || digest.PeriodEnds != "2026-05-30 at 7:00 AM" {
t.Fatalf("risk digest = %#v, want prompt-facing slight risk record", digest)
}
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal() error = %v", err) t.Fatalf("Marshal() error = %v", err)
} }
text := string(data) text := string(data)
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "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", "period_begins", "period_ends", "contains_location", "image_url"} {
if !strings.Contains(text, field) { if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field) t.Fatalf("json = %s, want field %s", text, field)
} }
@@ -88,6 +95,71 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
} }
} }
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
if defaultSPCRiskDigestOutlookType != "categorical" {
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
}
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
}
}
func TestSPCConvectiveOutlooksRiskDigestFilters(t *testing.T) {
tests := []struct {
name string
outlook weatherdata.ConvectiveOutlook
wantRisk bool
}{
{
name: "categorical slight risk included",
outlook: spcRiskDigestTestOutlook("categorical", "Slight Risk", 3, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
wantRisk: true,
},
{
name: "marginal risk excluded",
outlook: spcRiskDigestTestOutlook("categorical", "Marginal Risk", 2, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
},
{
name: "non categorical high rank excluded",
outlook: spcRiskDigestTestOutlook("wind", "30% Wind Risk", 30, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
},
{
name: "non overlapping excluded",
outlook: spcRiskDigestTestOutlook("categorical", "Enhanced Risk", 4, true,
"2026-05-30T07:00:00-05:00", "2026-05-31T07:00:00-05:00"),
},
{
name: "location miss excluded",
outlook: spcRiskDigestTestOutlook("categorical", "Moderate Risk", 5, false,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{tt.outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{tt.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)
gotRisk := len(value.RiskDigest) > 0
if gotRisk != tt.wantRisk {
t.Fatalf("RiskDigest = %#v, want included=%v", value.RiskDigest, tt.wantRisk)
}
})
}
}
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) { func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()
@@ -117,6 +189,9 @@ func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
if value.OutlookCount != 0 || len(value.Outlooks) != 0 { if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value) t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
} }
if len(value.RiskDigest) != 0 {
t.Fatalf("RiskDigest = %#v, want non-overlapping outlook omitted", value.RiskDigest)
}
} }
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) { func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
@@ -167,3 +242,16 @@ func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *tes
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value) t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
} }
} }
func spcRiskDigestTestOutlook(outlookType string, labelText string, rank int, containsLocation bool, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
return weatherdata.ConvectiveOutlook{
ID: labelText,
Day: 1,
OutlookType: outlookType,
LabelText: labelText,
SeverityRank: &rank,
ValidFrom: mustParseModuleTime(validFrom),
ValidTo: mustParseModuleTime(validTo),
ContainsLocation: containsLocation,
}
}

View File

@@ -243,7 +243,8 @@ func TestRenderHourly(t *testing.T) {
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."}}, 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{ SPCConvectiveOutlooks: &testSPCOutlooks{
Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}}, Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}},
RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "May 29 at 8:00 AM", PeriodEnds: "May 29 at 2:00 PM"}},
}, },
SPCConvectiveDiscussion: &testSPCDiscussion{ SPCConvectiveDiscussion: &testSPCDiscussion{
Discussions: []testSPCDiscussionRecord{{Summary: "Strong storms may develop late morning."}}, Discussions: []testSPCDiscussionRecord{{Summary: "Strong storms may develop late morning."}},
@@ -268,8 +269,9 @@ func TestRenderHourly(t *testing.T) {
"**Updated:** Saturday, June 14, 2026 at 9:14 AM", "**Updated:** Saturday, June 14, 2026 at 9:14 AM",
"Storm chances increase through late morning.", "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.", "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", "## Alert Digest",
"- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.", "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from May 29 at 8:00 AM to May 29 at 2:00 PM.",
"- **9:00 AM:** 74°F and cloudy.", "- **9:00 AM:** 74°F and cloudy.",
"- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", "- **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.", "- **10:00 AM** to **12:00 PM**: Expect showers. The peak precipitation chance is 70% at 10:00 AM.",
@@ -285,7 +287,7 @@ func TestRenderHourly(t *testing.T) {
} }
assertOrderedText(t, text, []string{ assertOrderedText(t, text, []string{
"# Hourly Report", "# Hourly Report",
"## Active Alerts", "## Alert Digest",
"## Current Conditions", "## Current Conditions",
"## Hourly Forecast", "## Hourly Forecast",
"## Precipitation Timing", "## Precipitation Timing",
@@ -354,6 +356,9 @@ func TestRenderTomorrow(t *testing.T) {
AlertDigest: &testAlertDigest{ AlertDigest: &testAlertDigest{
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."}}, 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."}},
}, },
SPCConvectiveOutlooks: &testSPCOutlooks{
RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}},
},
}, },
}) })
if err != nil { if err != nil {
@@ -365,8 +370,9 @@ func TestRenderTomorrow(t *testing.T) {
"**Forecast date:** Monday, June 15, 2026", "**Forecast date:** Monday, June 15, 2026",
"**Updated:** Sunday, June 14, 2026 at 9:14 AM", "**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"Tomorrow starts dry before showers return later in the day.", "Tomorrow starts dry before showers return later in the day.",
"## Active Alerts", "## Alert Digest",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.", "- **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.", "- **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%.", "- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.",
@@ -390,7 +396,7 @@ func TestRenderTomorrow(t *testing.T) {
assertOrderedText(t, text, []string{ assertOrderedText(t, text, []string{
"# Monday's Weather", "# Monday's Weather",
"Tomorrow starts dry before showers return later in the day.", "Tomorrow starts dry before showers return later in the day.",
"## Active Alerts", "## Alert Digest",
"## Daypart Forecast", "## Daypart Forecast",
"- **Overnight:**", "- **Overnight:**",
"- **Morning:**", "- **Morning:**",
@@ -452,6 +458,9 @@ func TestRenderDaily(t *testing.T) {
AlertDigest: &testAlertDigest{ AlertDigest: &testAlertDigest{
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."}}, 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."}},
}, },
SPCConvectiveOutlooks: &testSPCOutlooks{
RiskDigest: []testSPCRiskDigest{{LabelText: "Enhanced Risk", RiskLabel: "Enhanced risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}},
},
}, },
}) })
if err != nil { if err != nil {
@@ -463,8 +472,9 @@ func TestRenderDaily(t *testing.T) {
"**Forecast date:** Monday, June 15, 2026", "**Forecast date:** Monday, June 15, 2026",
"**Updated:** Sunday, June 14, 2026 at 9:14 AM", "**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"The selected day starts dry before showers return later in the day.", "The selected day starts dry before showers return later in the day.",
"## Active Alerts", "## Alert Digest",
"- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM. Monitor creek levels.", "- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM. Monitor creek levels.",
"- **SPC Convective Outlook**: Enhanced risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **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%.", "- **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.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.",
@@ -479,7 +489,7 @@ func TestRenderDaily(t *testing.T) {
assertOrderedText(t, text, []string{ assertOrderedText(t, text, []string{
"# Monday's Weather", "# Monday's Weather",
"The selected day starts dry before showers return later in the day.", "The selected day starts dry before showers return later in the day.",
"## Active Alerts", "## Alert Digest",
"## Daypart Forecast", "## Daypart Forecast",
"- **Morning:**", "- **Morning:**",
"- **Afternoon:**", "- **Afternoon:**",
@@ -552,6 +562,9 @@ func TestRenderToday(t *testing.T) {
AlertDigest: &testAlertDigest{ AlertDigest: &testAlertDigest{
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."}}, 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."}},
}, },
SPCConvectiveOutlooks: &testSPCOutlooks{
RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}},
},
TodayPlanning: &testTodayPlanning{ TodayPlanning: &testTodayPlanning{
MorningReadiness: []string{"Morning weather looks routine."}, MorningReadiness: []string{"Morning weather looks routine."},
LateDayChangeWatch: []string{"Watch late-day shower timing."}, LateDayChangeWatch: []string{"Watch late-day shower timing."},
@@ -567,8 +580,9 @@ func TestRenderToday(t *testing.T) {
"**Forecast date:** Monday, June 15, 2026", "**Forecast date:** Monday, June 15, 2026",
"**Updated:** Monday, June 15, 2026 at 7:14 AM", "**Updated:** Monday, June 15, 2026 at 7:14 AM",
"Today starts dry before showers return later in the day.", "Today starts dry before showers return later in the day.",
"## Active Alerts", "## Alert Digest",
"- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.",
"- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.",
"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.", "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.", "- **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%.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.",
@@ -584,7 +598,7 @@ func TestRenderToday(t *testing.T) {
assertOrderedText(t, text, []string{ assertOrderedText(t, text, []string{
"# Today's Weather", "# Today's Weather",
"Today starts dry before showers return later in the day.", "Today starts dry before showers return later in the day.",
"## Active Alerts", "## Alert Digest",
"## Current Conditions", "## Current Conditions",
"## Daypart Forecast", "## Daypart Forecast",
"- **Morning:**", "- **Morning:**",
@@ -673,8 +687,8 @@ func TestRenderDaypartTemplatesUseRichHelperFields(t *testing.T) {
t.Fatalf("rendered template missing %q:\n%s", want, text) t.Fatalf("rendered template missing %q:\n%s", want, text)
} }
} }
if strings.Contains(text, "## Active Alerts") { if strings.Contains(text, "## Alert Digest") {
t.Fatalf("rendered template includes empty Active Alerts section:\n%s", text) t.Fatalf("rendered template includes empty Alerts and Risk Products section:\n%s", text)
} }
if strings.Contains(text, "\n\n\n") { if strings.Contains(text, "\n\n\n") {
t.Fatalf("rendered template includes excess blank lines:\n%s", text) t.Fatalf("rendered template includes excess blank lines:\n%s", text)
@@ -752,7 +766,7 @@ func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) {
t.Fatalf("Render() error = %v", err) t.Fatalf("Render() error = %v", err)
} }
text := string(rendered) text := string(rendered)
for _, unwanted := range []string{"## Active Alerts", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} { for _, unwanted := range []string{"## Alert Digest", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} {
if strings.Contains(text, unwanted) { if strings.Contains(text, unwanted) {
t.Fatalf("clear render includes %q:\n%s", unwanted, text) t.Fatalf("clear render includes %q:\n%s", unwanted, text)
} }
@@ -928,23 +942,26 @@ type testModules struct {
} }
type testTomorrowModules struct { type testTomorrowModules struct {
Dayparts []testTomorrowDaypart Dayparts []testTomorrowDaypart
PrecipTiming *testPrecipTiming PrecipTiming *testPrecipTiming
AlertDigest *testAlertDigest AlertDigest *testAlertDigest
SPCConvectiveOutlooks *testSPCOutlooks
} }
type testTodayModules struct { type testTodayModules struct {
CurrentConditions *testCurrentConditions CurrentConditions *testCurrentConditions
Dayparts []testTomorrowDaypart Dayparts []testTomorrowDaypart
PrecipTiming *testPrecipTiming PrecipTiming *testPrecipTiming
AlertDigest *testAlertDigest AlertDigest *testAlertDigest
TodayPlanning *testTodayPlanning SPCConvectiveOutlooks *testSPCOutlooks
TodayPlanning *testTodayPlanning
} }
type testDailyModules struct { type testDailyModules struct {
Dayparts []testDailyDaypart Dayparts []testDailyDaypart
PrecipTiming *testPrecipTiming PrecipTiming *testPrecipTiming
AlertDigest *testAlertDigest AlertDigest *testAlertDigest
SPCConvectiveOutlooks *testSPCOutlooks
} }
type testTodayPlanning struct { type testTodayPlanning struct {
@@ -1046,7 +1063,8 @@ type testAlert struct {
} }
type testSPCOutlooks struct { type testSPCOutlooks struct {
Outlooks []testSPCOutlook Outlooks []testSPCOutlook
RiskDigest []testSPCRiskDigest
} }
type testSPCOutlook struct { type testSPCOutlook struct {
@@ -1057,6 +1075,13 @@ type testSPCOutlook struct {
PeriodEnds string PeriodEnds string
} }
type testSPCRiskDigest struct {
LabelText string
RiskLabel string
PeriodBegins string
PeriodEnds string
}
type testForecastDiscussion struct { type testForecastDiscussion struct {
KeyMessages []string KeyMessages []string
ShortTerm string ShortTerm string

View File

@@ -1,6 +1,8 @@
{{ define "alert_digest" }}{{ if and .Modules.AlertDigest .Modules.AlertDigest.Relevant }}## Active Alerts {{ define "alert_digest" }}{{ if or (and .Modules.AlertDigest .Modules.AlertDigest.Relevant) (and .Modules.SPCConvectiveOutlooks .Modules.SPCConvectiveOutlooks.RiskDigest) }}## Alert Digest
{{ range .Modules.AlertDigest.Relevant -}} {{ with .Modules.AlertDigest }}{{ range .Relevant -}}
- **{{ 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 }} - **{{ 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 }}{{ with .Modules.SPCConvectiveOutlooks }}{{ range .RiskDigest -}}
- **SPC Convective Outlook**: {{ with .RiskLabel }}{{ . }}{{ else }}Convective risk{{ end }} for severe thunderstorms in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}.
{{ end }}{{ end }}
{{ end }}{{ end }} {{ end }}{{ end }}