7 Commits
v0.5.3 ... main

Author SHA1 Message Date
3136131519 Clean up completed roadmap documents
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-07-02 07:48:56 -05:00
8578db99b3 Document current conditions consensus selection
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-29 12:03:57 +00:00
86fd848a6f Preserve current conditions condition code presentation 2026-06-29 12:02:37 +00:00
6f8e01729d Split current conditions condition code querying 2026-06-29 12:00:32 +00:00
3c7ba89e15 Add current conditions condition code consensus helper 2026-06-29 11:56:20 +00:00
cfe6748330 Add a feature roadmap and implementation plan for a consensus-based algorithm for the condition code in the current conditions endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
2026-06-28 16:44:34 -05:00
5a1134b955 Suppress superseded alerts in the /alerts/active endpoint
All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
2026-06-17 06:48:14 -05:00
18 changed files with 714 additions and 42 deletions

View File

@@ -162,8 +162,10 @@ GET /observations?units=us&precision=1
GET /conditions/current GET /conditions/current
``` ```
Returns current conditions aggregated from recent `observations` rows. The Returns current conditions from recent `observations` rows. Numeric fields are
implemented observation window is 30 minutes. aggregated over the implemented 30-minute observation window. `conditionCode`
is selected from the latest observation per source in that window by
source-balanced WMO family consensus.
Query parameters: `format`, `units`, `precision`. Query parameters: `format`, `units`, `precision`.
@@ -171,6 +173,7 @@ Common `data` fields:
| Field | Type | Notes | | Field | Type | Notes |
| --- | --- | --- | | --- | --- | --- |
| `conditionCode` | integer | WMO weather code selected by source-balanced family consensus |
| `conditionText` | string | optional text derived from WMO code and day/night flag | | `conditionText` | string | optional text derived from WMO code and day/night flag |
| `isDay` | boolean | optional | | `isDay` | boolean | optional |
| `relativeHumidityPercent` | number | optional | | `relativeHumidityPercent` | number | optional |
@@ -194,7 +197,8 @@ GET /conditions/current?format=json&precision=0
GET /alerts/active GET /alerts/active
``` ```
Returns the latest stored alert run filtered to alerts active at request time. Returns the latest stored alert run filtered to alerts active at request time,
omitting older alerts superseded by newer alert references in the same run.
Query parameters: `format`, `units`. Query parameters: `format`, `units`.
@@ -216,6 +220,9 @@ at or before request time, and the alert end boundary is absent or after request
time. The end boundary prefers `ends`; if `ends` is absent, `expires` is used as time. The end boundary prefers `ends`; if `ends` is absent, `expires` is used as
a fallback for older rows or providers that do not supply an alert-period end. a fallback for older rows or providers that do not supply an alert-period end.
`onset` is presented when available but is not used as the active boundary. `onset` is presented when available but is not used as the active boundary.
After active-time filtering, alerts referenced by another alert in the same run
are omitted as superseded. References from update and cancel messages are both
honored, even when the referencing alert is not itself returned.
Alert fields include `id`, `event`, `headline`, `severity`, `urgency`, Alert fields include `id`, `event`, `headline`, `severity`, `urgency`,
`certainty`, `status`, `messageType`, `category`, `response`, `description`, `certainty`, `status`, `messageType`, `category`, `response`, `description`,

View File

@@ -57,8 +57,10 @@ Latest parent resources use these ordering rules:
story_order ASC, story_index ASC`. story_order ASC, story_index ASC`.
- convective outlook runs: `as_of DESC, event_emitted_at DESC`. - convective outlook runs: `as_of DESC, event_emitted_at DESC`.
Current conditions aggregate `observations` rows where `observed_at` is inside Current conditions aggregate numeric values from `observations` rows where
the application-provided observation window. `observed_at` is inside the application-provided observation window. They also
use the latest row per `event_source` in that window to select `condition_code`
by source-balanced WMO family consensus.
## Child Ordering ## Child Ordering
@@ -79,15 +81,16 @@ The repository reads only these columns.
### `observations` ### `observations`
`event_id`, `station_id`, `station_name`, `observed_at`, `condition_code`, `event_id`, `event_source`, `station_id`, `station_name`, `observed_at`,
`is_day`, `text_description`, `temperature_c`, `dewpoint_c`, `condition_code`, `is_day`, `text_description`, `temperature_c`, `dewpoint_c`,
`wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`, `wind_direction_degrees`, `wind_speed_kmh`, `wind_gust_kmh`,
`barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`, `barometric_pressure_pa`, `visibility_meters`, `relative_humidity_percent`,
`apparent_temperature_c`, and `event_emitted_at`. `apparent_temperature_c`, and `event_emitted_at`.
Current conditions additionally aggregate recent `observations` values for Current conditions additionally read recent `observations` values for
temperature, apparent temperature, dewpoint, humidity, wind speed, wind temperature, apparent temperature, dewpoint, humidity, wind speed, wind
direction, condition code, and latest `is_day`. direction, latest `is_day`, and latest condition-code candidates per
`event_source`.
### `observation_present_weather` ### `observation_present_weather`

View File

@@ -136,7 +136,10 @@ behavior deterministic.
the application service with `alertNow().UTC()` so active alert filtering uses the application service with `alertNow().UTC()` so active alert filtering uses
the request-time instant while remaining deterministic in endpoint tests. The the request-time instant while remaining deterministic in endpoint tests. The
application service prefers alert `ends` over `expires` when deciding whether an application service prefers alert `ends` over `expires` when deciding whether an
alert has ended. alert has ended. The application service also suppresses alerts referenced by
another alert in the same latest run. This supersession rule uses alert
references from update and cancel messages, even when the referencing message is
not returned by `/alerts/active`.
## Outlook Filters ## Outlook Filters

View File

@@ -76,8 +76,9 @@ successful responses with `data: null`.
- `LatestObservation`: latest row from `observations`, then present-weather - `LatestObservation`: latest row from `observations`, then present-weather
rows from `observation_present_weather`. rows from `observation_present_weather`.
- `CurrentConditions`: aggregates recent rows from `observations` using the - `CurrentConditions`: reads an aggregate row from recent `observations` using
application-provided observation window. the application-provided observation window, then reads the latest
condition-code candidate per `event_source` in the same window.
- `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and - `LatestAlertRun`: latest row from `alert_runs`, then child `alerts` and
`alert_references`. This is the latest stored alert snapshot. The repository `alert_references`. This is the latest stored alert snapshot. The repository
maps both `ends` and `expires`; active-time filtering is performed by the maps both `ends` and `expires`; active-time filtering is performed by the

View File

@@ -1361,6 +1361,9 @@ func TestCurrentConditionsMetricDefaultJSON(t *testing.T) {
if payload.Data["conditionText"] != "Rain" { if payload.Data["conditionText"] != "Rain" {
t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"]) t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"])
} }
if payload.Data["conditionCode"] != float64(63) {
t.Fatalf("expected conditionCode 63, got %#v", payload.Data["conditionCode"])
}
} }
func TestCurrentConditionsUSJSON(t *testing.T) { func TestCurrentConditionsUSJSON(t *testing.T) {
@@ -1426,6 +1429,9 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
if !strings.Contains(w.Body.String(), "temperatureF") { if !strings.Contains(w.Body.String(), "temperatureF") {
t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String()) t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String())
} }
if !strings.Contains(w.Body.String(), "<conditionCode>2</conditionCode>") {
t.Fatalf("expected conditionCode in XML payload: %s", w.Body.String())
}
w = httptest.NewRecorder() w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil) req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil)
@@ -1437,7 +1443,7 @@ func TestCurrentConditionsXMLAndTextFormats(t *testing.T) {
if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type"))
} }
if !strings.Contains(w.Body.String(), "Conditions text") { if !strings.Contains(w.Body.String(), "Condition Code: 2") {
t.Fatalf("expected rendered text template body, got %q", w.Body.String()) t.Fatalf("expected rendered text template body, got %q", w.Body.String())
} }
} }
@@ -2458,7 +2464,7 @@ func testRenderers(t *testing.T) *render.Registry {
"weatherstories.txt.tmpl": "Weather Stories", "weatherstories.txt.tmpl": "Weather Stories",
"weatherstories_latest.txt.tmpl": "Latest Weather Story", "weatherstories_latest.txt.tmpl": "Latest Weather Story",
"alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{if .Ends}}\nEnds: {{.Ends}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}", "alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{if .Ends}}\nEnds: {{.Ends}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}",
"conditions_current.txt.tmpl": "Conditions text", "conditions_current.txt.tmpl": "Condition Code: {{.Data.ConditionCode}}",
} { } {
tmpl, err := template.New(name).Parse(body) tmpl, err := template.New(name).Parse(body)
if err != nil { if err != nil {

View File

@@ -4,6 +4,7 @@ package presenter
import ( import (
"gitea.maximumdirect.net/ejr/weatherapi/internal/app" "gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
"gitea.maximumdirect.net/ejr/weatherfeeder/standards" "gitea.maximumdirect.net/ejr/weatherfeeder/standards"
) )
@@ -20,6 +21,7 @@ type CurrentConditionsResponse struct {
WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"` WindSpeedMph *float64 `json:"windSpeedMph,omitempty" xml:"windSpeedMph,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"` RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty" xml:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"` WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty" xml:"windDirectionDegrees,omitempty"`
ConditionCode model.WMOCode `json:"conditionCode" xml:"conditionCode"`
ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"` ConditionText string `json:"conditionText,omitempty" xml:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"` IsDay *bool `json:"isDay,omitempty" xml:"isDay,omitempty"`
IsDayText string `json:"-" xml:"-"` IsDayText string `json:"-" xml:"-"`
@@ -33,6 +35,7 @@ func CurrentConditionsPayload(conditions *app.CurrentConditions, units Units, pr
out := CurrentConditionsResponse{ out := CurrentConditionsResponse{
RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision), RelativeHumidityPercent: roundedPtr(copyFloat64Ptr(conditions.RelativeHumidityPercent), precision),
WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision), WindDirectionDegrees: roundedPtr(copyFloat64Ptr(conditions.WindDirectionDegrees), precision),
ConditionCode: conditions.ConditionCode,
ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay), ConditionText: standards.WMOText(conditions.ConditionCode, conditions.IsDay),
IsDay: copyBoolPtr(conditions.IsDay), IsDay: copyBoolPtr(conditions.IsDay),
IsDayText: boolText(conditions.IsDay), IsDayText: boolText(conditions.IsDay),

View File

@@ -378,6 +378,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
if metric.ConditionText != "Sunny" { if metric.ConditionText != "Sunny" {
t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText) t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText)
} }
if metric.ConditionCode != 0 {
t.Fatalf("expected condition code 0, got %d", metric.ConditionCode)
}
usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2) usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2)
us, ok := usPayload.(CurrentConditionsResponse) us, ok := usPayload.(CurrentConditionsResponse)
@@ -389,6 +392,9 @@ func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) {
if us.TemperatureC != nil || us.WindSpeedKmh != nil { if us.TemperatureC != nil || us.WindSpeedKmh != nil {
t.Fatalf("expected metric fields omitted for US payload") t.Fatalf("expected metric fields omitted for US payload")
} }
if us.ConditionCode != 0 {
t.Fatalf("expected condition code 0, got %d", us.ConditionCode)
}
} }
func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) { func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) {

View File

@@ -0,0 +1,107 @@
// conditions_codes.go selects current-conditions WMO codes from source candidates.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import "gitea.maximumdirect.net/ejr/weatherfeeder/model"
type currentConditionsConditionCodeCandidate struct {
EventSource string
ConditionCode model.WMOCode
}
type conditionCodeFamily int
const (
conditionCodeFamilyUnknown conditionCodeFamily = iota
conditionCodeFamilyClearOrCloud
conditionCodeFamilyFog
conditionCodeFamilyDrizzle
conditionCodeFamilyRain
conditionCodeFamilySnow
conditionCodeFamilyThunderstorm
)
var currentConditionsConditionCodeRankings = map[conditionCodeFamily][]model.WMOCode{
conditionCodeFamilyClearOrCloud: {0, 1, 2, 3},
conditionCodeFamilyFog: {45, 48},
conditionCodeFamilyDrizzle: {51, 53, 55, 56, 57},
conditionCodeFamilyRain: {61, 63, 65, 80, 81, 82, 66, 67},
conditionCodeFamilySnow: {71, 73, 75, 85, 86, 77},
conditionCodeFamilyThunderstorm: {95, 96, 99},
}
var currentConditionsConditionCodeFamilies = buildCurrentConditionsConditionCodeFamilies()
func buildCurrentConditionsConditionCodeFamilies() map[model.WMOCode]conditionCodeFamily {
families := make(map[model.WMOCode]conditionCodeFamily)
for family, ranking := range currentConditionsConditionCodeRankings {
for _, code := range ranking {
families[code] = family
}
}
return families
}
func selectCurrentConditionsConditionCode(candidates []currentConditionsConditionCodeCandidate) model.WMOCode {
seenSources := make(map[string]struct{})
familyCounts := make(map[conditionCodeFamily]int)
codeCounts := make(map[model.WMOCode]int)
for _, candidate := range candidates {
if _, seen := seenSources[candidate.EventSource]; seen {
continue
}
seenSources[candidate.EventSource] = struct{}{}
family, ok := currentConditionsConditionCodeFamilies[candidate.ConditionCode]
if !ok {
continue
}
familyCounts[family]++
codeCounts[candidate.ConditionCode]++
}
winningFamily, ok := currentConditionsWinningConditionCodeFamily(familyCounts)
if !ok {
return model.WMOUnknown
}
return currentConditionsWinningConditionCode(winningFamily, codeCounts)
}
func currentConditionsWinningConditionCodeFamily(counts map[conditionCodeFamily]int) (conditionCodeFamily, bool) {
winningFamily := conditionCodeFamilyUnknown
winningCount := 0
tied := false
for family, count := range counts {
if count > winningCount {
winningFamily = family
winningCount = count
tied = false
continue
}
if count == winningCount {
tied = true
}
}
if winningCount == 0 || tied {
return conditionCodeFamilyUnknown, false
}
return winningFamily, true
}
func currentConditionsWinningConditionCode(family conditionCodeFamily, counts map[model.WMOCode]int) model.WMOCode {
winningCode := model.WMOUnknown
winningCount := 0
for _, code := range currentConditionsConditionCodeRankings[family] {
if counts[code] > winningCount {
winningCode = code
winningCount = counts[code]
}
}
return winningCode
}

View File

@@ -0,0 +1,116 @@
// conditions_codes_test.go tests current-conditions WMO code selection.
// Layer: adapters/outbound/postgres conditions feature.
package postgres
import (
"testing"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
func TestSelectCurrentConditionsConditionCode(t *testing.T) {
tests := []struct {
name string
candidates []currentConditionsConditionCodeCandidate
want model.WMOCode
}{
{
name: "clear cloud ranking breaks exact tie",
candidates: conditionCodeCandidates(
0,
1,
2,
),
want: 0,
},
{
name: "clear cloud family wins over thunderstorm",
candidates: conditionCodeCandidates(
1,
2,
95,
),
want: 1,
},
{
name: "tied families return unknown",
candidates: conditionCodeCandidates(
0,
95,
),
want: model.WMOUnknown,
},
{
name: "rain ranking breaks exact tie",
candidates: conditionCodeCandidates(
61,
63,
80,
),
want: 61,
},
{
name: "three tied families return unknown",
candidates: conditionCodeCandidates(
61,
95,
0,
),
want: model.WMOUnknown,
},
{
name: "single thunderstorm code wins",
candidates: conditionCodeCandidates(
95,
),
want: 95,
},
{
name: "unrecognized only returns unknown",
candidates: conditionCodeCandidates(
4,
100,
),
want: model.WMOUnknown,
},
{
name: "duplicate source only votes once",
candidates: []currentConditionsConditionCodeCandidate{
{EventSource: "source-1", ConditionCode: 95},
{EventSource: "source-1", ConditionCode: 95},
{EventSource: "source-2", ConditionCode: 0},
{EventSource: "source-3", ConditionCode: 0},
},
want: 0,
},
{
name: "exact code frequency wins before ranking",
candidates: []currentConditionsConditionCodeCandidate{
{EventSource: "source-1", ConditionCode: 61},
{EventSource: "source-2", ConditionCode: 63},
{EventSource: "source-3", ConditionCode: 63},
},
want: 63,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selectCurrentConditionsConditionCode(tt.candidates)
if got != tt.want {
t.Fatalf("expected condition code %d, got %d", tt.want, got)
}
})
}
}
func conditionCodeCandidates(codes ...model.WMOCode) []currentConditionsConditionCodeCandidate {
candidates := make([]currentConditionsConditionCodeCandidate, 0, len(codes))
for i, code := range codes {
candidates = append(candidates, currentConditionsConditionCodeCandidate{
EventSource: string(rune('a' + i)),
ConditionCode: code,
})
}
return candidates
}

View File

@@ -7,16 +7,11 @@ import (
"gitea.maximumdirect.net/ejr/weatherfeeder/model" "gitea.maximumdirect.net/ejr/weatherfeeder/model"
) )
func mapCurrentConditionsRow(row currentConditionsRow) *app.CurrentConditions { func mapCurrentConditionsRow(row currentConditionsRow, conditionCode model.WMOCode) *app.CurrentConditions {
if row.SampleCount == 0 { if row.SampleCount == 0 {
return nil return nil
} }
conditionCode := model.WMOUnknown
if row.ConditionCode.Valid {
conditionCode = model.WMOCode(row.ConditionCode.Int64)
}
return &app.CurrentConditions{ return &app.CurrentConditions{
TemperatureC: float64Ptr(row.TemperatureC), TemperatureC: float64Ptr(row.TemperatureC),
ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC), ApparentTemperatureC: float64Ptr(row.ApparentTemperatureC),

View File

@@ -12,7 +12,6 @@ WITH windowed AS (
relative_humidity_percent, relative_humidity_percent,
wind_speed_kmh, wind_speed_kmh,
wind_direction_degrees, wind_direction_degrees,
condition_code,
is_day, is_day,
observed_at observed_at
FROM observations FROM observations
@@ -39,7 +38,6 @@ SELECT
AVG(cosd(wind_direction_degrees)) AVG(cosd(wind_direction_degrees))
) )
END AS wind_direction_degrees, END AS wind_direction_degrees,
MAX(condition_code) AS condition_code,
( (
SELECT is_day SELECT is_day
FROM windowed FROM windowed
@@ -47,4 +45,23 @@ SELECT
LIMIT 1 LIMIT 1
) AS is_day ) AS is_day
FROM windowed` FROM windowed`
queryCurrentConditionsConditionCodeCandidates = `
WITH ranked AS (
SELECT
event_source,
condition_code,
ROW_NUMBER() OVER (
PARTITION BY event_source
ORDER BY observed_at DESC, event_emitted_at DESC
) AS source_rank
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
)
SELECT
event_source,
condition_code
FROM ranked
WHERE source_rank = 1
ORDER BY event_source`
) )

View File

@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"gitea.maximumdirect.net/ejr/weatherapi/internal/app" "gitea.maximumdirect.net/ejr/weatherapi/internal/app"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
) )
func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) { func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMinutes int) (*app.CurrentConditions, error) {
@@ -25,7 +26,6 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
&row.RelativeHumidityPercent, &row.RelativeHumidityPercent,
&row.WindSpeedKmh, &row.WindSpeedKmh,
&row.WindDirectionDegrees, &row.WindDirectionDegrees,
&row.ConditionCode,
&row.IsDay, &row.IsDay,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -35,5 +35,42 @@ func (r *Repository) CurrentConditions(ctx context.Context, observationWindowMin
return nil, fmt.Errorf("query current conditions: %w", err) return nil, fmt.Errorf("query current conditions: %w", err)
} }
return mapCurrentConditionsRow(row), nil if row.SampleCount == 0 {
return nil, nil
}
candidates, err := r.currentConditionsConditionCodeCandidates(ctx, observationWindowMinutes)
if err != nil {
return nil, err
}
return mapCurrentConditionsRow(row, selectCurrentConditionsConditionCode(candidates)), nil
}
func (r *Repository) currentConditionsConditionCodeCandidates(ctx context.Context, observationWindowMinutes int) ([]currentConditionsConditionCodeCandidate, error) {
rows, err := r.db.QueryContext(ctx, queryCurrentConditionsConditionCodeCandidates, observationWindowMinutes)
if err != nil {
return nil, fmt.Errorf("query current conditions condition code candidates: %w", err)
}
defer rows.Close()
var candidates []currentConditionsConditionCodeCandidate
for rows.Next() {
var (
eventSource string
conditionCode int64
)
if err := rows.Scan(&eventSource, &conditionCode); err != nil {
return nil, fmt.Errorf("scan current conditions condition code candidate: %w", err)
}
candidates = append(candidates, currentConditionsConditionCodeCandidate{
EventSource: eventSource,
ConditionCode: model.WMOCode(conditionCode),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate current conditions condition code candidates: %w", err)
}
return candidates, nil
} }

View File

@@ -0,0 +1,280 @@
// conditions_read_test.go validates current-conditions repository read flow.
// Layer: adapters/outbound/postgres conditions read tests.
package postgres
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
)
const currentConditionsTestDriverName = "weatherapi_current_conditions_read_test"
func init() {
sql.Register(currentConditionsTestDriverName, currentConditionsTestDriver{})
}
func TestCurrentConditionsUsesConsensusConditionCode(t *testing.T) {
repo, closeDB := openCurrentConditionsTestRepository(t,
currentConditionsAggregateQuery(currentConditionsAggregateRow(3), nil),
currentConditionsConditionCodeCandidatesQuery([][]driver.Value{
{"source-a", int64(1)},
{"source-b", int64(2)},
{"source-c", int64(95)},
}, nil),
)
defer closeDB()
conditions, err := repo.CurrentConditions(context.Background(), 15)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conditions == nil {
t.Fatal("expected current conditions")
}
if conditions.ConditionCode != 1 {
t.Fatalf("expected consensus condition code 1, got %d", conditions.ConditionCode)
}
if conditions.TemperatureC == nil || *conditions.TemperatureC != 15.5 {
t.Fatalf("expected temperature 15.5, got %v", conditions.TemperatureC)
}
if conditions.WindDirectionDegrees == nil || *conditions.WindDirectionDegrees != 182.5 {
t.Fatalf("expected wind direction 182.5, got %v", conditions.WindDirectionDegrees)
}
if conditions.IsDay == nil || !*conditions.IsDay {
t.Fatalf("expected isDay true, got %v", conditions.IsDay)
}
assertCurrentConditionsTestQueriesConsumed(t)
}
func TestCurrentConditionsNoSamplesSkipsConditionCodeCandidates(t *testing.T) {
repo, closeDB := openCurrentConditionsTestRepository(t,
currentConditionsAggregateQuery([]driver.Value{int64(0), nil, nil, nil, nil, nil, nil, nil}, nil),
)
defer closeDB()
conditions, err := repo.CurrentConditions(context.Background(), 15)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conditions != nil {
t.Fatalf("expected nil current conditions, got %+v", conditions)
}
assertCurrentConditionsTestQueriesConsumed(t)
}
func TestCurrentConditionsConditionCodeCandidateQueryUsesLatestPerSourceOrdering(t *testing.T) {
query := compactSQL(queryCurrentConditionsConditionCodeCandidates)
want := "PARTITION BY event_source ORDER BY observed_at DESC, event_emitted_at DESC"
if !strings.Contains(query, want) {
t.Fatalf("expected condition code candidate query to contain %q, got %q", want, query)
}
if !strings.Contains(query, "SELECT event_source, condition_code") {
t.Fatalf("expected condition code candidate query to select event_source and condition_code, got %q", query)
}
}
func TestCurrentConditionsAggregateQueryDoesNotSelectConditionCode(t *testing.T) {
query := compactSQL(queryCurrentConditions)
if strings.Contains(query, "condition_code") {
t.Fatalf("expected aggregate query not to select condition_code, got %q", query)
}
}
func TestCurrentConditionsConditionCodeCandidateQueryWrapsErrors(t *testing.T) {
repo, closeDB := openCurrentConditionsTestRepository(t,
currentConditionsAggregateQuery(currentConditionsAggregateRow(1), nil),
scriptedCurrentConditionsQuery{
name: "condition code candidates",
query: queryCurrentConditionsConditionCodeCandidates,
args: []driver.Value{int64(15)},
err: errors.New("candidate query unavailable"),
},
)
defer closeDB()
_, err := repo.CurrentConditions(context.Background(), 15)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "query current conditions condition code candidates") {
t.Fatalf("expected candidate query context, got %v", err)
}
}
func openCurrentConditionsTestRepository(t *testing.T, queries ...scriptedCurrentConditionsQuery) (*Repository, func()) {
t.Helper()
currentConditionsTestScript.set(queries)
db, err := sql.Open(currentConditionsTestDriverName, "")
if err != nil {
t.Fatalf("open test db: %v", err)
}
db.SetMaxOpenConns(1)
return NewRepository(db), func() {
_ = db.Close()
currentConditionsTestScript.set(nil)
}
}
func assertCurrentConditionsTestQueriesConsumed(t *testing.T) {
t.Helper()
if remaining := currentConditionsTestScript.remaining(); remaining != 0 {
t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining)
}
}
func currentConditionsAggregateQuery(row []driver.Value, nextErr error) scriptedCurrentConditionsQuery {
return scriptedCurrentConditionsQuery{
name: "aggregate",
query: queryCurrentConditions,
args: []driver.Value{int64(15)},
columns: []string{"sample_count", "temperature_c", "apparent_temperature_c", "dewpoint_c", "relative_humidity_percent", "wind_speed_kmh", "wind_direction_degrees", "is_day"},
rows: [][]driver.Value{row},
nextErr: nextErr,
}
}
func currentConditionsConditionCodeCandidatesQuery(rows [][]driver.Value, nextErr error) scriptedCurrentConditionsQuery {
return scriptedCurrentConditionsQuery{
name: "condition code candidates",
query: queryCurrentConditionsConditionCodeCandidates,
args: []driver.Value{int64(15)},
columns: []string{"event_source", "condition_code"},
rows: rows,
nextErr: nextErr,
}
}
func currentConditionsAggregateRow(sampleCount int64) []driver.Value {
return []driver.Value{
sampleCount,
float64(15.5),
float64(14.2),
float64(10.1),
float64(72),
float64(24.8),
float64(182.5),
true,
}
}
type currentConditionsTestDriver struct{}
func (currentConditionsTestDriver) Open(string) (driver.Conn, error) {
return currentConditionsTestConn{}, nil
}
type currentConditionsTestConn struct{}
func (currentConditionsTestConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (currentConditionsTestConn) Close() error {
return nil
}
func (currentConditionsTestConn) Begin() (driver.Tx, error) {
return nil, errors.New("transactions are not supported")
}
func (currentConditionsTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
return currentConditionsTestScript.next(query, args)
}
type scriptedCurrentConditionsQuery struct {
name string
query string
args []driver.Value
columns []string
rows [][]driver.Value
err error
nextErr error
}
type currentConditionsTestScriptState struct {
mu sync.Mutex
queries []scriptedCurrentConditionsQuery
}
var currentConditionsTestScript currentConditionsTestScriptState
func (s *currentConditionsTestScriptState) set(queries []scriptedCurrentConditionsQuery) {
s.mu.Lock()
defer s.mu.Unlock()
s.queries = append([]scriptedCurrentConditionsQuery(nil), queries...)
}
func (s *currentConditionsTestScriptState) remaining() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.queries)
}
func (s *currentConditionsTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.queries) == 0 {
return nil, fmt.Errorf("unexpected query: %s", compactSQL(query))
}
next := s.queries[0]
s.queries = s.queries[1:]
if compactSQL(query) != compactSQL(next.query) {
return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query))
}
if len(args) != len(next.args) {
return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args))
}
for i, arg := range args {
if arg.Value != next.args[i] {
return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value)
}
}
if next.err != nil {
return nil, next.err
}
return &currentConditionsTestRows{
columns: append([]string(nil), next.columns...),
rows: append([][]driver.Value(nil), next.rows...),
nextErr: next.nextErr,
}, nil
}
type currentConditionsTestRows struct {
columns []string
rows [][]driver.Value
index int
nextErr error
}
func (r *currentConditionsTestRows) Columns() []string {
return r.columns
}
func (r *currentConditionsTestRows) Close() error {
return nil
}
func (r *currentConditionsTestRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
if r.nextErr != nil {
err := r.nextErr
r.nextErr = nil
return err
}
return io.EOF
}
copy(dest, r.rows[r.index])
r.index++
return nil
}

View File

@@ -12,6 +12,5 @@ type currentConditionsRow struct {
RelativeHumidityPercent sql.NullFloat64 RelativeHumidityPercent sql.NullFloat64
WindSpeedKmh sql.NullFloat64 WindSpeedKmh sql.NullFloat64
WindDirectionDegrees sql.NullFloat64 WindDirectionDegrees sql.NullFloat64
ConditionCode sql.NullInt64
IsDay sql.NullBool IsDay sql.NullBool
} }

View File

@@ -279,7 +279,7 @@ func TestMapAlertRowNullableEnds(t *testing.T) {
func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) { func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
got := mapCurrentConditionsRow(currentConditionsRow{ got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 0, SampleCount: 0,
}) }, model.WMOUnknown)
if got != nil { if got != nil {
t.Fatalf("expected nil for empty sample window, got %+v", got) t.Fatalf("expected nil for empty sample window, got %+v", got)
} }
@@ -295,9 +295,8 @@ func TestMapCurrentConditionsRowMapsFields(t *testing.T) {
RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true}, RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true},
WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true}, WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true},
WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true}, WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true},
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
IsDay: sql.NullBool{Bool: isDay, Valid: true}, IsDay: sql.NullBool{Bool: isDay, Valid: true},
}) }, 65)
if got == nil { if got == nil {
t.Fatalf("expected mapped current conditions") t.Fatalf("expected mapped current conditions")
} }

View File

@@ -10,6 +10,8 @@ import (
"gitea.maximumdirect.net/ejr/weatherfeeder/model" "gitea.maximumdirect.net/ejr/weatherfeeder/model"
) )
const nwsAlertURLPrefix = "https://api.weather.gov/alerts/"
// Repository defines outbound data access used by weatherapi use cases. // Repository defines outbound data access used by weatherapi use cases.
type Repository interface { type Repository interface {
LatestObservation(ctx context.Context) (*model.WeatherObservation, error) LatestObservation(ctx context.Context) (*model.WeatherObservation, error)
@@ -77,9 +79,10 @@ func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time)
} }
out := cloneAlertRun(run) out := cloneAlertRun(run)
supersededIDs := collectSupersededAlertIDs(out.Alerts)
alerts := out.Alerts[:0] alerts := out.Alerts[:0]
for _, alert := range out.Alerts { for _, alert := range out.Alerts {
if isActiveAlert(alert, activeAt) { if isActiveAlert(alert, activeAt) && !isSupersededAlert(alert, supersededIDs) {
alerts = append(alerts, alert) alerts = append(alerts, alert)
} }
} }
@@ -181,6 +184,41 @@ func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool {
return true return true
} }
func collectSupersededAlertIDs(alerts []model.WeatherAlert) map[string]struct{} {
supersededIDs := make(map[string]struct{})
for _, alert := range alerts {
for _, ref := range alert.References {
id := normalizeAlertID(referenceAlertID(ref))
if id != "" {
supersededIDs[id] = struct{}{}
}
}
}
return supersededIDs
}
func isSupersededAlert(alert model.WeatherAlert, supersededIDs map[string]struct{}) bool {
id := normalizeAlertID(alert.ID)
if id == "" {
return false
}
_, ok := supersededIDs[id]
return ok
}
func referenceAlertID(ref model.AlertReference) string {
if strings.TrimSpace(ref.Identifier) != "" {
return ref.Identifier
}
return ref.ID
}
func normalizeAlertID(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, nwsAlertURLPrefix)
return value
}
func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun { func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun {
out := *run out := *run
out.Latitude = copyFloat64(run.Latitude) out.Latitude = copyFloat64(run.Latitude)

View File

@@ -272,6 +272,60 @@ func TestServiceLatestActiveAlertRunUsesEndsBeforeExpires(t *testing.T) {
assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"}) assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"})
} }
func TestServiceLatestActiveAlertRunSuppressesReferencedOriginal(t *testing.T) {
activeAt := testTime(12)
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
update := testAlert("https://api.weather.gov/alerts/urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
update.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
unrelated := testAlert("https://api.weather.gov/alerts/urn:oid:unrelated", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, update, unrelated})}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertAlertIDs(t, run, []string{
"https://api.weather.gov/alerts/urn:oid:update",
"https://api.weather.gov/alerts/urn:oid:unrelated",
})
}
func TestServiceLatestActiveAlertRunCancelSuppressesReferencedOriginal(t *testing.T) {
activeAt := testTime(12)
original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
cancel := testAlert("https://api.weather.gov/alerts/urn:oid:cancel", "Cancel", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
cancel.References = []model.AlertReference{{Identifier: "urn:oid:original"}}
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, cancel})}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertAlertIDs(t, run, []string{})
}
func TestServiceLatestActiveAlertRunReferenceIdentifierPrecedenceAndIDFallback(t *testing.T) {
activeAt := testTime(12)
fromID := testAlert("urn:oid:from-id", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
fromIdentifier := testAlert("urn:oid:from-identifier", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
idFallback := testAlert("urn:oid:id-fallback", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13))
update := testAlert("urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13))
update.References = []model.AlertReference{
{ID: "urn:oid:from-id", Identifier: "urn:oid:from-identifier"},
{ID: "urn:oid:id-fallback"},
}
repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{fromID, fromIdentifier, idFallback, update})}
svc := NewService(repo)
run, err := svc.LatestActiveAlertRun(context.Background(), activeAt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertAlertIDs(t, run, []string{"urn:oid:from-id", "urn:oid:update"})
}
func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) { func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) {
repo := &fakeRepository{outlookRun: testOutlookRun()} repo := &fakeRepository{outlookRun: testOutlookRun()}
svc := NewService(repo) svc := NewService(repo)

View File

@@ -1,5 +1,6 @@
{{- if .Data -}} {{- if .Data -}}
Current Conditions Current Conditions
Condition Code: {{.Data.ConditionCode}}
{{- if .Data.ConditionText}} {{- if .Data.ConditionText}}
Condition: {{.Data.ConditionText}} Condition: {{.Data.ConditionText}}
{{- end}} {{- end}}