Add SPC Outlook summaries to the alert digest template
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user