Centralize NWS forecast discussion section headings

This commit is contained in:
2026-08-02 20:50:51 +00:00
parent f720b6cdc0
commit f402e27542
2 changed files with 194 additions and 41 deletions

View File

@@ -27,8 +27,28 @@ type ForecastDiscussionSection struct {
Text string Text string
} }
const (
forecastDiscussionSectionKeyMessages = "KEY MESSAGES"
forecastDiscussionSectionShortTerm = "SHORT TERM"
forecastDiscussionSectionLongTerm = "LONG TERM"
forecastDiscussionSectionAviation = "AVIATION"
forecastDiscussionSectionIdentityPattern = "KEY MESSAGES|SHORT TERM|LONG TERM|AVIATION"
)
type forecastDiscussionSectionHeading struct {
section string
qualifier string
}
type forecastDiscussionSectionBlock struct {
heading forecastDiscussionSectionHeading
body []string
}
var ( var (
forecastDiscussionHeaderRE = regexp.MustCompile(`^\.(KEY MESSAGES|SHORT TERM|LONG TERM|AVIATION)\.\.\.(.*)$`) forecastDiscussionEllipsisHeadingRE = regexp.MustCompile(`^\.(` + forecastDiscussionSectionIdentityPattern + `)\.\.\.(.*)$`)
forecastDiscussionSlashHeadingRE = regexp.MustCompile(`^\.(` + forecastDiscussionSectionIdentityPattern + `)[ \t]+/([^/]+)/\.\.\.$`)
forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`) forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`)
forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`) forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`)
forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`) forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`)
@@ -99,20 +119,20 @@ func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) {
IssuedAt: issuedAt.UTC(), IssuedAt: issuedAt.UTC(),
} }
if block, ok := extractForecastDiscussionSection(lines, "KEY MESSAGES"); ok { if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionKeyMessages); ok {
out.KeyMessages = parseForecastDiscussionKeyMessages(block) out.KeyMessages = parseForecastDiscussionKeyMessages(block.body)
} }
if block, ok := extractForecastDiscussionSection(lines, "SHORT TERM"); ok { if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionShortTerm); ok {
section, err := parseForecastDiscussionTextSection(block) section, err := parseForecastDiscussionTextSection(block)
if err != nil { if err != nil {
return ForecastDiscussion{}, fmt.Errorf("parse SHORT TERM: %w", err) return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", forecastDiscussionSectionShortTerm, err)
} }
out.ShortTerm = &section out.ShortTerm = &section
} }
if block, ok := extractForecastDiscussionSection(lines, "LONG TERM"); ok { if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionLongTerm); ok {
section, err := parseForecastDiscussionTextSection(block) section, err := parseForecastDiscussionTextSection(block)
if err != nil { if err != nil {
return ForecastDiscussion{}, fmt.Errorf("parse LONG TERM: %w", err) return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", forecastDiscussionSectionLongTerm, err)
} }
out.LongTerm = &section out.LongTerm = &section
} }
@@ -386,40 +406,56 @@ func forecastDiscussionLocation(abbrev string) (*time.Location, error) {
return time.FixedZone(abbr, offset), nil return time.FixedZone(abbr, offset), nil
} }
func extractForecastDiscussionSection(lines []string, section string) ([]string, bool) { func parseForecastDiscussionSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
target := "." + section + "..." line = strings.TrimSpace(line)
if m := forecastDiscussionEllipsisHeadingRE.FindStringSubmatch(line); len(m) == 3 {
return forecastDiscussionSectionHeading{
section: m[1],
qualifier: strings.TrimSpace(m[2]),
}, true
}
if m := forecastDiscussionSlashHeadingRE.FindStringSubmatch(line); len(m) == 3 {
qualifier := strings.TrimSpace(m[2])
if qualifier == "" {
return forecastDiscussionSectionHeading{}, false
}
return forecastDiscussionSectionHeading{
section: m[1],
qualifier: qualifier,
}, true
}
return forecastDiscussionSectionHeading{}, false
}
func extractForecastDiscussionSection(lines []string, section string) (forecastDiscussionSectionBlock, bool) {
for i, raw := range lines { for i, raw := range lines {
line := strings.TrimSpace(raw) heading, ok := parseForecastDiscussionSectionHeading(raw)
if !strings.HasPrefix(line, target) { if !ok || heading.section != section {
continue continue
} }
out := []string{line} block := forecastDiscussionSectionBlock{heading: heading}
for j := i + 1; j < len(lines); j++ { for j := i + 1; j < len(lines); j++ {
next := strings.TrimSpace(lines[j]) next := strings.TrimSpace(lines[j])
if next == "&&" || next == "$$" || strings.Contains(next, "WATCHES/WARNINGS/ADVISORIES") { if next == "&&" || next == "$$" || strings.Contains(next, "WATCHES/WARNINGS/ADVISORIES") {
break break
} }
if j > i+1 && isForecastDiscussionSectionHeader(next) { if _, ok := parseForecastDiscussionSectionHeading(next); ok {
break break
} }
out = append(out, lines[j]) block.body = append(block.body, lines[j])
} }
return out, true return block, true
} }
return nil, false return forecastDiscussionSectionBlock{}, false
} }
func isForecastDiscussionSectionHeader(line string) bool { func parseForecastDiscussionKeyMessages(body []string) []string {
return forecastDiscussionHeaderRE.MatchString(strings.TrimSpace(line)) if len(body) == 0 {
}
func parseForecastDiscussionKeyMessages(block []string) []string {
if len(block) <= 1 {
return nil return nil
} }
body := trimBlankLines(block[1:]) body = trimBlankLines(body)
var messages []string var messages []string
var current strings.Builder var current strings.Builder
@@ -452,16 +488,12 @@ func parseForecastDiscussionKeyMessages(block []string) []string {
return messages return messages
} }
func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSection, error) { func parseForecastDiscussionTextSection(block forecastDiscussionSectionBlock) (ForecastDiscussionSection, error) {
if len(block) == 0 {
return ForecastDiscussionSection{}, fmt.Errorf("empty section")
}
section := ForecastDiscussionSection{ section := ForecastDiscussionSection{
Qualifier: parseForecastDiscussionQualifier(strings.TrimSpace(block[0])), Qualifier: block.heading.qualifier,
} }
body := trimBlankLines(block[1:]) body := trimBlankLines(block.body)
if len(body) == 0 { if len(body) == 0 {
return section, nil return section, nil
} }
@@ -482,14 +514,6 @@ func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSecti
return section, nil return section, nil
} }
func parseForecastDiscussionQualifier(header string) string {
m := forecastDiscussionHeaderRE.FindStringSubmatch(header)
if len(m) != 3 {
return ""
}
return strings.TrimSpace(m[2])
}
func trimBlankLines(lines []string) []string { func trimBlankLines(lines []string) []string {
start := 0 start := 0
for start < len(lines) && strings.TrimSpace(lines[start]) == "" { for start < len(lines) && strings.TrimSpace(lines[start]) == "" {

View File

@@ -8,6 +8,135 @@ import (
"time" "time"
) )
func TestParseForecastDiscussionSectionHeading(t *testing.T) {
tests := []struct {
name string
line string
wantSection string
wantQualifier string
wantOK bool
}{
{
name: "ellipsis-first key messages",
line: ".KEY MESSAGES... (Through Tonight)",
wantSection: forecastDiscussionSectionKeyMessages,
wantQualifier: "(Through Tonight)",
wantOK: true,
},
{
name: "ellipsis-first short term",
line: ".SHORT TERM... (Sunday)",
wantSection: forecastDiscussionSectionShortTerm,
wantQualifier: "(Sunday)",
wantOK: true,
},
{
name: "ellipsis-first long term",
line: ".LONG TERM... (Monday Through Friday)",
wantSection: forecastDiscussionSectionLongTerm,
wantQualifier: "(Monday Through Friday)",
wantOK: true,
},
{
name: "ellipsis-first aviation without qualifier",
line: ".AVIATION...",
wantSection: forecastDiscussionSectionAviation,
wantOK: true,
},
{
name: "slash-qualified key messages",
line: ".KEY MESSAGES /Tonight/...",
wantSection: forecastDiscussionSectionKeyMessages,
wantQualifier: "Tonight",
wantOK: true,
},
{
name: "slash-qualified short term with outer whitespace",
line: " .SHORT TERM /Through Late Sunday Night/... ",
wantSection: forecastDiscussionSectionShortTerm,
wantQualifier: "Through Late Sunday Night",
wantOK: true,
},
{
name: "slash-qualified long term",
line: ".LONG TERM /Monday through Next Saturday/...",
wantSection: forecastDiscussionSectionLongTerm,
wantQualifier: "Monday through Next Saturday",
wantOK: true,
},
{
name: "slash-qualified aviation",
line: ".AVIATION /18Z TAFS/...",
wantSection: forecastDiscussionSectionAviation,
wantQualifier: "18Z TAFS",
wantOK: true,
},
{
name: "unknown identity",
line: ".SYNOPSIS... Overview",
wantOK: false,
},
{
name: "slash qualifier missing opening slash",
line: ".SHORT TERM Through Tonight/...",
wantOK: false,
},
{
name: "slash qualifier missing closing slash",
line: ".SHORT TERM /Through Tonight...",
wantOK: false,
},
{
name: "slash qualifier contains embedded slash",
line: ".SHORT TERM /Tonight/Sunday/...",
wantOK: false,
},
{
name: "slash qualifier whitespace only",
line: ".SHORT TERM / /...",
wantOK: false,
},
{
name: "slash qualifier missing required whitespace",
line: ".SHORT TERM/Through Tonight/...",
wantOK: false,
},
{
name: "slash qualifier missing final ellipsis",
line: ".SHORT TERM /Through Tonight/..",
wantOK: false,
},
{
name: "slash qualifier has trailing text",
line: ".SHORT TERM /Through Tonight/... more",
wantOK: false,
},
{
name: "lowercase identity",
line: ".short term... (Tonight)",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := parseForecastDiscussionSectionHeading(tt.line)
if ok != tt.wantOK {
t.Fatalf("parseForecastDiscussionSectionHeading(%q) ok = %t, want %t", tt.line, ok, tt.wantOK)
}
if !ok {
return
}
if got.section != tt.wantSection {
t.Fatalf("section = %q, want %q", got.section, tt.wantSection)
}
if got.qualifier != tt.wantQualifier {
t.Fatalf("qualifier = %q, want %q", got.qualifier, tt.wantQualifier)
}
})
}
}
func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) { func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) {
raw := loadForecastDiscussionSampleHTML(t) raw := loadForecastDiscussionSampleHTML(t)