Finalize SPC outlook feature addition and clean up implemented roadmap documentation
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-06-10 19:54:39 -05:00
parent a4cd63ca4e
commit a990da957b
17 changed files with 203 additions and 473 deletions

View File

@@ -138,11 +138,10 @@ func parseDiscussions(pages []spcprovider.RawDiscussionPage) (map[int]parsedDisc
out := map[int]parsedDiscussion{}
var latestUpdated time.Time
for _, page := range pages {
text, err := spcprovider.ExtractProductText(page.Body)
parsed, err := spcprovider.ParseDiscussionHTML(page.Body)
if err != nil {
return nil, time.Time{}, fmt.Errorf("discussion %s: %w", page.Key, err)
}
parsed := spcprovider.ParseDiscussionText(text)
day := page.Day
if day == 0 {
if meta, ok := spcprovider.DiscussionProductByKey(page.Key); ok {

View File

@@ -99,6 +99,9 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
t.Fatalf("Discussion missing product text: %q", got.Discussion)
}
if !strings.HasPrefix(got.Discussion, "SPC AC 111234") {
t.Fatalf("Discussion = %q, want SPC product code prefix", got.Discussion)
}
if got.ID != "spc-convective-day1-categorical-slgt-2026-06-11T12:34:56Z-2026-06-11T13:00:00Z-0" {
t.Fatalf("ID = %q", got.ID)
}

View File

@@ -13,6 +13,8 @@ var (
preBlockRE = regexp.MustCompile(`(?is)<pre\b[^>]*>(.*?)</pre>`)
tagRE = regexp.MustCompile(`(?is)<[^>]+>`)
updatedRE = regexp.MustCompile(`(?im)^\s*Updated:\s*(.+?)\s*$`)
pageUpdatedRE = regexp.MustCompile(`(?i)\bUpdated:\s*((?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)|(?:[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+UTC\s+\d{4})|(?:\d{4}\s+UTC\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4})|(?:\d{4}Z\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4}))`)
productCodeRE = regexp.MustCompile(`(?i)^SPC\s+AC\s+\d+\s*$`)
sectionRE = regexp.MustCompile(`^\s*\.\.\.[A-Z0-9 /-]+\.{3}\s*$`)
)
@@ -41,6 +43,20 @@ func ExtractProductText(rawHTML string) (string, error) {
return "", fmt.Errorf("no useful pre block found")
}
// ParseDiscussionHTML extracts SPC product text and page-level metadata from a
// print-page HTML document.
func ParseDiscussionHTML(rawHTML string) (DiscussionText, error) {
text, err := ExtractProductText(rawHTML)
if err != nil {
return DiscussionText{}, err
}
parsed := ParseDiscussionText(text)
if updatedAt := ParsePageUpdatedTimestamp(rawHTML); updatedAt != nil {
parsed.UpdatedAt = updatedAt
}
return parsed, nil
}
// ParseDiscussionText extracts common SPC narrative metadata from cleaned
// product text.
func ParseDiscussionText(text string) DiscussionText {
@@ -55,6 +71,19 @@ func ParseDiscussionText(text string) DiscussionText {
}
}
// ParsePageUpdatedTimestamp parses the page-level Updated row from an SPC print
// page. SPC currently places this outside the product <pre> block.
func ParsePageUpdatedTimestamp(rawHTML string) *time.Time {
text := cleanHTMLText(rawHTML)
text = strings.ReplaceAll(text, "\u00a0", " ")
text = strings.Join(strings.Fields(text), " ")
match := pageUpdatedRE.FindStringSubmatch(text)
if len(match) != 2 {
return nil
}
return parseUpdatedValue(match[1])
}
// ParseUpdatedTimestamp parses an SPC print-page Updated line when present.
func ParseUpdatedTimestamp(text string) *time.Time {
match := updatedRE.FindStringSubmatch(normalizeNewlines(text))
@@ -68,7 +97,7 @@ func ParseUpdatedTimestamp(text string) *time.Time {
func ParseProductTitle(text string) string {
for _, line := range strings.Split(normalizeNewlines(text), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Updated:") {
if line == "" || strings.HasPrefix(line, "Updated:") || productCodeRE.MatchString(line) {
continue
}
return line
@@ -142,6 +171,7 @@ func parseUpdatedValue(value string) *time.Time {
return t
}
for _, layout := range []string{
"Mon Jan 2 15:04:05 UTC 2006",
"1504 UTC Mon Jan 2 2006",
"1504Z Mon Jan 2 2006",
"3:04 PM UTC Mon Jan 2 2006",

View File

@@ -22,18 +22,19 @@ func TestExtractProductTextCleansPreBlock(t *testing.T) {
if !strings.Contains(got, "Day 1 Convective Outlook") {
t.Fatalf("ExtractProductText() missing headline: %q", got)
}
if !strings.HasPrefix(got, "SPC AC 111234") {
t.Fatalf("ExtractProductText() = %q, want product code prefix", got)
}
if strings.HasPrefix(got, "\n") || strings.HasSuffix(got, "\n") {
t.Fatalf("ExtractProductText() retained surrounding blank lines: %q", got)
}
}
func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
text, err := ExtractProductText(string(readTestFile(t, "day1_prt.html")))
func TestParseDiscussionHTMLExtractsHeadlineSummaryAndUpdated(t *testing.T) {
got, err := ParseDiscussionHTML(string(readTestFile(t, "day1_prt.html")))
if err != nil {
t.Fatalf("ExtractProductText() error = %v", err)
t.Fatalf("ParseDiscussionHTML() error = %v", err)
}
got := ParseDiscussionText(text)
if got.ProductTitle != "Day 1 Convective Outlook" {
t.Fatalf("ProductTitle = %q", got.ProductTitle)
}
@@ -47,27 +48,37 @@ func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
t.Fatalf("Discussion missing full text: %q", got.Discussion)
}
if !strings.HasPrefix(got.Discussion, "SPC AC 111234") {
t.Fatalf("Discussion = %q, want product code prefix", got.Discussion)
}
wantUpdated := time.Date(2026, 6, 11, 12, 45, 0, 0, time.UTC)
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
}
}
func TestParseProductTitleSkipsSPCProductCode(t *testing.T) {
got := ParseProductTitle("SPC AC 101959\nDay 1 Convective Outlook\nNWS Storm Prediction Center Norman OK")
if got != "Day 1 Convective Outlook" {
t.Fatalf("ParseProductTitle() = %q, want Day 1 Convective Outlook", got)
}
}
func TestParseDiscussionTextPreservesCorrectionMarker(t *testing.T) {
text, err := ExtractProductText(string(readTestFile(t, "day2_prt_corr.html")))
got, err := ParseDiscussionHTML(string(readTestFile(t, "day2_prt_corr.html")))
if err != nil {
t.Fatalf("ExtractProductText() error = %v", err)
t.Fatalf("ParseDiscussionHTML() error = %v", err)
}
got := ParseDiscussionText(text)
if !strings.Contains(got.Headline, "CORR 1") {
t.Fatalf("Headline = %q, want correction marker", got.Headline)
}
if !strings.Contains(got.Discussion, "CORR 1") {
t.Fatalf("Discussion = %q, want correction marker", got.Discussion)
}
if got.UpdatedAt != nil {
t.Fatalf("UpdatedAt = %v, want nil", got.UpdatedAt)
wantUpdated := time.Date(2026, 6, 11, 17, 30, 0, 0, time.UTC)
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
}
}
@@ -88,3 +99,11 @@ func TestParseUpdatedTimestampAcceptsSPCUTCFormat(t *testing.T) {
t.Fatalf("ParseUpdatedTimestamp() = %v, want %s", got, want)
}
}
func TestParsePageUpdatedTimestampAcceptsLiveSPCShape(t *testing.T) {
got := ParsePageUpdatedTimestamp(string(readTestFile(t, "day3_prt.html")))
want := time.Date(2026, 6, 11, 20, 0, 0, 0, time.UTC)
if got == nil || !got.Equal(want) {
t.Fatalf("ParsePageUpdatedTimestamp() = %v, want %s", got, want)
}
}

View File

@@ -2,11 +2,14 @@
<html>
<head><title>Day 1 Convective Outlook</title></head>
<body>
<table>
<tr><td align="center" class="rpttext" nowrap>Updated:&nbsp;Thu Jun 11 12:45:00 UTC 2026&nbsp;(<a href="archive/day1-geojson.zip">geojson</a>)</td></tr>
</table>
<pre>
<script>window.bad = "<b>ignore me</b>";</script>
SPC AC 111234
Day 1 Convective Outlook
NWS Storm Prediction Center Norman OK
Updated: 2026-06-11T12:45:00Z
...SUMMARY...
Severe thunderstorms are possible across parts of the central Plains

View File

@@ -1,7 +1,11 @@
<!doctype html>
<html>
<body>
<table>
<tr><td class="rpttext">Updated:&nbsp;Thu Jun 11 17:30:00 UTC 2026&nbsp;</td></tr>
</table>
<pre>
SPC AC 111730
Day 2 Convective Outlook CORR 1
NWS Storm Prediction Center Norman OK

View File

@@ -1,10 +1,13 @@
<!doctype html>
<html>
<body>
<table>
<tr><td class="rpttext">Updated:&nbsp;Thu Jun 11 20:00:00 UTC 2026&nbsp;</td></tr>
</table>
<pre>
SPC AC 112000
Day 3 Convective Outlook
NWS Storm Prediction Center Norman OK
Updated: 2026-06-11T20:00:00Z
...SUMMARY...
A corridor of strong to severe storms may develop near a frontal zone.

View File

@@ -233,6 +233,8 @@
// - run_event_id TEXT -> outlook_runs.event_id / payload.outlooks[i]
// - outlook_index INTEGER -> i (array position in payload.outlooks)
// - as_of TIMESTAMPTZ -> payload.asOf (copied from parent)
// - outlook_id TEXT -> payload.outlooks[i].id
// - provider TEXT -> payload.outlooks[i].provider
// - product TEXT -> payload.outlooks[i].product
// - day INTEGER -> payload.outlooks[i].day
// - outlook_type TEXT -> payload.outlooks[i].outlookType

View File

@@ -404,6 +404,8 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
"run_event_id": e.ID,
"outlook_index": i,
"as_of": asOf,
"outlook_id": outlook.ID,
"provider": outlook.Provider,
"product": outlook.Product,
"day": outlook.Day,
"outlook_type": outlook.OutlookType,

View File

@@ -312,6 +312,12 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
if got := writes[1].Values["outlook_index"]; got != 0 {
t.Fatalf("first outlook index = %#v, want 0", got)
}
if got := writes[1].Values["outlook_id"]; got != "outlook-1" {
t.Fatalf("first outlook_id = %#v, want outlook-1", got)
}
if got := writes[1].Values["provider"]; got != "spc" {
t.Fatalf("first provider = %#v, want spc", got)
}
if got := writes[1].Values["valid_from"]; got != run.Outlooks[0].ValidFrom.UTC() {
t.Fatalf("first valid_from = %#v, want UTC %s", got, run.Outlooks[0].ValidFrom.UTC())
}
@@ -335,6 +341,57 @@ func TestMapPostgresEventOutlookRejectsMissingAsOf(t *testing.T) {
}
}
func TestMapPostgresEventOutlookRejectsMissingIDAndProvider(t *testing.T) {
base := model.WeatherOutlook{
ID: "outlook-1",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
ValidFrom: time.Date(2026, 6, 11, 13, 0, 0, 0, time.UTC),
ValidTo: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
IssuedAt: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
}
tests := []struct {
name string
mutate func(*model.WeatherOutlook)
wantErr string
}{
{
name: "missing id",
mutate: func(outlook *model.WeatherOutlook) { outlook.ID = "" },
wantErr: "outlooks[0].id is required",
},
{
name: "missing provider",
mutate: func(outlook *model.WeatherOutlook) { outlook.Provider = "" },
wantErr: "outlooks[0].provider is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
outlook := base
tt.mutate(&outlook)
run := model.WeatherOutlookRun{
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
Outlooks: []model.WeatherOutlook{outlook},
}
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, "outlook", run))
if err == nil {
t.Fatalf("mapPostgresEvent() error = nil, want %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err, tt.wantErr)
}
})
}
}
func TestMapPostgresEventOutlookRejectsMissingRequiredTimes(t *testing.T) {
run := model.WeatherOutlookRun{
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),

View File

@@ -329,6 +329,8 @@ func PostgresSchema() fksinks.PostgresSchema {
{Name: "run_event_id", Type: "TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE", Nullable: false},
{Name: "outlook_index", Type: "INTEGER", Nullable: false},
{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
{Name: "outlook_id", Type: "TEXT", Nullable: false},
{Name: "provider", Type: "TEXT", Nullable: false},
{Name: "product", Type: "TEXT", Nullable: false},
{Name: "day", Type: "INTEGER", Nullable: false},
{Name: "outlook_type", Type: "TEXT", Nullable: false},

View File

@@ -60,7 +60,7 @@ func TestWeatherPostgresSchemaIncludesOutlookTables(t *testing.T) {
assertTableIndex(t, tableOutlookRuns, "idx_wf_outlook_run_as_of", []string{"as_of"})
outlookColumns := columnsForTable(t, tableOutlooks)
for _, col := range []string{"run_event_id", "outlook_index", "as_of", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "headline", "summary", "discussion", "source_url", "image_url", "contains_location", "geometry_json"} {
for _, col := range []string{"run_event_id", "outlook_index", "as_of", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "headline", "summary", "discussion", "source_url", "image_url", "contains_location", "geometry_json"} {
if !outlookColumns[col] {
t.Fatalf("%s missing %s column", tableOutlooks, col)
}

View File

@@ -377,11 +377,7 @@ func latestIssueTime(raw []byte) time.Time {
}
func discussionUpdatedTime(rawHTML string) time.Time {
text, err := spcprovider.ExtractProductText(rawHTML)
if err != nil {
return time.Time{}
}
t := spcprovider.ParseUpdatedTimestamp(text)
t := spcprovider.ParsePageUpdatedTimestamp(rawHTML)
if t == nil {
return time.Time{}
}