From cca873cafb7da1ecd14f4227e8aa0500ecd1495b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 28 May 2026 07:47:07 -0500 Subject: [PATCH] Made forecast-period conditionCode optional --- API.md | 2 +- internal/normalizers/nws/forecast.go | 21 ++++++++++++------- internal/normalizers/nws/forecast_test.go | 6 ++++++ internal/normalizers/openmeteo/forecast.go | 8 ++++++- .../normalizers/openmeteo/forecast_test.go | 3 +++ internal/sinks/postgres/doc.go | 2 +- internal/sinks/postgres/map.go | 9 +++++++- internal/sinks/postgres/map_test.go | 14 ++++++++++--- internal/sinks/postgres/schema.go | 2 +- model/forecast.go | 6 +++--- 10 files changed, 54 insertions(+), 19 deletions(-) diff --git a/API.md b/API.md index 4ffd6f2..5cc2175 100644 --- a/API.md +++ b/API.md @@ -152,7 +152,7 @@ A `WeatherForecastPeriod` is valid for `[startTime, endTime)`. | `endTime` | string (timestamp) | yes | Period end | | `name` | string | no | Human label (often empty for hourly) | | `isDay` | bool | no | Day/night hint | -| `conditionCode` | int | yes | WMO code (`-1` for unknown) | +| `conditionCode` | int | no | WMO code when applicable (`-1` for unknown) | | `textDescription` | string | no | Human-facing short phrase | | `temperatureC` | number | no | °C | | `temperatureCMin` | number | no | °C (aggregated products) | diff --git a/internal/normalizers/nws/forecast.go b/internal/normalizers/nws/forecast.go index f3a7693..2bb382f 100644 --- a/internal/normalizers/nws/forecast.go +++ b/internal/normalizers/nws/forecast.go @@ -23,10 +23,11 @@ import ( // builders by raw schema. // // Caveats / policy: -// 1. NWS forecast periods do not include METAR presentWeather phenomena, so ConditionCode -// is inferred from period.shortForecast (with a conservative icon-based fallback). -// 2. Temperature is converted to °C when NWS supplies °F. -// 3. WindSpeed is parsed from strings like "9 mph" / "10 to 15 mph" and converted to km/h. +// 1. Hourly NWS forecast periods do not include METAR presentWeather phenomena, so +// ConditionCode is inferred from period.shortForecast (with a conservative icon fallback). +// 2. Narrative NWS periods intentionally leave ConditionCode unset. +// 3. Temperature is converted to °C when NWS supplies °F. +// 4. WindSpeed is parsed from strings like "9 mph" / "10 to 15 mph" and converted to km/h. type ForecastNormalizer struct{} func (ForecastNormalizer) Match(e event.Event) bool { @@ -224,6 +225,7 @@ func mapHourlyForecastPeriod(idx int, p nwsHourlyForecastPeriod) (model.WeatherF // Infer WMO from shortForecast (and fall back to icon token). providerDesc := strings.TrimSpace(p.ShortForecast) wmo := wmoFromNWSForecast(providerDesc, p.Icon, tempC) + wmoPtr := wmoCodePtr(wmo) return model.WeatherForecastPeriod{ StartTime: start, @@ -232,7 +234,7 @@ func mapHourlyForecastPeriod(idx int, p nwsHourlyForecastPeriod) (model.WeatherF Name: strings.TrimSpace(p.Name), IsDay: isDay, - ConditionCode: wmo, + ConditionCode: wmoPtr, // For forecasts, keep provider short forecast text as the human-facing description. TextDescription: providerDesc, @@ -264,9 +266,7 @@ func mapNarrativeForecastPeriod(idx int, p nwsNarrativeForecastPeriod) (model.We tempC := tempCFromNWS(p.Temperature, p.TemperatureUnit) - // Infer WMO from shortForecast (and fall back to icon token). shortForecast := strings.TrimSpace(p.ShortForecast) - wmo := wmoFromNWSForecast(shortForecast, p.Icon, tempC) textDescription := strings.TrimSpace(p.DetailedForecast) if textDescription == "" { @@ -280,7 +280,7 @@ func mapNarrativeForecastPeriod(idx int, p nwsNarrativeForecastPeriod) (model.We Name: strings.TrimSpace(p.Name), IsDay: isDay, - ConditionCode: wmo, + ConditionCode: nil, TextDescription: textDescription, @@ -292,3 +292,8 @@ func mapNarrativeForecastPeriod(idx int, p nwsNarrativeForecastPeriod) (model.We ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitation.Value, }, nil } + +func wmoCodePtr(code model.WMOCode) *model.WMOCode { + out := code + return &out +} diff --git a/internal/normalizers/nws/forecast_test.go b/internal/normalizers/nws/forecast_test.go index ec3dbca..3f16c41 100644 --- a/internal/normalizers/nws/forecast_test.go +++ b/internal/normalizers/nws/forecast_test.go @@ -35,6 +35,9 @@ func TestBuildHourlyForecastUsesShortForecastAsTextDescription(t *testing.T) { if got, want := run.Periods[0].TextDescription, "Mostly Cloudy"; got != want { t.Fatalf("TextDescription = %q, want %q", got, want) } + if run.Periods[0].ConditionCode == nil { + t.Fatalf("ConditionCode is nil, want inferred hourly WMO code") + } wantIssued := time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC) if !run.IssuedAt.Equal(wantIssued) { @@ -261,6 +264,9 @@ func TestBuildNarrativeForecastMapsExpectedFields(t *testing.T) { if p.ProbabilityOfPrecipitationPercent == nil || *p.ProbabilityOfPrecipitationPercent != 20 { t.Fatalf("ProbabilityOfPrecipitationPercent = %v, want 20", p.ProbabilityOfPrecipitationPercent) } + if p.ConditionCode != nil { + t.Fatalf("ConditionCode = %v, want nil for narrative period", p.ConditionCode) + } wantIssued := time.Date(2026, 3, 27, 15, 17, 1, 0, time.UTC) if !run.IssuedAt.Equal(wantIssued) { diff --git a/internal/normalizers/openmeteo/forecast.go b/internal/normalizers/openmeteo/forecast.go index 654523b..67b1ab0 100644 --- a/internal/normalizers/openmeteo/forecast.go +++ b/internal/normalizers/openmeteo/forecast.go @@ -98,6 +98,7 @@ func buildForecast(parsed omForecastResponse, fallbackIssued time.Time) (model.W } wmo := wmoAt(parsed.Hourly.WeatherCode, i) + wmoPtr := wmoCodePtr(wmo) canonicalText := standards.WMOText(wmo, isDay) period := model.WeatherForecastPeriod{ @@ -107,7 +108,7 @@ func buildForecast(parsed omForecastResponse, fallbackIssued time.Time) (model.W Name: "", IsDay: isDay, - ConditionCode: wmo, + ConditionCode: wmoPtr, TextDescription: canonicalText, } @@ -237,3 +238,8 @@ func wmoAt(vals []*int, idx int) model.WMOCode { } return model.WMOUnknown } + +func wmoCodePtr(code model.WMOCode) *model.WMOCode { + out := code + return &out +} diff --git a/internal/normalizers/openmeteo/forecast_test.go b/internal/normalizers/openmeteo/forecast_test.go index b501a83..8c20bc5 100644 --- a/internal/normalizers/openmeteo/forecast_test.go +++ b/internal/normalizers/openmeteo/forecast_test.go @@ -35,6 +35,9 @@ func TestBuildForecastUsesCanonicalTextDescription(t *testing.T) { if got := run.Periods[0].TextDescription; got != expectedText { t.Fatalf("TextDescription = %q, want %q", got, expectedText) } + if run.Periods[0].ConditionCode == nil { + t.Fatalf("ConditionCode is nil, want mapped WMO code") + } wantIssued := time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC) if !run.IssuedAt.Equal(wantIssued) { diff --git a/internal/sinks/postgres/doc.go b/internal/sinks/postgres/doc.go index deb53a8..674064a 100644 --- a/internal/sinks/postgres/doc.go +++ b/internal/sinks/postgres/doc.go @@ -101,7 +101,7 @@ // - end_time TIMESTAMPTZ -> payload.periods[i].endTime // - name TEXT NULL -> payload.periods[i].name // - is_day BOOLEAN NULL -> payload.periods[i].isDay -// - condition_code INTEGER -> payload.periods[i].conditionCode +// - condition_code INTEGER NULL -> payload.periods[i].conditionCode // - text_description TEXT NULL -> payload.periods[i].textDescription // - temperature_c DOUBLE PRECISION NULL -> payload.periods[i].temperatureC // - temperature_c_min DOUBLE PRECISION NULL -> payload.periods[i].temperatureCMin diff --git a/internal/sinks/postgres/map.go b/internal/sinks/postgres/map.go index c891b79..a8b6af1 100644 --- a/internal/sinks/postgres/map.go +++ b/internal/sinks/postgres/map.go @@ -137,7 +137,7 @@ func mapForecastEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) { "end_time": p.EndTime.UTC(), "name": nullableString(p.Name), "is_day": nullableBool(p.IsDay), - "condition_code": int(p.ConditionCode), + "condition_code": nullableWMOCode(p.ConditionCode), "text_description": nullableString(p.TextDescription), "temperature_c": nullableFloat64(p.TemperatureC), "temperature_c_min": nullableFloat64(p.TemperatureCMin), @@ -370,6 +370,13 @@ func nullableTime(v *time.Time) any { return v.UTC() } +func nullableWMOCode(v *model.WMOCode) any { + if v == nil { + return nil + } + return int(*v) +} + func compactJSONText(v any) (any, error) { if v == nil { return nil, nil diff --git a/internal/sinks/postgres/map_test.go b/internal/sinks/postgres/map_test.go index 651f965..d4ec2d4 100644 --- a/internal/sinks/postgres/map_test.go +++ b/internal/sinks/postgres/map_test.go @@ -63,13 +63,13 @@ func TestMapPostgresEventForecastStructPayload(t *testing.T) { StartTime: time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC), IsDay: &isDay, - ConditionCode: model.WMOCode(2), + ConditionCode: wmoCodePtr(model.WMOCode(2)), TemperatureC: &temp, }, { StartTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 3, 16, 21, 0, 0, 0, time.UTC), - ConditionCode: model.WMOCode(3), + ConditionCode: nil, }, }, } @@ -94,6 +94,9 @@ func TestMapPostgresEventForecastStructPayload(t *testing.T) { if got := writes[1].Values["period_index"]; got != 0 { t.Fatalf("first period index = %#v, want 0", got) } + if got := writes[2].Values["condition_code"]; got != nil { + t.Fatalf("second period condition_code = %#v, want nil", got) + } assertAllWritesIncludeAllColumns(t, writes) } @@ -198,7 +201,7 @@ func TestMapPostgresEventMapPayload(t *testing.T) { { StartTime: time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC), - ConditionCode: model.WMOCode(2), + ConditionCode: wmoCodePtr(model.WMOCode(2)), }, }, } @@ -299,3 +302,8 @@ func tableColumnCounts() map[string]int { } return m } + +func wmoCodePtr(v model.WMOCode) *model.WMOCode { + out := v + return &out +} diff --git a/internal/sinks/postgres/schema.go b/internal/sinks/postgres/schema.go index 1f2d31f..3303090 100644 --- a/internal/sinks/postgres/schema.go +++ b/internal/sinks/postgres/schema.go @@ -104,7 +104,7 @@ func PostgresSchema() fksinks.PostgresSchema { {Name: "end_time", Type: "TIMESTAMPTZ", Nullable: false}, {Name: "name", Type: "TEXT", Nullable: true}, {Name: "is_day", Type: "BOOLEAN", Nullable: true}, - {Name: "condition_code", Type: "INTEGER", Nullable: false}, + {Name: "condition_code", Type: "INTEGER", Nullable: true}, {Name: "text_description", Type: "TEXT", Nullable: true}, {Name: "temperature_c", Type: "DOUBLE PRECISION", Nullable: true}, {Name: "temperature_c_min", Type: "DOUBLE PRECISION", Nullable: true}, diff --git a/model/forecast.go b/model/forecast.go index d021f04..ff89765 100644 --- a/model/forecast.go +++ b/model/forecast.go @@ -71,9 +71,9 @@ type WeatherForecastPeriod struct { // Providers vary in whether they explicitly include this. IsDay *bool `json:"isDay,omitempty"` - // Canonical internal representation (provider-independent). - // Like WeatherObservation, this is required; use an “unknown” WMOCode if unmappable. - ConditionCode WMOCode `json:"conditionCode"` + // Canonical internal representation (provider-independent), when applicable. + // Some products (notably narrative) may not provide or imply a canonical WMO code. + ConditionCode *WMOCode `json:"conditionCode,omitempty"` // Human-facing narrative summary for this period. TextDescription string `json:"textDescription,omitempty"`