Handle additional NWS forecast discussion formats
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful

This commit is contained in:
2026-08-03 01:14:47 +00:00
parent 456a46e01b
commit 9b88be4dd2
9 changed files with 374 additions and 544 deletions

View File

@@ -550,6 +550,7 @@ func isForecastDiscussionHorizontalWhitespace(b byte) bool {
func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSectionBlock {
var blocks []forecastDiscussionSectionBlock
var active *forecastDiscussionSectionBlock
embeddedHeadings := false
finish := func() {
if active == nil {
@@ -567,6 +568,7 @@ func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSe
}
if line == "&&" || strings.Contains(line, "WATCHES/WARNINGS/ADVISORIES") {
finish()
embeddedHeadings = false
continue
}
@@ -574,8 +576,17 @@ func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSe
if ok {
finish()
active = &forecastDiscussionSectionBlock{heading: heading}
embeddedHeadings = isForecastDiscussionEmbeddedSectionWrapper(heading.section)
continue
}
if embeddedHeadings {
heading, ok = parseForecastDiscussionEmbeddedSectionHeading(raw)
if ok {
finish()
active = &forecastDiscussionSectionBlock{heading: heading}
continue
}
}
if active != nil {
active.body = append(active.body, raw)
}
@@ -585,6 +596,18 @@ func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSe
return blocks
}
func isForecastDiscussionEmbeddedSectionWrapper(section string) bool {
return section == "PREV DISCUSSION"
}
func parseForecastDiscussionEmbeddedSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
line = strings.TrimSpace(line)
if line == "" || line[0] == '.' {
return forecastDiscussionSectionHeading{}, false
}
return parseForecastDiscussionSectionHeading("." + line)
}
func parseForecastDiscussionKeyMessages(body []string) []string {
body = removeForecastDiscussionPresentationMarkers(body)
body = trimBlankLines(body)
@@ -716,7 +739,32 @@ func isForecastDiscussionKeyMessageMetadataLine(line string) bool {
return true
}
}
return false
return isForecastDiscussionKeyMessageAsOfLine(line)
}
func isForecastDiscussionKeyMessageAsOfLine(line string) bool {
const label = "As of"
if !hasForecastDiscussionASCIIPrefix(line, label) || len(line) == len(label) || !isForecastDiscussionHorizontalWhitespace(line[len(label)]) {
return false
}
remainder := strings.TrimSpace(line[len(label):])
if !strings.HasSuffix(remainder, "...") {
return false
}
fields := strings.Fields(strings.TrimSpace(strings.TrimSuffix(remainder, "...")))
if len(fields) != 3 {
return false
}
if _, _, err := parseForecastDiscussionClock(fields[0], fields[1]); err != nil {
return false
}
switch strings.ToLower(fields[2]) {
case "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday":
return true
default:
return false
}
}
func stripForecastDiscussionKeyMessageMarker(line string) (string, bool) {
@@ -740,11 +788,23 @@ func stripForecastDiscussionKeyMessageMarker(line string) (string, bool) {
}
func stripForecastDiscussionKeyMessageNumericMarker(line string) (string, bool) {
digitStart := 0
digitEnd := 0
parenthesized := len(line) > 0 && line[0] == '('
if parenthesized {
digitStart = 1
digitEnd = 1
}
for digitEnd < len(line) && line[digitEnd] >= '0' && line[digitEnd] <= '9' {
digitEnd++
}
if digitEnd == 0 || digitEnd == len(line) || (line[digitEnd] != ')' && line[digitEnd] != '.') {
if digitEnd == digitStart || digitEnd == len(line) {
return "", false
}
if parenthesized && line[digitEnd] != ')' {
return "", false
}
if !parenthesized && line[digitEnd] != ')' && line[digitEnd] != '.' {
return "", false
}
@@ -752,7 +812,7 @@ func stripForecastDiscussionKeyMessageNumericMarker(line string) (string, bool)
if markerEnd < len(line) && !isForecastDiscussionHorizontalWhitespace(line[markerEnd]) {
return "", false
}
value, err := strconv.ParseUint(line[:digitEnd], 10, 0)
value, err := strconv.ParseUint(line[digitStart:digitEnd], 10, 0)
if err != nil || value == 0 {
return "", false
}

View File

@@ -391,6 +391,58 @@ func TestParseForecastDiscussionSectionBlocksUsesGenericHeadingsAsBoundaries(t *
}
}
func TestParseForecastDiscussionSectionBlocksRecognizesEmbeddedHeadingsInPreviousDiscussion(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
".PREV DISCUSSION... /Issued 319 PM PDT Sun Aug 2 2026/",
"KEY MESSAGES...",
"* First embedded message.",
"DISCUSSION...",
"Discussion prose.",
"&&",
})
want := []forecastDiscussionSectionBlock{
{
heading: forecastDiscussionSectionHeading{
section: "PREV DISCUSSION",
qualifier: "/Issued 319 PM PDT Sun Aug 2 2026/",
},
},
{
heading: forecastDiscussionSectionHeading{section: "KEY MESSAGES"},
body: []string{"* First embedded message."},
},
{
heading: forecastDiscussionSectionHeading{section: "DISCUSSION"},
body: []string{"Discussion prose."},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionSectionBlocksKeepsUndottedHeadingLikeLinesOutsideWrappers(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
".SHORT TERM...",
"Short-term prose.",
"KEY MESSAGES...",
"Still short-term prose.",
})
want := []forecastDiscussionSectionBlock{
{
heading: forecastDiscussionSectionHeading{section: "SHORT TERM"},
body: []string{
"Short-term prose.",
"KEY MESSAGES...",
"Still short-term prose.",
},
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("blocks = %#v, want %#v", got, want)
}
}
func TestParseForecastDiscussionSectionBlocksUsesParenthesizedTerminalHeadingAsBoundary(t *testing.T) {
got := parseForecastDiscussionSectionBlocks([]string{
".SHORT TERM... (Tonight)",
@@ -756,6 +808,16 @@ func TestIsForecastDiscussionKeyMessageMetadataLine(t *testing.T) {
line: "uPdAtEd At 300 PM CDT Sat Mar 28 2026",
want: true,
},
{
name: "as of weekday preamble",
line: "As of 1235 PM Sunday...",
want: true,
},
{
name: "as of ASCII case insensitive",
line: "aS oF 1235 pm SuNdAy...",
want: true,
},
{
name: "label without whitespace boundary",
line: "Updated at300 PM CDT Sat Mar 28 2026",
@@ -766,6 +828,26 @@ func TestIsForecastDiscussionKeyMessageMetadataLine(t *testing.T) {
line: "Updated at not a timestamp",
want: false,
},
{
name: "as of invalid clock",
line: "As of 2535 PM Sunday...",
want: false,
},
{
name: "as of invalid weekday",
line: "As of 1235 PM Someday...",
want: false,
},
{
name: "as of without terminal ellipsis",
line: "As of 1235 PM Sunday",
want: false,
},
{
name: "as of prefix collision",
line: "As often happens, storms weaken overnight.",
want: false,
},
{
name: "nonmetadata prose",
line: "Updated atmospheric conditions remain unsettled.",
@@ -794,13 +876,18 @@ func TestStripForecastDiscussionKeyMessageMarker(t *testing.T) {
{name: "asterisk", line: "* First message.", want: "First message.", ok: true},
{name: "numeric parenthesis", line: "1) First message.", want: "First message.", ok: true},
{name: "numeric dot", line: "2. Second message.", want: "Second message.", ok: true},
{name: "parenthesized numeric", line: "(1) First message.", want: "First message.", ok: true},
{name: "composite hyphen", line: "- 1. First message.", want: "First message.", ok: true},
{name: "composite asterisk", line: "* 1) First message.", want: "First message.", ok: true},
{name: "composite parenthesized numeric", line: "- (1) First message.", want: "First message.", ok: true},
{name: "composite asterisk parenthesized numeric", line: "* (1) First message.", want: "First message.", ok: true},
{name: "multi digit numeric", line: "12. Twelfth message.", want: "Twelfth message.", ok: true},
{name: "hyphen only", line: "-", want: "", ok: true},
{name: "numeric marker only", line: "1)", want: "", ok: true},
{name: "zero numeric marker", line: "0) Not a marker.", want: "", ok: false},
{name: "zero parenthesized numeric marker", line: "(0) Not a marker.", want: "", ok: false},
{name: "overflow numeric marker", line: "999999999999999999999999999999. Too big.", want: "", ok: false},
{name: "parenthesized numeric marker without boundary", line: "(1)Not a marker.", want: "", ok: false},
{name: "alphanumeric numeric prefix", line: "1x Not a marker.", want: "", ok: false},
{name: "numeric marker without boundary", line: "1)Not a marker.", want: "", ok: false},
{name: "numeric marker with unsupported punctuation", line: "1, Not a marker.", want: "", ok: false},
@@ -877,6 +964,14 @@ func TestParseForecastDiscussionKeyMessagesClassifiesListsAndMetadata(t *testing
},
want: []string{"First message."},
},
{
name: "leading as of metadata",
body: []string{
"As of 1235 PM Sunday...",
"- First message.",
},
want: []string{"First message."},
},
{
name: "updated atmospheric prose remains content",
body: []string{
@@ -901,6 +996,14 @@ func TestParseForecastDiscussionKeyMessagesClassifiesListsAndMetadata(t *testing
},
want: []string{"First message. Updated at 300 PM CDT Sat Mar 28 2026"},
},
{
name: "later as of metadata remains content",
body: []string{
"- First message.",
"As of 1235 PM Sunday...",
},
want: []string{"First message. As of 1235 PM Sunday..."},
},
}
for _, tt := range tests {
@@ -1089,7 +1192,7 @@ func TestParseForecastDiscussionHTMLSupportsMixedHeadingFormats(t *testing.T) {
}
}
func TestParseForecastDiscussionHTMLParsesNumberedAndKeyPointsFixtures(t *testing.T) {
func TestParseForecastDiscussionHTMLParsesCrossOfficeKeyMessageFixtures(t *testing.T) {
tests := []struct {
name string
filename string
@@ -1127,6 +1230,48 @@ func TestParseForecastDiscussionHTMLParsesNumberedAndKeyPointsFixtures(t *testin
"*", "DISCUSSION", "(Today through Thursday)", "Discussion details must not be included with key points.",
},
},
{
name: "as of preamble",
filename: "forecast_discussion_rah_as_of_sample.html",
officeID: "RAH",
officeName: "National Weather Service Raleigh NC",
issuedAt: time.Date(2026, 8, 2, 16, 35, 0, 0, time.UTC),
messages: []string{
"Scattered storms may produce locally heavy rain this afternoon.",
"Drier weather arrives Monday.",
},
unwanted: []string{
"As of", "1)", "2)", "DISCUSSION", "Discussion details remain boundary-only content.",
},
},
{
name: "parenthesized numeric markers",
filename: "forecast_discussion_lwx_parenthesized_number_sample.html",
officeID: "LWX",
officeName: "National Weather Service Baltimore MD/Washington DC",
issuedAt: time.Date(2026, 8, 2, 18, 0, 0, 0, time.UTC),
messages: []string{
"Thunderstorms remain possible near the Blue Ridge this evening.",
"Seasonably warm conditions continue Monday.",
},
unwanted: []string{
"(1)", "(2)", "AVIATION", "Aviation details remain boundary-only content.",
},
},
{
name: "embedded key messages in previous discussion",
filename: "forecast_discussion_mfr_prev_discussion_sample.html",
officeID: "MFR",
officeName: "National Weather Service Medford OR",
issuedAt: time.Date(2026, 8, 2, 22, 19, 0, 0, time.UTC),
messages: []string{
"Heat returns to inland valleys Monday.",
"Gusty afternoon winds develop east of the Cascades.",
},
unwanted: []string{
"PREV DISCUSSION", "Issued 319 PM", "*", "DISCUSSION", "Discussion details must not be included with key messages.",
},
},
}
for _, tt := range tests {

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<!-- Representative LWX-style layout; prose is concise edited test data, not an archived product. -->
<html>
<body>
<pre class="glossaryProduct">
FXUS61 KLWX 021800
AFDLWX
Area Forecast Discussion
National Weather Service Baltimore MD/Washington DC
200 PM EDT Sun Aug 2 2026
.KEY MESSAGES...
- (1) Thunderstorms remain possible near the Blue Ridge this evening.
- (2) Seasonably warm conditions continue Monday.
&&
.AVIATION...
Aviation details remain boundary-only content.
$$
WFO LWX
</pre>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<!-- Representative current MFR previous-discussion wrapper layout; prose is concise edited test data, not an archived product. -->
<html>
<body>
<pre class="glossaryProduct">
FXUS66 KMFR 022219
AFDMFR
Area Forecast Discussion
National Weather Service Medford OR
319 PM PDT Sun Aug 2 2026
.PREV DISCUSSION... /Issued 319 PM PDT Sun Aug 2 2026/
KEY MESSAGES...
* Heat returns to inland valleys Monday.
* Gusty afternoon winds develop east of the Cascades.
DISCUSSION...
Discussion details must not be included with key messages.
&&
.MFR WATCHES/WARNINGS/ADVISORIES...
None.
$$
WFO MFR
</pre>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<!-- Representative RAH-style layout; prose is concise edited test data, not an archived product. -->
<html>
<body>
<pre class="glossaryProduct">
FXUS62 KRAH 021635
AFDRAH
Area Forecast Discussion
National Weather Service Raleigh NC
1235 PM EDT Sun Aug 2 2026
.KEY MESSAGES...
As of 1235 PM Sunday...
1) Scattered storms may produce locally heavy rain this afternoon.
2) Drier weather arrives Monday.
&&
.DISCUSSION...
Discussion details remain boundary-only content.
$$
WFO RAH
</pre>
</body>
</html>