Scan NWS forecast discussion blocks once

This commit is contained in:
2026-08-02 22:56:03 +00:00
parent 2b19a121fa
commit 0a3e52d0e5
2 changed files with 176 additions and 76 deletions

View File

@@ -121,22 +121,30 @@ func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) {
IssuedAt: issuedAt.UTC(),
}
if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleKeyMessages)); ok {
out.KeyMessages = parseForecastDiscussionKeyMessages(block.body)
}
if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm)); ok {
section, err := parseForecastDiscussionTextSection(block)
if err != nil {
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
seenRoles := make(map[forecastDiscussionSectionRole]bool, len(forecastDiscussionSectionRoles))
for _, block := range parseForecastDiscussionSectionBlocks(lines) {
role, ok := forecastDiscussionSectionRoles[block.heading.section]
if !ok || seenRoles[role] {
continue
}
out.ShortTerm = &section
}
if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleLongTerm)); ok {
section, err := parseForecastDiscussionTextSection(block)
if err != nil {
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
seenRoles[role] = true
switch role {
case forecastDiscussionSectionRoleKeyMessages:
out.KeyMessages = parseForecastDiscussionKeyMessages(block.body)
case forecastDiscussionSectionRoleShortTerm:
section, err := parseForecastDiscussionTextSection(block)
if err != nil {
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
}
out.ShortTerm = &section
case forecastDiscussionSectionRoleLongTerm:
section, err := parseForecastDiscussionTextSection(block)
if err != nil {
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
}
out.LongTerm = &section
}
out.LongTerm = &section
}
return out, nil
@@ -500,36 +508,42 @@ func isForecastDiscussionHorizontalWhitespace(b byte) bool {
return b == ' ' || b == '\t'
}
func forecastDiscussionSectionForRole(role forecastDiscussionSectionRole) string {
for section, registeredRole := range forecastDiscussionSectionRoles {
if registeredRole == role {
return section
}
}
return ""
}
func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSectionBlock {
var blocks []forecastDiscussionSectionBlock
var active *forecastDiscussionSectionBlock
func extractForecastDiscussionSection(lines []string, section string) (forecastDiscussionSectionBlock, bool) {
for i, raw := range lines {
heading, ok := parseForecastDiscussionSectionHeading(raw)
if !ok || heading.section != section {
finish := func() {
if active == nil {
return
}
blocks = append(blocks, *active)
active = nil
}
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "$$" {
finish()
break
}
if line == "&&" || strings.Contains(line, "WATCHES/WARNINGS/ADVISORIES") {
finish()
continue
}
block := forecastDiscussionSectionBlock{heading: heading}
for j := i + 1; j < len(lines); j++ {
next := strings.TrimSpace(lines[j])
if next == "&&" || next == "$$" || strings.Contains(next, "WATCHES/WARNINGS/ADVISORIES") {
break
}
if _, ok := parseForecastDiscussionSectionHeading(next); ok {
break
}
block.body = append(block.body, lines[j])
heading, ok := parseForecastDiscussionSectionHeading(raw)
if ok {
finish()
active = &forecastDiscussionSectionBlock{heading: heading}
continue
}
if active != nil {
active.body = append(active.body, raw)
}
return block, true
}
return forecastDiscussionSectionBlock{}, false
finish()
return blocks
}
func parseForecastDiscussionKeyMessages(body []string) []string {

View File

@@ -236,41 +236,82 @@ func TestForecastDiscussionSectionRoleHeadingsParse(t *testing.T) {
}
}
func TestExtractForecastDiscussionSectionStopsAtSlashQualifiedHeading(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
".LONG TERM /Monday through Next Saturday/...",
"Long-term prose.",
".AVIATION /18Z TAFS/...",
"Aviation prose.",
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleLongTerm))
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no LONG TERM block")
}
if block.heading.qualifier != "Monday through Next Saturday" {
t.Fatalf("qualifier = %q, want normalized slash qualifier", block.heading.qualifier)
}
wantBody := []string{"Long-term prose."}
if !reflect.DeepEqual(block.body, wantBody) {
t.Fatalf("body = %#v, want %#v", block.body, wantBody)
}
}
func TestExtractForecastDiscussionSectionAllowsEmptyBodyBeforeHeading(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
func TestParseForecastDiscussionSectionBlocksRetainsOrderAndEmptyBodies(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
".SHORT TERM... (Tonight)",
".LONG TERM... (Tomorrow)",
"Long-term prose.",
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm))
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
})
want := []forecastDiscussionSectionBlock{
{heading: forecastDiscussionSectionHeading{section: "SHORT TERM", qualifier: "(Tonight)"}},
{
heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "(Tomorrow)"},
body: []string{"Long-term prose."},
},
}
if len(block.body) != 0 {
t.Fatalf("body = %#v, want empty body", block.body)
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestExtractForecastDiscussionSectionKeepsMalformedHeadingLikeLines(t *testing.T) {
block, ok := extractForecastDiscussionSection([]string{
func TestParseForecastDiscussionSectionBlocksStopsAtTerminators(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
"Preamble is ignored.",
".KEY MESSAGES...",
"- First message.",
"&&",
".SHORT TERM...",
"Short-term prose.",
".LSX WATCHES/WARNINGS/ADVISORIES...",
"Watch text is ignored.",
"$$",
"WFO LSX",
})
want := []forecastDiscussionSectionBlock{
{
heading: forecastDiscussionSectionHeading{section: "KEY MESSAGES"},
body: []string{"- First message."},
},
{
heading: forecastDiscussionSectionHeading{section: "SHORT TERM"},
body: []string{"Short-term prose."},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionSectionBlocksUsesGenericHeadingsAsBoundaries(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
".SHORT TERM...",
"Short-term prose.",
".SYNOPSIS...",
"Synopsis prose.",
".LONG TERM /Tomorrow/...",
"Long-term prose.",
})
want := []forecastDiscussionSectionBlock{
{
heading: forecastDiscussionSectionHeading{section: "SHORT TERM"},
body: []string{"Short-term prose."},
},
{
heading: forecastDiscussionSectionHeading{section: "SYNOPSIS"},
body: []string{"Synopsis prose."},
},
{
heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "Tomorrow"},
body: []string{"Long-term prose."},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionSectionBlocksKeepsMalformedHeadingLikeLines(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
".SHORT TERM... (Tonight)",
".short term... Lowercase prose stays in the body.",
".LONG TERM /Tomorrow/..",
@@ -278,18 +319,63 @@ func TestExtractForecastDiscussionSectionKeepsMalformedHeadingLikeLines(t *testi
"Expected short-term prose.",
".LONG TERM... (Tomorrow)",
"Long-term prose.",
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm))
if !ok {
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
})
want := []forecastDiscussionSectionBlock{
{
heading: forecastDiscussionSectionHeading{section: "SHORT TERM", qualifier: "(Tonight)"},
body: []string{
".short term... Lowercase prose stays in the body.",
".LONG TERM /Tomorrow/..",
".LONG TERM /Tomorrow/... more",
"Expected short-term prose.",
},
},
{
heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "(Tomorrow)"},
body: []string{"Long-term prose."},
},
}
wantBody := []string{
".short term... Lowercase prose stays in the body.",
".LONG TERM /Tomorrow/..",
".LONG TERM /Tomorrow/... more",
"Expected short-term prose.",
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
if !reflect.DeepEqual(block.body, wantBody) {
t.Fatalf("body = %#v, want %#v", block.body, wantBody)
}
func TestParseForecastDiscussionTextKeepsFirstMappedSections(t *testing.T) {
text := strings.Join([]string{
"AFDLSX",
"National Weather Service Saint Louis MO",
"224 PM CDT Sat Mar 28 2026",
"",
".KEY MESSAGES...",
"&&",
".KEY MESSAGES...",
"- Second key message.",
"&&",
".SHORT TERM... (First short term)",
"First short-term prose.",
"&&",
".SHORT TERM... (Second short term)",
"Second short-term prose.",
"&&",
".LONG TERM... (First long term)",
"First long-term prose.",
"&&",
".LONG TERM... (Second long term)",
"Second long-term prose.",
}, "\n")
got, err := ParseForecastDiscussionText(text)
if err != nil {
t.Fatalf("ParseForecastDiscussionText() error = %v", err)
}
if len(got.KeyMessages) != 0 {
t.Fatalf("KeyMessages = %#v, want empty first block", got.KeyMessages)
}
if got.ShortTerm == nil || got.ShortTerm.Qualifier != "(First short term)" || got.ShortTerm.Text != "First short-term prose." {
t.Fatalf("ShortTerm = %#v, want first section", got.ShortTerm)
}
if got.LongTerm == nil || got.LongTerm.Qualifier != "(First long term)" || got.LongTerm.Text != "First long-term prose." {
t.Fatalf("LongTerm = %#v, want first section", got.LongTerm)
}
}