Simplified the forecast schema
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:
6
API.md
6
API.md
@@ -143,11 +143,7 @@ A `WeatherForecastPeriod` is valid for `[startTime, endTime)`.
|
|||||||
| `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 | 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 |
|
| `textDescription` | string | no | Human-facing short phrase |
|
||||||
| `detailedText` | string | no | Longer narrative |
|
|
||||||
| `iconUrl` | string | no | Legacy/transitional |
|
|
||||||
| `temperatureC` | number | no | °C |
|
| `temperatureC` | number | no | °C |
|
||||||
| `temperatureCMin` | number | no | °C (aggregated products) |
|
| `temperatureCMin` | number | no | °C (aggregated products) |
|
||||||
| `temperatureCMax` | 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",
|
"startTime": "2026-01-17T14:00:00Z",
|
||||||
"endTime": "2026-01-17T15:00:00Z",
|
"endTime": "2026-01-17T15:00:00Z",
|
||||||
"conditionCode": 2,
|
"conditionCode": 2,
|
||||||
"conditionText": "Partly Cloudy",
|
"textDescription": "Partly Cloudy",
|
||||||
"temperatureC": 3.5,
|
"temperatureC": 3.5,
|
||||||
"probabilityOfPrecipitationPercent": 10
|
"probabilityOfPrecipitationPercent": 10
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,8 +118,6 @@ func buildForecast(parsed nwsForecastResponse) (model.WeatherForecastRun, time.T
|
|||||||
providerDesc := strings.TrimSpace(p.ShortForecast)
|
providerDesc := strings.TrimSpace(p.ShortForecast)
|
||||||
wmo := wmoFromNWSForecast(providerDesc, p.Icon, tempC)
|
wmo := wmoFromNWSForecast(providerDesc, p.Icon, tempC)
|
||||||
|
|
||||||
canonicalText := standards.WMOText(wmo, isDay)
|
|
||||||
|
|
||||||
period := model.WeatherForecastPeriod{
|
period := model.WeatherForecastPeriod{
|
||||||
StartTime: start,
|
StartTime: start,
|
||||||
EndTime: end,
|
EndTime: end,
|
||||||
@@ -128,15 +126,9 @@ func buildForecast(parsed nwsForecastResponse) (model.WeatherForecastRun, time.T
|
|||||||
IsDay: isDay,
|
IsDay: isDay,
|
||||||
|
|
||||||
ConditionCode: wmo,
|
ConditionCode: wmo,
|
||||||
ConditionText: canonicalText,
|
|
||||||
|
|
||||||
ProviderRawDescription: providerDesc,
|
// For forecasts, keep provider short forecast text as the human-facing description.
|
||||||
|
TextDescription: 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),
|
|
||||||
|
|
||||||
TemperatureC: tempC,
|
TemperatureC: tempC,
|
||||||
|
|
||||||
|
|||||||
61
internal/normalizers/nws/forecast_test.go
Normal file
61
internal/normalizers/nws/forecast_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,15 +107,8 @@ func buildForecast(parsed omForecastResponse, fallbackIssued time.Time) (model.W
|
|||||||
Name: "",
|
Name: "",
|
||||||
IsDay: isDay,
|
IsDay: isDay,
|
||||||
|
|
||||||
ConditionCode: wmo,
|
ConditionCode: wmo,
|
||||||
ConditionText: canonicalText,
|
|
||||||
|
|
||||||
ProviderRawDescription: "",
|
|
||||||
|
|
||||||
TextDescription: canonicalText,
|
TextDescription: canonicalText,
|
||||||
DetailedText: "",
|
|
||||||
|
|
||||||
IconURL: "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if v := floatAt(parsed.Hourly.Temperature2m, i); v != nil {
|
if v := floatAt(parsed.Hourly.Temperature2m, i); v != nil {
|
||||||
|
|||||||
71
internal/normalizers/openmeteo/forecast_test.go
Normal file
71
internal/normalizers/openmeteo/forecast_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,11 +102,7 @@
|
|||||||
// - 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 -> 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
|
// - 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 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
|
||||||
// - temperature_c_max DOUBLE PRECISION NULL -> payload.periods[i].temperatureCMax
|
// - temperature_c_max DOUBLE PRECISION NULL -> payload.periods[i].temperatureCMax
|
||||||
|
|||||||
@@ -136,11 +136,7 @@ func mapForecastEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
|
|||||||
"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": int(p.ConditionCode),
|
||||||
"condition_text": nullableString(p.ConditionText),
|
|
||||||
"provider_raw_description": nullableString(p.ProviderRawDescription),
|
|
||||||
"text_description": nullableString(p.TextDescription),
|
"text_description": nullableString(p.TextDescription),
|
||||||
"detailed_text": nullableString(p.DetailedText),
|
|
||||||
"icon_url": nullableString(p.IconURL),
|
|
||||||
"temperature_c": nullableFloat64(p.TemperatureC),
|
"temperature_c": nullableFloat64(p.TemperatureC),
|
||||||
"temperature_c_min": nullableFloat64(p.TemperatureCMin),
|
"temperature_c_min": nullableFloat64(p.TemperatureCMin),
|
||||||
"temperature_c_max": nullableFloat64(p.TemperatureCMax),
|
"temperature_c_max": nullableFloat64(p.TemperatureCMax),
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ func TestMapPostgresEventForecastStructPayload(t *testing.T) {
|
|||||||
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: model.WMOCode(2),
|
||||||
ConditionText: "Partly Cloudy",
|
|
||||||
TemperatureC: &temp,
|
TemperatureC: &temp,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -130,11 +130,7 @@ func weatherPostgresSchema() fksinks.PostgresSchema {
|
|||||||
{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: false},
|
||||||
{Name: "condition_text", Type: "TEXT", Nullable: true},
|
|
||||||
{Name: "provider_raw_description", Type: "TEXT", Nullable: true},
|
|
||||||
{Name: "text_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", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
{Name: "temperature_c_min", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "temperature_c_min", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
{Name: "temperature_c_max", Type: "DOUBLE PRECISION", Nullable: true},
|
{Name: "temperature_c_max", Type: "DOUBLE PRECISION", Nullable: true},
|
||||||
|
|||||||
@@ -75,18 +75,8 @@ type WeatherForecastPeriod struct {
|
|||||||
// Like WeatherObservation, this is required; use an “unknown” WMOCode if unmappable.
|
// Like WeatherObservation, this is required; use an “unknown” WMOCode if unmappable.
|
||||||
ConditionCode WMOCode `json:"conditionCode"`
|
ConditionCode WMOCode `json:"conditionCode"`
|
||||||
|
|
||||||
// Provider-independent short text describing the conditions (normalized, if possible).
|
// Human-facing narrative summary for this period.
|
||||||
ConditionText string `json:"conditionText,omitempty"`
|
TextDescription string `json:"textDescription,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"`
|
|
||||||
|
|
||||||
// Core predicted measurements (nullable; units align with WeatherObservation)
|
// Core predicted measurements (nullable; units align with WeatherObservation)
|
||||||
TemperatureC *float64 `json:"temperatureC,omitempty"`
|
TemperatureC *float64 `json:"temperatureC,omitempty"`
|
||||||
|
|||||||
Reference in New Issue
Block a user