Updated the normalized observation schema to remove duplicate and/or unnecessary fields
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -54,14 +54,6 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation,
|
||||
ts = t.UTC()
|
||||
}
|
||||
|
||||
cloudLayers := make([]model.CloudLayer, 0, len(parsed.Properties.CloudLayers))
|
||||
for _, cl := range parsed.Properties.CloudLayers {
|
||||
cloudLayers = append(cloudLayers, model.CloudLayer{
|
||||
BaseMeters: cl.Base.Value,
|
||||
Amount: cl.Amount,
|
||||
})
|
||||
}
|
||||
|
||||
// Preserve raw presentWeather objects (for troubleshooting / drift analysis).
|
||||
present := make([]model.PresentWeather, 0, len(parsed.Properties.PresentWeather))
|
||||
for _, pw := range parsed.Properties.PresentWeather {
|
||||
@@ -70,6 +62,7 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation,
|
||||
|
||||
// Decode presentWeather into typed METAR phenomena for mapping.
|
||||
phenomena := decodeMetarPhenomena(parsed.Properties.PresentWeather)
|
||||
cloudLayers := parsed.Properties.CloudLayers
|
||||
|
||||
providerDesc := strings.TrimSpace(parsed.Properties.TextDescription)
|
||||
|
||||
@@ -81,9 +74,6 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation,
|
||||
isDay = isDayFromLatLonTime(*lat, *lon, ts)
|
||||
}
|
||||
|
||||
// Canonical condition text comes from our WMO table.
|
||||
canonicalText := standards.WMOText(wmo, isDay)
|
||||
|
||||
// Apparent temperature: prefer wind chill when both are supplied.
|
||||
var apparentC *float64
|
||||
if parsed.Properties.WindChill.Value != nil {
|
||||
@@ -98,15 +88,9 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation,
|
||||
Timestamp: ts,
|
||||
|
||||
ConditionCode: wmo,
|
||||
ConditionText: canonicalText,
|
||||
IsDay: isDay,
|
||||
|
||||
ProviderRawDescription: providerDesc,
|
||||
|
||||
// Transitional / human-facing:
|
||||
// keep output consistent by populating TextDescription from canonical text.
|
||||
TextDescription: canonicalText,
|
||||
IconURL: parsed.Properties.Icon,
|
||||
TextDescription: providerDesc,
|
||||
|
||||
TemperatureC: parsed.Properties.Temperature.Value,
|
||||
DewpointC: parsed.Properties.Dewpoint.Value,
|
||||
@@ -115,19 +99,21 @@ func buildObservation(parsed nwsObservationResponse) (model.WeatherObservation,
|
||||
WindSpeedKmh: parsed.Properties.WindSpeed.Value,
|
||||
WindGustKmh: parsed.Properties.WindGust.Value,
|
||||
|
||||
BarometricPressurePa: parsed.Properties.BarometricPressure.Value,
|
||||
SeaLevelPressurePa: parsed.Properties.SeaLevelPressure.Value,
|
||||
BarometricPressurePa: pressurePrecedenceNWS(parsed.Properties.SeaLevelPressure.Value, parsed.Properties.BarometricPressure.Value),
|
||||
VisibilityMeters: parsed.Properties.Visibility.Value,
|
||||
|
||||
RelativeHumidityPercent: parsed.Properties.RelativeHumidity.Value,
|
||||
ApparentTemperatureC: apparentC,
|
||||
|
||||
ElevationMeters: parsed.Properties.Elevation.Value,
|
||||
RawMessage: parsed.Properties.RawMessage,
|
||||
|
||||
PresentWeather: present,
|
||||
CloudLayers: cloudLayers,
|
||||
}
|
||||
|
||||
return obs, ts, nil
|
||||
}
|
||||
|
||||
func pressurePrecedenceNWS(seaLevelPa, barometricPa *float64) *float64 {
|
||||
if seaLevelPa != nil {
|
||||
return seaLevelPa
|
||||
}
|
||||
return barometricPa
|
||||
}
|
||||
|
||||
51
internal/normalizers/nws/observation_test.go
Normal file
51
internal/normalizers/nws/observation_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package nws
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildObservationTextDescriptionAndPressurePrecedence(t *testing.T) {
|
||||
barometric := 100200.0
|
||||
seaLevel := 101400.0
|
||||
|
||||
parsed := nwsObservationResponse{}
|
||||
parsed.Properties.Timestamp = "2026-03-16T19:00:00Z"
|
||||
parsed.Properties.TextDescription = " Overcast "
|
||||
parsed.Properties.BarometricPressure.Value = &barometric
|
||||
parsed.Properties.SeaLevelPressure.Value = &seaLevel
|
||||
|
||||
obs, _, err := buildObservation(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("buildObservation() error = %v", err)
|
||||
}
|
||||
|
||||
if got, want := obs.TextDescription, "Overcast"; got != want {
|
||||
t.Fatalf("TextDescription = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if obs.BarometricPressurePa == nil {
|
||||
t.Fatalf("BarometricPressurePa = nil, want non-nil")
|
||||
}
|
||||
if got, want := *obs.BarometricPressurePa, seaLevel; got != want {
|
||||
t.Fatalf("BarometricPressurePa = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildObservationPressureFallbackToBarometric(t *testing.T) {
|
||||
barometric := 99900.0
|
||||
|
||||
parsed := nwsObservationResponse{}
|
||||
parsed.Properties.Timestamp = "2026-03-16T19:00:00Z"
|
||||
parsed.Properties.TextDescription = "Cloudy"
|
||||
parsed.Properties.BarometricPressure.Value = &barometric
|
||||
|
||||
obs, _, err := buildObservation(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("buildObservation() error = %v", err)
|
||||
}
|
||||
|
||||
if obs.BarometricPressurePa == nil {
|
||||
t.Fatalf("BarometricPressurePa = nil, want non-nil")
|
||||
}
|
||||
if got, want := *obs.BarometricPressurePa, barometric; got != want {
|
||||
t.Fatalf("BarometricPressurePa = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -86,16 +86,18 @@ type nwsObservationResponse struct {
|
||||
// We decode these as generic maps, then optionally interpret them in metar.go.
|
||||
PresentWeather []map[string]any `json:"presentWeather"`
|
||||
|
||||
CloudLayers []struct {
|
||||
Base struct {
|
||||
UnitCode string `json:"unitCode"`
|
||||
Value *float64 `json:"value"`
|
||||
} `json:"base"`
|
||||
Amount string `json:"amount"`
|
||||
} `json:"cloudLayers"`
|
||||
CloudLayers []nwsCloudLayer `json:"cloudLayers"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
|
||||
type nwsCloudLayer struct {
|
||||
Base struct {
|
||||
UnitCode string `json:"unitCode"`
|
||||
Value *float64 `json:"value"`
|
||||
} `json:"base"`
|
||||
Amount string `json:"amount"`
|
||||
}
|
||||
|
||||
// nwsForecastResponse is a minimal-but-sufficient representation of the NWS
|
||||
// gridpoint forecast GeoJSON payload needed for mapping into model.WeatherForecastRun.
|
||||
//
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// 1. METAR phenomena (presentWeather) — most reliable for precip/hazards
|
||||
// 2. textDescription keywords — weaker, but reusable across providers
|
||||
// 3. cloud layers fallback — only for sky-only conditions
|
||||
func mapNWSToWMO(providerDesc string, cloudLayers []model.CloudLayer, phenomena []metarPhenomenon) model.WMOCode {
|
||||
func mapNWSToWMO(providerDesc string, cloudLayers []nwsCloudLayer, phenomena []metarPhenomenon) model.WMOCode {
|
||||
// 1) Prefer METAR phenomena if present.
|
||||
if code := wmoFromPhenomena(phenomena); code != model.WMOUnknown {
|
||||
return code
|
||||
@@ -167,7 +167,7 @@ func wmoFromPhenomena(phenomena []metarPhenomenon) model.WMOCode {
|
||||
return model.WMOUnknown
|
||||
}
|
||||
|
||||
func wmoFromCloudLayers(cloudLayers []model.CloudLayer) model.WMOCode {
|
||||
func wmoFromCloudLayers(cloudLayers []nwsCloudLayer) model.WMOCode {
|
||||
// NWS cloud layer amount values commonly include:
|
||||
// OVC, BKN, SCT, FEW, SKC, CLR, VV (vertical visibility / obscured sky)
|
||||
//
|
||||
|
||||
@@ -86,20 +86,9 @@ func buildObservation(parsed omResponse) (model.WeatherObservation, time.Time, e
|
||||
StationName: "Open-Meteo",
|
||||
Timestamp: ts,
|
||||
|
||||
ConditionCode: wmo,
|
||||
ConditionText: canonicalText,
|
||||
IsDay: isDay,
|
||||
|
||||
// Open-Meteo does not provide a separate human text description for "current"
|
||||
// when using weather_code; we leave provider evidence empty.
|
||||
ProviderRawDescription: "",
|
||||
|
||||
// Transitional / human-facing:
|
||||
// keep output consistent by populating TextDescription from canonical text.
|
||||
ConditionCode: wmo,
|
||||
IsDay: isDay,
|
||||
TextDescription: canonicalText,
|
||||
|
||||
// IconURL: Open-Meteo does not provide an icon URL in this endpoint.
|
||||
IconURL: "",
|
||||
}
|
||||
|
||||
// Measurements (all optional; only set when present).
|
||||
@@ -132,20 +121,13 @@ func buildObservation(parsed omResponse) (model.WeatherObservation, time.Time, e
|
||||
obs.WindGustKmh = &v
|
||||
}
|
||||
|
||||
if parsed.Current.SurfacePressure != nil {
|
||||
if parsed.Current.PressureMSL != nil {
|
||||
v := normcommon.PressurePaFromHPa(*parsed.Current.PressureMSL)
|
||||
obs.BarometricPressurePa = &v
|
||||
} else if parsed.Current.SurfacePressure != nil {
|
||||
v := normcommon.PressurePaFromHPa(*parsed.Current.SurfacePressure)
|
||||
obs.BarometricPressurePa = &v
|
||||
}
|
||||
|
||||
if parsed.Current.PressureMSL != nil {
|
||||
v := normcommon.PressurePaFromHPa(*parsed.Current.PressureMSL)
|
||||
obs.SeaLevelPressurePa = &v
|
||||
}
|
||||
|
||||
if parsed.Elevation != nil {
|
||||
v := *parsed.Elevation
|
||||
obs.ElevationMeters = &v
|
||||
}
|
||||
|
||||
return obs, ts, nil
|
||||
}
|
||||
|
||||
61
internal/normalizers/openmeteo/observation_test.go
Normal file
61
internal/normalizers/openmeteo/observation_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package openmeteo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildObservationTextDescriptionAndPressurePrecedence(t *testing.T) {
|
||||
weatherCode := 2
|
||||
pressureMSL := 1016.0
|
||||
surfacePressure := 1009.0
|
||||
|
||||
parsed := omResponse{
|
||||
Timezone: "UTC",
|
||||
UTCOffsetSeconds: 0,
|
||||
Current: omCurrent{
|
||||
Time: "2026-03-16T19:00",
|
||||
WeatherCode: &weatherCode,
|
||||
PressureMSL: &pressureMSL,
|
||||
SurfacePressure: &surfacePressure,
|
||||
},
|
||||
}
|
||||
|
||||
obs, _, err := buildObservation(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("buildObservation() error = %v", err)
|
||||
}
|
||||
|
||||
if got, want := obs.TextDescription, "Partly Cloudy"; got != want {
|
||||
t.Fatalf("TextDescription = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if obs.BarometricPressurePa == nil {
|
||||
t.Fatalf("BarometricPressurePa = nil, want non-nil")
|
||||
}
|
||||
if got, want := *obs.BarometricPressurePa, 101600.0; got != want {
|
||||
t.Fatalf("BarometricPressurePa = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildObservationPressureFallbackToSurface(t *testing.T) {
|
||||
surfacePressure := 1008.0
|
||||
|
||||
parsed := omResponse{
|
||||
Timezone: "UTC",
|
||||
UTCOffsetSeconds: 0,
|
||||
Current: omCurrent{
|
||||
Time: "2026-03-16T19:00",
|
||||
SurfacePressure: &surfacePressure,
|
||||
},
|
||||
}
|
||||
|
||||
obs, _, err := buildObservation(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("buildObservation() error = %v", err)
|
||||
}
|
||||
|
||||
if obs.BarometricPressurePa == nil {
|
||||
t.Fatalf("BarometricPressurePa = nil, want non-nil")
|
||||
}
|
||||
if got, want := *obs.BarometricPressurePa, 100800.0; got != want {
|
||||
t.Fatalf("BarometricPressurePa = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -53,15 +53,6 @@ func inferIsDay(icon string, dt, sunrise, sunset int64) *bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openWeatherIconURL builds the standard OpenWeather icon URL for the given icon code.
|
||||
func openWeatherIconURL(icon string) string {
|
||||
icon = strings.TrimSpace(icon)
|
||||
if icon == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://openweathermap.org/img/wn/%s@2x.png", icon)
|
||||
}
|
||||
|
||||
// openWeatherStationID returns a stable station identifier for the given response.
|
||||
// Prefer the OpenWeather city ID when present; otherwise, fall back to coordinates.
|
||||
func openWeatherStationID(parsed owmResponse) string {
|
||||
|
||||
@@ -75,10 +75,10 @@ func buildObservation(parsed owmResponse) (model.WeatherObservation, time.Time,
|
||||
}
|
||||
|
||||
surfacePa := normcommon.PressurePaFromHPa(parsed.Main.Pressure)
|
||||
var seaLevelPa *float64
|
||||
barometricPa := &surfacePa
|
||||
if parsed.Main.SeaLevel != nil {
|
||||
v := normcommon.PressurePaFromHPa(*parsed.Main.SeaLevel)
|
||||
seaLevelPa = &v
|
||||
barometricPa = &v
|
||||
}
|
||||
|
||||
wsKmh := normcommon.SpeedKmhFromMps(parsed.Wind.Speed)
|
||||
@@ -96,9 +96,6 @@ func buildObservation(parsed owmResponse) (model.WeatherObservation, time.Time,
|
||||
|
||||
// Condition mapping: OpenWeather condition IDs -> canonical WMO code vocabulary.
|
||||
wmo := mapOpenWeatherToWMO(owmID)
|
||||
canonicalText := standards.WMOText(wmo, isDay)
|
||||
|
||||
iconURL := openWeatherIconURL(icon)
|
||||
|
||||
stationID := openWeatherStationID(parsed)
|
||||
stationName := strings.TrimSpace(parsed.Name)
|
||||
@@ -111,15 +108,9 @@ func buildObservation(parsed owmResponse) (model.WeatherObservation, time.Time,
|
||||
StationName: stationName,
|
||||
Timestamp: ts,
|
||||
|
||||
ConditionCode: wmo,
|
||||
ConditionText: canonicalText,
|
||||
IsDay: isDay,
|
||||
|
||||
ProviderRawDescription: rawDesc,
|
||||
|
||||
// Human-facing legacy fields: populate with canonical text for consistency.
|
||||
TextDescription: canonicalText,
|
||||
IconURL: iconURL,
|
||||
ConditionCode: wmo,
|
||||
IsDay: isDay,
|
||||
TextDescription: rawDesc,
|
||||
|
||||
TemperatureC: &tempC,
|
||||
ApparentTemperatureC: apparentC,
|
||||
@@ -128,8 +119,7 @@ func buildObservation(parsed owmResponse) (model.WeatherObservation, time.Time,
|
||||
WindSpeedKmh: &wsKmh,
|
||||
WindGustKmh: wgKmh,
|
||||
|
||||
BarometricPressurePa: &surfacePa,
|
||||
SeaLevelPressurePa: seaLevelPa,
|
||||
BarometricPressurePa: barometricPa,
|
||||
VisibilityMeters: visM,
|
||||
|
||||
RelativeHumidityPercent: &rh,
|
||||
|
||||
58
internal/normalizers/openweather/observation_test.go
Normal file
58
internal/normalizers/openweather/observation_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package openweather
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildObservationTextDescriptionAndPressurePrecedence(t *testing.T) {
|
||||
seaLevel := 1018.0
|
||||
|
||||
parsed := owmResponse{}
|
||||
parsed.Dt = 1710000000
|
||||
parsed.Main.Temp = 20.0
|
||||
parsed.Main.Humidity = 45.0
|
||||
parsed.Main.Pressure = 1000.0
|
||||
parsed.Main.SeaLevel = &seaLevel
|
||||
parsed.Wind.Speed = 3.0
|
||||
parsed.Weather = []owmWeather{
|
||||
{ID: 801, Main: "Clouds", Description: "few clouds", Icon: "02d"},
|
||||
}
|
||||
|
||||
obs, _, err := buildObservation(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("buildObservation() error = %v", err)
|
||||
}
|
||||
|
||||
if obs.TextDescription != "few clouds" {
|
||||
t.Fatalf("TextDescription = %q, want %q", obs.TextDescription, "few clouds")
|
||||
}
|
||||
|
||||
if obs.BarometricPressurePa == nil {
|
||||
t.Fatalf("BarometricPressurePa = nil, want non-nil")
|
||||
}
|
||||
if got, want := *obs.BarometricPressurePa, 101800.0; got != want {
|
||||
t.Fatalf("BarometricPressurePa = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildObservationPressureFallbackToSurface(t *testing.T) {
|
||||
parsed := owmResponse{}
|
||||
parsed.Dt = 1710000000
|
||||
parsed.Main.Temp = 20.0
|
||||
parsed.Main.Humidity = 45.0
|
||||
parsed.Main.Pressure = 1001.0
|
||||
parsed.Wind.Speed = 3.0
|
||||
parsed.Weather = []owmWeather{
|
||||
{ID: 800, Description: "clear sky", Icon: "01d"},
|
||||
}
|
||||
|
||||
obs, _, err := buildObservation(parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("buildObservation() error = %v", err)
|
||||
}
|
||||
|
||||
if obs.BarometricPressurePa == nil {
|
||||
t.Fatalf("BarometricPressurePa = nil, want non-nil")
|
||||
}
|
||||
if got, want := *obs.BarometricPressurePa, 100100.0; got != want {
|
||||
t.Fatalf("BarometricPressurePa = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user