From 88d5727a840bf31f43fdeee8f073936719688d2c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 26 Mar 2026 21:35:08 -0500 Subject: [PATCH] Simplified the forecast schema --- API.md | 6 +- internal/normalizers/nws/forecast.go | 12 +--- internal/normalizers/nws/forecast_test.go | 61 ++++++++++++++++ internal/normalizers/openmeteo/forecast.go | 9 +-- .../normalizers/openmeteo/forecast_test.go | 71 +++++++++++++++++++ internal/sinks/postgres/doc.go | 4 -- internal/sinks/postgres/map.go | 4 -- internal/sinks/postgres/map_test.go | 1 - internal/sinks/postgres/schema.go | 4 -- model/forecast.go | 14 +--- 10 files changed, 138 insertions(+), 48 deletions(-) create mode 100644 internal/normalizers/nws/forecast_test.go create mode 100644 internal/normalizers/openmeteo/forecast_test.go diff --git a/API.md b/API.md index c8c5d98..4927882 100644 --- a/API.md +++ b/API.md @@ -143,11 +143,7 @@ A `WeatherForecastPeriod` is valid for `[startTime, endTime)`. | `name` | string | no | Human label (often empty for hourly) | | `isDay` | bool | no | Day/night hint | | `conditionCode` | int | yes | WMO code (`-1` for unknown) | -| `conditionText` | string | no | Canonical short text | -| `providerRawDescription` | string | no | Provider-specific “evidence” text | | `textDescription` | string | no | Human-facing short phrase | -| `detailedText` | string | no | Longer narrative | -| `iconUrl` | string | no | Legacy/transitional | | `temperatureC` | number | no | °C | | `temperatureCMin` | number | no | °C (aggregated products) | | `temperatureCMax` | number | no | °C (aggregated products) | @@ -269,7 +265,7 @@ A run may contain zero, one, or many alerts. "startTime": "2026-01-17T14:00:00Z", "endTime": "2026-01-17T15:00:00Z", "conditionCode": 2, - "conditionText": "Partly Cloudy", + "textDescription": "Partly Cloudy", "temperatureC": 3.5, "probabilityOfPrecipitationPercent": 10 } diff --git a/internal/normalizers/nws/forecast.go b/internal/normalizers/nws/forecast.go index b76e8c2..b0dec68 100644 --- a/internal/normalizers/nws/forecast.go +++ b/internal/normalizers/nws/forecast.go @@ -118,8 +118,6 @@ func buildForecast(parsed nwsForecastResponse) (model.WeatherForecastRun, time.T providerDesc := strings.TrimSpace(p.ShortForecast) wmo := wmoFromNWSForecast(providerDesc, p.Icon, tempC) - canonicalText := standards.WMOText(wmo, isDay) - period := model.WeatherForecastPeriod{ StartTime: start, EndTime: end, @@ -128,15 +126,9 @@ func buildForecast(parsed nwsForecastResponse) (model.WeatherForecastRun, time.T IsDay: isDay, ConditionCode: wmo, - ConditionText: canonicalText, - ProviderRawDescription: providerDesc, - - // For forecasts, keep provider text as the human-facing description. - TextDescription: strings.TrimSpace(p.ShortForecast), - DetailedText: strings.TrimSpace(p.DetailedForecast), - - IconURL: strings.TrimSpace(p.Icon), + // For forecasts, keep provider short forecast text as the human-facing description. + TextDescription: providerDesc, TemperatureC: tempC, diff --git a/internal/normalizers/nws/forecast_test.go b/internal/normalizers/nws/forecast_test.go new file mode 100644 index 0000000..b3c90fe --- /dev/null +++ b/internal/normalizers/nws/forecast_test.go @@ -0,0 +1,61 @@ +package nws + +import ( + "encoding/json" + "testing" + "time" +) + +func TestBuildForecastUsesShortForecastAsTextDescription(t *testing.T) { + parsed := nwsForecastResponse{} + parsed.Properties.GeneratedAt = "2026-03-16T18:00:00Z" + parsed.Properties.Periods = []nwsForecastPeriod{ + { + StartTime: "2026-03-16T19:00:00Z", + EndTime: "2026-03-16T20:00:00Z", + ShortForecast: " Mostly Cloudy ", + DetailedForecast: "Clouds increasing overnight.", + Icon: "https://example.invalid/icon", + }, + } + + run, effectiveAt, err := buildForecast(parsed) + if err != nil { + t.Fatalf("buildForecast() error = %v", err) + } + if len(run.Periods) != 1 { + t.Fatalf("periods len = %d, want 1", len(run.Periods)) + } + if got, want := run.Periods[0].TextDescription, "Mostly Cloudy"; got != want { + t.Fatalf("TextDescription = %q, want %q", got, want) + } + + wantIssued := time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC) + if !run.IssuedAt.Equal(wantIssued) { + t.Fatalf("IssuedAt = %s, want %s", run.IssuedAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339)) + } + if !effectiveAt.Equal(wantIssued) { + t.Fatalf("effectiveAt = %s, want %s", effectiveAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339)) + } + + assertNoLegacyForecastDescriptionKeys(t, run.Periods[0]) +} + +func assertNoLegacyForecastDescriptionKeys(t *testing.T, period any) { + t.Helper() + + b, err := json.Marshal(period) + if err != nil { + t.Fatalf("json.Marshal(period) error = %v", err) + } + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("json.Unmarshal(period) error = %v", err) + } + + for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} { + if _, ok := got[key]; ok { + t.Fatalf("unexpected legacy key %q in marshaled period: %#v", key, got) + } + } +} diff --git a/internal/normalizers/openmeteo/forecast.go b/internal/normalizers/openmeteo/forecast.go index ed51c66..654523b 100644 --- a/internal/normalizers/openmeteo/forecast.go +++ b/internal/normalizers/openmeteo/forecast.go @@ -107,15 +107,8 @@ func buildForecast(parsed omForecastResponse, fallbackIssued time.Time) (model.W Name: "", IsDay: isDay, - ConditionCode: wmo, - ConditionText: canonicalText, - - ProviderRawDescription: "", - + ConditionCode: wmo, TextDescription: canonicalText, - DetailedText: "", - - IconURL: "", } if v := floatAt(parsed.Hourly.Temperature2m, i); v != nil { diff --git a/internal/normalizers/openmeteo/forecast_test.go b/internal/normalizers/openmeteo/forecast_test.go new file mode 100644 index 0000000..b501a83 --- /dev/null +++ b/internal/normalizers/openmeteo/forecast_test.go @@ -0,0 +1,71 @@ +package openmeteo + +import ( + "encoding/json" + "testing" + "time" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" + "gitea.maximumdirect.net/ejr/weatherfeeder/standards" +) + +func TestBuildForecastUsesCanonicalTextDescription(t *testing.T) { + weatherCode := 2 + isDay := 1 + + parsed := omForecastResponse{ + Timezone: "UTC", + UTCOffsetSeconds: 0, + Hourly: omForecastHourly{ + Time: []string{"2026-03-16T19:00"}, + WeatherCode: []*int{&weatherCode}, + IsDay: []*int{&isDay}, + }, + } + + run, effectiveAt, err := buildForecast(parsed, time.Time{}) + if err != nil { + t.Fatalf("buildForecast() error = %v", err) + } + if len(run.Periods) != 1 { + t.Fatalf("periods len = %d, want 1", len(run.Periods)) + } + + expectedText := standards.WMOText(model.WMOCode(weatherCode), boolPtr(true)) + if got := run.Periods[0].TextDescription; got != expectedText { + t.Fatalf("TextDescription = %q, want %q", got, expectedText) + } + + wantIssued := time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC) + if !run.IssuedAt.Equal(wantIssued) { + t.Fatalf("IssuedAt = %s, want %s", run.IssuedAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339)) + } + if !effectiveAt.Equal(wantIssued) { + t.Fatalf("effectiveAt = %s, want %s", effectiveAt.Format(time.RFC3339), wantIssued.Format(time.RFC3339)) + } + + assertNoLegacyForecastDescriptionKeys(t, run.Periods[0]) +} + +func boolPtr(v bool) *bool { + return &v +} + +func assertNoLegacyForecastDescriptionKeys(t *testing.T, period any) { + t.Helper() + + b, err := json.Marshal(period) + if err != nil { + t.Fatalf("json.Marshal(period) error = %v", err) + } + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("json.Unmarshal(period) error = %v", err) + } + + for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} { + if _, ok := got[key]; ok { + t.Fatalf("unexpected legacy key %q in marshaled period: %#v", key, got) + } + } +} diff --git a/internal/sinks/postgres/doc.go b/internal/sinks/postgres/doc.go index 324bbc2..deb53a8 100644 --- a/internal/sinks/postgres/doc.go +++ b/internal/sinks/postgres/doc.go @@ -102,11 +102,7 @@ // - name TEXT NULL -> payload.periods[i].name // - is_day BOOLEAN NULL -> payload.periods[i].isDay // - condition_code INTEGER -> payload.periods[i].conditionCode -// - condition_text TEXT NULL -> payload.periods[i].conditionText -// - provider_raw_description TEXT NULL -> payload.periods[i].providerRawDescription // - text_description TEXT NULL -> payload.periods[i].textDescription -// - detailed_text TEXT NULL -> payload.periods[i].detailedText -// - icon_url TEXT NULL -> payload.periods[i].iconUrl // - temperature_c DOUBLE PRECISION NULL -> payload.periods[i].temperatureC // - temperature_c_min DOUBLE PRECISION NULL -> payload.periods[i].temperatureCMin // - temperature_c_max DOUBLE PRECISION NULL -> payload.periods[i].temperatureCMax diff --git a/internal/sinks/postgres/map.go b/internal/sinks/postgres/map.go index 49311cb..4934b3c 100644 --- a/internal/sinks/postgres/map.go +++ b/internal/sinks/postgres/map.go @@ -136,11 +136,7 @@ func mapForecastEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) { "name": nullableString(p.Name), "is_day": nullableBool(p.IsDay), "condition_code": int(p.ConditionCode), - "condition_text": nullableString(p.ConditionText), - "provider_raw_description": nullableString(p.ProviderRawDescription), "text_description": nullableString(p.TextDescription), - "detailed_text": nullableString(p.DetailedText), - "icon_url": nullableString(p.IconURL), "temperature_c": nullableFloat64(p.TemperatureC), "temperature_c_min": nullableFloat64(p.TemperatureCMin), "temperature_c_max": nullableFloat64(p.TemperatureCMax), diff --git a/internal/sinks/postgres/map_test.go b/internal/sinks/postgres/map_test.go index cdacf7e..843a5f8 100644 --- a/internal/sinks/postgres/map_test.go +++ b/internal/sinks/postgres/map_test.go @@ -64,7 +64,6 @@ func TestMapPostgresEventForecastStructPayload(t *testing.T) { EndTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC), IsDay: &isDay, ConditionCode: model.WMOCode(2), - ConditionText: "Partly Cloudy", TemperatureC: &temp, }, { diff --git a/internal/sinks/postgres/schema.go b/internal/sinks/postgres/schema.go index 2927213..c54c134 100644 --- a/internal/sinks/postgres/schema.go +++ b/internal/sinks/postgres/schema.go @@ -130,11 +130,7 @@ func weatherPostgresSchema() fksinks.PostgresSchema { {Name: "name", Type: "TEXT", Nullable: true}, {Name: "is_day", Type: "BOOLEAN", Nullable: true}, {Name: "condition_code", Type: "INTEGER", Nullable: false}, - {Name: "condition_text", Type: "TEXT", Nullable: true}, - {Name: "provider_raw_description", Type: "TEXT", Nullable: true}, {Name: "text_description", Type: "TEXT", Nullable: true}, - {Name: "detailed_text", Type: "TEXT", Nullable: true}, - {Name: "icon_url", Type: "TEXT", Nullable: true}, {Name: "temperature_c", Type: "DOUBLE PRECISION", Nullable: true}, {Name: "temperature_c_min", Type: "DOUBLE PRECISION", Nullable: true}, {Name: "temperature_c_max", Type: "DOUBLE PRECISION", Nullable: true}, diff --git a/model/forecast.go b/model/forecast.go index b7646fd..d021f04 100644 --- a/model/forecast.go +++ b/model/forecast.go @@ -75,18 +75,8 @@ type WeatherForecastPeriod struct { // Like WeatherObservation, this is required; use an “unknown” WMOCode if unmappable. ConditionCode WMOCode `json:"conditionCode"` - // Provider-independent short text describing the conditions (normalized, if possible). - ConditionText string `json:"conditionText,omitempty"` - - // Provider-specific “evidence” for troubleshooting mapping and drift. - ProviderRawDescription string `json:"providerRawDescription,omitempty"` - - // Human-facing narrative. Not all providers supply rich text (Open-Meteo often won’t). - TextDescription string `json:"textDescription,omitempty"` // short phrase / summary - DetailedText string `json:"detailedText,omitempty"` // longer narrative, if available - - // Provider-specific (legacy / transitional) - IconURL string `json:"iconUrl,omitempty"` + // Human-facing narrative summary for this period. + TextDescription string `json:"textDescription,omitempty"` // Core predicted measurements (nullable; units align with WeatherObservation) TemperatureC *float64 `json:"temperatureC,omitempty"`