Scan NWS forecast discussion blocks once
This commit is contained in:
@@ -121,23 +121,31 @@ func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) {
|
|||||||
IssuedAt: issuedAt.UTC(),
|
IssuedAt: issuedAt.UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleKeyMessages)); ok {
|
seenRoles := make(map[forecastDiscussionSectionRole]bool, len(forecastDiscussionSectionRoles))
|
||||||
out.KeyMessages = parseForecastDiscussionKeyMessages(block.body)
|
for _, block := range parseForecastDiscussionSectionBlocks(lines) {
|
||||||
|
role, ok := forecastDiscussionSectionRoles[block.heading.section]
|
||||||
|
if !ok || seenRoles[role] {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm)); ok {
|
seenRoles[role] = true
|
||||||
|
|
||||||
|
switch role {
|
||||||
|
case forecastDiscussionSectionRoleKeyMessages:
|
||||||
|
out.KeyMessages = parseForecastDiscussionKeyMessages(block.body)
|
||||||
|
case forecastDiscussionSectionRoleShortTerm:
|
||||||
section, err := parseForecastDiscussionTextSection(block)
|
section, err := parseForecastDiscussionTextSection(block)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
||||||
}
|
}
|
||||||
out.ShortTerm = §ion
|
out.ShortTerm = §ion
|
||||||
}
|
case forecastDiscussionSectionRoleLongTerm:
|
||||||
if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleLongTerm)); ok {
|
|
||||||
section, err := parseForecastDiscussionTextSection(block)
|
section, err := parseForecastDiscussionTextSection(block)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
||||||
}
|
}
|
||||||
out.LongTerm = §ion
|
out.LongTerm = §ion
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -500,36 +508,42 @@ func isForecastDiscussionHorizontalWhitespace(b byte) bool {
|
|||||||
return b == ' ' || b == '\t'
|
return b == ' ' || b == '\t'
|
||||||
}
|
}
|
||||||
|
|
||||||
func forecastDiscussionSectionForRole(role forecastDiscussionSectionRole) string {
|
func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSectionBlock {
|
||||||
for section, registeredRole := range forecastDiscussionSectionRoles {
|
var blocks []forecastDiscussionSectionBlock
|
||||||
if registeredRole == role {
|
var active *forecastDiscussionSectionBlock
|
||||||
return section
|
|
||||||
|
finish := func() {
|
||||||
|
if active == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
blocks = append(blocks, *active)
|
||||||
return ""
|
active = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractForecastDiscussionSection(lines []string, section string) (forecastDiscussionSectionBlock, bool) {
|
for _, raw := range lines {
|
||||||
for i, raw := range lines {
|
line := strings.TrimSpace(raw)
|
||||||
heading, ok := parseForecastDiscussionSectionHeading(raw)
|
if line == "$$" {
|
||||||
if !ok || heading.section != section {
|
finish()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if line == "&&" || strings.Contains(line, "WATCHES/WARNINGS/ADVISORIES") {
|
||||||
|
finish()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
block := forecastDiscussionSectionBlock{heading: heading}
|
heading, ok := parseForecastDiscussionSectionHeading(raw)
|
||||||
for j := i + 1; j < len(lines); j++ {
|
if ok {
|
||||||
next := strings.TrimSpace(lines[j])
|
finish()
|
||||||
if next == "&&" || next == "$$" || strings.Contains(next, "WATCHES/WARNINGS/ADVISORIES") {
|
active = &forecastDiscussionSectionBlock{heading: heading}
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := parseForecastDiscussionSectionHeading(next); ok {
|
if active != nil {
|
||||||
break
|
active.body = append(active.body, raw)
|
||||||
}
|
}
|
||||||
block.body = append(block.body, lines[j])
|
|
||||||
}
|
}
|
||||||
return block, true
|
finish()
|
||||||
}
|
|
||||||
return forecastDiscussionSectionBlock{}, false
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseForecastDiscussionKeyMessages(body []string) []string {
|
func parseForecastDiscussionKeyMessages(body []string) []string {
|
||||||
|
|||||||
@@ -236,41 +236,82 @@ func TestForecastDiscussionSectionRoleHeadingsParse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractForecastDiscussionSectionStopsAtSlashQualifiedHeading(t *testing.T) {
|
func TestParseForecastDiscussionSectionBlocksRetainsOrderAndEmptyBodies(t *testing.T) {
|
||||||
block, ok := extractForecastDiscussionSection([]string{
|
got := parseForecastDiscussionSectionBlocks([]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{
|
|
||||||
".SHORT TERM... (Tonight)",
|
".SHORT TERM... (Tonight)",
|
||||||
".LONG TERM... (Tomorrow)",
|
".LONG TERM... (Tomorrow)",
|
||||||
"Long-term prose.",
|
"Long-term prose.",
|
||||||
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm))
|
})
|
||||||
if !ok {
|
want := []forecastDiscussionSectionBlock{
|
||||||
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
|
{heading: forecastDiscussionSectionHeading{section: "SHORT TERM", qualifier: "(Tonight)"}},
|
||||||
|
{
|
||||||
|
heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "(Tomorrow)"},
|
||||||
|
body: []string{"Long-term prose."},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if len(block.body) != 0 {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("body = %#v, want empty body", block.body)
|
t.Fatalf("blocks = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractForecastDiscussionSectionKeepsMalformedHeadingLikeLines(t *testing.T) {
|
func TestParseForecastDiscussionSectionBlocksStopsAtTerminators(t *testing.T) {
|
||||||
block, ok := extractForecastDiscussionSection([]string{
|
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... (Tonight)",
|
||||||
".short term... Lowercase prose stays in the body.",
|
".short term... Lowercase prose stays in the body.",
|
||||||
".LONG TERM /Tomorrow/..",
|
".LONG TERM /Tomorrow/..",
|
||||||
@@ -278,18 +319,63 @@ func TestExtractForecastDiscussionSectionKeepsMalformedHeadingLikeLines(t *testi
|
|||||||
"Expected short-term prose.",
|
"Expected short-term prose.",
|
||||||
".LONG TERM... (Tomorrow)",
|
".LONG TERM... (Tomorrow)",
|
||||||
"Long-term prose.",
|
"Long-term prose.",
|
||||||
}, forecastDiscussionSectionForRole(forecastDiscussionSectionRoleShortTerm))
|
})
|
||||||
if !ok {
|
want := []forecastDiscussionSectionBlock{
|
||||||
t.Fatalf("extractForecastDiscussionSection() found no SHORT TERM block")
|
{
|
||||||
}
|
heading: forecastDiscussionSectionHeading{section: "SHORT TERM", qualifier: "(Tonight)"},
|
||||||
wantBody := []string{
|
body: []string{
|
||||||
".short term... Lowercase prose stays in the body.",
|
".short term... Lowercase prose stays in the body.",
|
||||||
".LONG TERM /Tomorrow/..",
|
".LONG TERM /Tomorrow/..",
|
||||||
".LONG TERM /Tomorrow/... more",
|
".LONG TERM /Tomorrow/... more",
|
||||||
"Expected short-term prose.",
|
"Expected short-term prose.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: forecastDiscussionSectionHeading{section: "LONG TERM", qualifier: "(Tomorrow)"},
|
||||||
|
body: []string{"Long-term prose."},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(block.body, wantBody) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("body = %#v, want %#v", block.body, wantBody)
|
t.Fatalf("blocks = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user