Made forecast-period conditionCode optional
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
This commit is contained in:
2
API.md
2
API.md
@@ -152,7 +152,7 @@ A `WeatherForecastPeriod` is valid for `[startTime, endTime)`.
|
|||||||
| `endTime` | string (timestamp) | yes | Period end |
|
| `endTime` | string (timestamp) | yes | Period end |
|
||||||
| `name` | string | no | Human label (often empty for hourly) |
|
| `name` | string | no | Human label (often empty for hourly) |
|
||||||
| `isDay` | bool | no | Day/night hint |
|
| `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 |
|
| `textDescription` | string | no | Human-facing short phrase |
|
||||||
| `temperatureC` | number | no | °C |
|
| `temperatureC` | number | no | °C |
|
||||||
| `temperatureCMin` | number | no | °C (aggregated products) |
|
| `temperatureCMin` | number | no | °C (aggregated products) |
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ import (
|
|||||||
// builders by raw schema.
|
// builders by raw schema.
|
||||||
//
|
//
|
||||||
// Caveats / policy:
|
// Caveats / policy:
|
||||||
// 1. NWS forecast periods do not include METAR presentWeather phenomena, so ConditionCode
|
// 1. Hourly NWS forecast periods do not include METAR presentWeather phenomena, so
|
||||||
// is inferred from period.shortForecast (with a conservative icon-based fallback).
|
// ConditionCode is inferred from period.shortForecast (with a conservative icon fallback).
|
||||||
// 2. Temperature is converted to °C when NWS supplies °F.
|
// 2. Narrative NWS periods intentionally leave ConditionCode unset.
|
||||||
// 3. WindSpeed is parsed from strings like "9 mph" / "10 to 15 mph" and converted to km/h.
|
// 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{}
|
type ForecastNormalizer struct{}
|
||||||
|
|
||||||
func (ForecastNormalizer) Match(e event.Event) bool {
|
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).
|
// Infer WMO from shortForecast (and fall back to icon token).
|
||||||
providerDesc := strings.TrimSpace(p.ShortForecast)
|
providerDesc := strings.TrimSpace(p.ShortForecast)
|
||||||
wmo := wmoFromNWSForecast(providerDesc, p.Icon, tempC)
|
wmo := wmoFromNWSForecast(providerDesc, p.Icon, tempC)
|
||||||
|
wmoPtr := wmoCodePtr(wmo)
|
||||||
|
|
||||||
return model.WeatherForecastPeriod{
|
return model.WeatherForecastPeriod{
|
||||||
StartTime: start,
|
StartTime: start,
|
||||||
@@ -232,7 +234,7 @@ func mapHourlyForecastPeriod(idx int, p nwsHourlyForecastPeriod) (model.WeatherF
|
|||||||
Name: strings.TrimSpace(p.Name),
|
Name: strings.TrimSpace(p.Name),
|
||||||
IsDay: isDay,
|
IsDay: isDay,
|
||||||
|
|
||||||
ConditionCode: wmo,
|
ConditionCode: wmoPtr,
|
||||||
|
|
||||||
// For forecasts, keep provider short forecast text as the human-facing description.
|
// For forecasts, keep provider short forecast text as the human-facing description.
|
||||||
TextDescription: providerDesc,
|
TextDescription: providerDesc,
|
||||||
@@ -264,9 +266,7 @@ func mapNarrativeForecastPeriod(idx int, p nwsNarrativeForecastPeriod) (model.We
|
|||||||
|
|
||||||
tempC := tempCFromNWS(p.Temperature, p.TemperatureUnit)
|
tempC := tempCFromNWS(p.Temperature, p.TemperatureUnit)
|
||||||
|
|
||||||
// Infer WMO from shortForecast (and fall back to icon token).
|
|
||||||
shortForecast := strings.TrimSpace(p.ShortForecast)
|
shortForecast := strings.TrimSpace(p.ShortForecast)
|
||||||
wmo := wmoFromNWSForecast(shortForecast, p.Icon, tempC)
|
|
||||||
|
|
||||||
textDescription := strings.TrimSpace(p.DetailedForecast)
|
textDescription := strings.TrimSpace(p.DetailedForecast)
|
||||||
if textDescription == "" {
|
if textDescription == "" {
|
||||||
@@ -280,7 +280,7 @@ func mapNarrativeForecastPeriod(idx int, p nwsNarrativeForecastPeriod) (model.We
|
|||||||
Name: strings.TrimSpace(p.Name),
|
Name: strings.TrimSpace(p.Name),
|
||||||
IsDay: isDay,
|
IsDay: isDay,
|
||||||
|
|
||||||
ConditionCode: wmo,
|
ConditionCode: nil,
|
||||||
|
|
||||||
TextDescription: textDescription,
|
TextDescription: textDescription,
|
||||||
|
|
||||||
@@ -292,3 +292,8 @@ func mapNarrativeForecastPeriod(idx int, p nwsNarrativeForecastPeriod) (model.We
|
|||||||
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitation.Value,
|
ProbabilityOfPrecipitationPercent: p.ProbabilityOfPrecipitation.Value,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wmoCodePtr(code model.WMOCode) *model.WMOCode {
|
||||||
|
out := code
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ func TestBuildHourlyForecastUsesShortForecastAsTextDescription(t *testing.T) {
|
|||||||
if got, want := run.Periods[0].TextDescription, "Mostly Cloudy"; got != want {
|
if got, want := run.Periods[0].TextDescription, "Mostly Cloudy"; got != want {
|
||||||
t.Fatalf("TextDescription = %q, want %q", 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)
|
wantIssued := time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC)
|
||||||
if !run.IssuedAt.Equal(wantIssued) {
|
if !run.IssuedAt.Equal(wantIssued) {
|
||||||
@@ -261,6 +264,9 @@ func TestBuildNarrativeForecastMapsExpectedFields(t *testing.T) {
|
|||||||
if p.ProbabilityOfPrecipitationPercent == nil || *p.ProbabilityOfPrecipitationPercent != 20 {
|
if p.ProbabilityOfPrecipitationPercent == nil || *p.ProbabilityOfPrecipitationPercent != 20 {
|
||||||
t.Fatalf("ProbabilityOfPrecipitationPercent = %v, want 20", p.ProbabilityOfPrecipitationPercent)
|
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)
|
wantIssued := time.Date(2026, 3, 27, 15, 17, 1, 0, time.UTC)
|
||||||
if !run.IssuedAt.Equal(wantIssued) {
|
if !run.IssuedAt.Equal(wantIssued) {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func buildForecast(parsed omForecastResponse, fallbackIssued time.Time) (model.W
|
|||||||
}
|
}
|
||||||
|
|
||||||
wmo := wmoAt(parsed.Hourly.WeatherCode, i)
|
wmo := wmoAt(parsed.Hourly.WeatherCode, i)
|
||||||
|
wmoPtr := wmoCodePtr(wmo)
|
||||||
canonicalText := standards.WMOText(wmo, isDay)
|
canonicalText := standards.WMOText(wmo, isDay)
|
||||||
|
|
||||||
period := model.WeatherForecastPeriod{
|
period := model.WeatherForecastPeriod{
|
||||||
@@ -107,7 +108,7 @@ func buildForecast(parsed omForecastResponse, fallbackIssued time.Time) (model.W
|
|||||||
Name: "",
|
Name: "",
|
||||||
IsDay: isDay,
|
IsDay: isDay,
|
||||||
|
|
||||||
ConditionCode: wmo,
|
ConditionCode: wmoPtr,
|
||||||
TextDescription: canonicalText,
|
TextDescription: canonicalText,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,3 +238,8 @@ func wmoAt(vals []*int, idx int) model.WMOCode {
|
|||||||
}
|
}
|
||||||
return model.WMOUnknown
|
return model.WMOUnknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wmoCodePtr(code model.WMOCode) *model.WMOCode {
|
||||||
|
out := code
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ func TestBuildForecastUsesCanonicalTextDescription(t *testing.T) {
|
|||||||
if got := run.Periods[0].TextDescription; got != expectedText {
|
if got := run.Periods[0].TextDescription; got != expectedText {
|
||||||
t.Fatalf("TextDescription = %q, want %q", 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)
|
wantIssued := time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC)
|
||||||
if !run.IssuedAt.Equal(wantIssued) {
|
if !run.IssuedAt.Equal(wantIssued) {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
// - end_time TIMESTAMPTZ -> payload.periods[i].endTime
|
// - end_time TIMESTAMPTZ -> payload.periods[i].endTime
|
||||||
// - name TEXT NULL -> payload.periods[i].name
|
// - name TEXT NULL -> payload.periods[i].name
|
||||||
// - is_day BOOLEAN NULL -> payload.periods[i].isDay
|
// - 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
|
// - text_description TEXT NULL -> payload.periods[i].textDescription
|
||||||
// - temperature_c DOUBLE PRECISION NULL -> payload.periods[i].temperatureC
|
// - temperature_c DOUBLE PRECISION NULL -> payload.periods[i].temperatureC
|
||||||
// - temperature_c_min DOUBLE PRECISION NULL -> payload.periods[i].temperatureCMin
|
// - temperature_c_min DOUBLE PRECISION NULL -> payload.periods[i].temperatureCMin
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ func mapForecastEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
"end_time": p.EndTime.UTC(),
|
"end_time": p.EndTime.UTC(),
|
||||||
"name": nullableString(p.Name),
|
"name": nullableString(p.Name),
|
||||||
"is_day": nullableBool(p.IsDay),
|
"is_day": nullableBool(p.IsDay),
|
||||||
"condition_code": int(p.ConditionCode),
|
"condition_code": nullableWMOCode(p.ConditionCode),
|
||||||
"text_description": nullableString(p.TextDescription),
|
"text_description": nullableString(p.TextDescription),
|
||||||
"temperature_c": nullableFloat64(p.TemperatureC),
|
"temperature_c": nullableFloat64(p.TemperatureC),
|
||||||
"temperature_c_min": nullableFloat64(p.TemperatureCMin),
|
"temperature_c_min": nullableFloat64(p.TemperatureCMin),
|
||||||
@@ -370,6 +370,13 @@ func nullableTime(v *time.Time) any {
|
|||||||
return v.UTC()
|
return v.UTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullableWMOCode(v *model.WMOCode) any {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return int(*v)
|
||||||
|
}
|
||||||
|
|
||||||
func compactJSONText(v any) (any, error) {
|
func compactJSONText(v any) (any, error) {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -63,13 +63,13 @@ func TestMapPostgresEventForecastStructPayload(t *testing.T) {
|
|||||||
StartTime: time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC),
|
StartTime: time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC),
|
||||||
EndTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC),
|
EndTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC),
|
||||||
IsDay: &isDay,
|
IsDay: &isDay,
|
||||||
ConditionCode: model.WMOCode(2),
|
ConditionCode: wmoCodePtr(model.WMOCode(2)),
|
||||||
TemperatureC: &temp,
|
TemperatureC: &temp,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
StartTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC),
|
StartTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC),
|
||||||
EndTime: time.Date(2026, 3, 16, 21, 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 {
|
if got := writes[1].Values["period_index"]; got != 0 {
|
||||||
t.Fatalf("first period index = %#v, want 0", got)
|
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)
|
assertAllWritesIncludeAllColumns(t, writes)
|
||||||
}
|
}
|
||||||
@@ -198,7 +201,7 @@ func TestMapPostgresEventMapPayload(t *testing.T) {
|
|||||||
{
|
{
|
||||||
StartTime: time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC),
|
StartTime: time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC),
|
||||||
EndTime: time.Date(2026, 3, 16, 20, 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
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wmoCodePtr(v model.WMOCode) *model.WMOCode {
|
||||||
|
out := v
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func PostgresSchema() fksinks.PostgresSchema {
|
|||||||
{Name: "end_time", Type: "TIMESTAMPTZ", Nullable: false},
|
{Name: "end_time", Type: "TIMESTAMPTZ", Nullable: false},
|
||||||
{Name: "name", Type: "TEXT", Nullable: true},
|
{Name: "name", Type: "TEXT", Nullable: true},
|
||||||
{Name: "is_day", Type: "BOOLEAN", 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: "text_description", Type: "TEXT", Nullable: true},
|
||||||
{Name: "temperature_c", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "temperature_c", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
{Name: "temperature_c_min", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "temperature_c_min", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ type WeatherForecastPeriod struct {
|
|||||||
// Providers vary in whether they explicitly include this.
|
// Providers vary in whether they explicitly include this.
|
||||||
IsDay *bool `json:"isDay,omitempty"`
|
IsDay *bool `json:"isDay,omitempty"`
|
||||||
|
|
||||||
// Canonical internal representation (provider-independent).
|
// Canonical internal representation (provider-independent), when applicable.
|
||||||
// Like WeatherObservation, this is required; use an “unknown” WMOCode if unmappable.
|
// Some products (notably narrative) may not provide or imply a canonical WMO code.
|
||||||
ConditionCode WMOCode `json:"conditionCode"`
|
ConditionCode *WMOCode `json:"conditionCode,omitempty"`
|
||||||
|
|
||||||
// Human-facing narrative summary for this period.
|
// Human-facing narrative summary for this period.
|
||||||
TextDescription string `json:"textDescription,omitempty"`
|
TextDescription string `json:"textDescription,omitempty"`
|
||||||
|
|||||||
Reference in New Issue
Block a user