Added a /conditions/current endpoint with computed best-guess values for current conditions
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-03-17 15:51:00 -05:00
parent e3aab86376
commit cb316c228a
19 changed files with 1146 additions and 358 deletions

View File

@@ -3,6 +3,7 @@ package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
@@ -18,84 +19,161 @@ func NewObservationRepository(pool *pgxpool.Pool) *ObservationRepository {
return &ObservationRepository{pool: pool}
}
func (r *ObservationRepository) GetCurrentSummary(ctx context.Context, window time.Duration) (ports.ObservationSummaryMetric, error) {
func (r *ObservationRepository) GetCurrentConditionsSummary(ctx context.Context, window time.Duration) (ports.ObservationCurrentConditionsMetric, error) {
var temperature sql.NullFloat64
var apparent sql.NullFloat64
var dewpoint sql.NullFloat64
var relativeHumidity sql.NullFloat64
var windSpeed sql.NullFloat64
var windDirection sql.NullFloat64
var conditionCode sql.NullInt64
var isDay sql.NullBool
if err := r.pool.QueryRow(ctx, queryObservationSummary, windowMinutes(window)).Scan(&temperature, &apparent); err != nil {
return ports.ObservationSummaryMetric{}, fmt.Errorf("query observation summary: %w", err)
if err := r.pool.QueryRow(ctx, queryCurrentConditionsSummary, windowMinutes(window)).Scan(
&temperature,
&apparent,
&dewpoint,
&relativeHumidity,
&windSpeed,
&windDirection,
&conditionCode,
&isDay,
); err != nil {
return ports.ObservationCurrentConditionsMetric{}, fmt.Errorf("query current conditions summary: %w", err)
}
return ports.ObservationSummaryMetric{
return ports.ObservationCurrentConditionsMetric{
TemperatureC: ptrFloat64(temperature),
ApparentTemperatureC: ptrFloat64(apparent),
DewpointC: ptrFloat64(dewpoint),
RelativeHumidity: ptrFloat64(relativeHumidity),
WindSpeedKmh: ptrFloat64(windSpeed),
WindDirectionDegrees: ptrFloat64(windDirection),
ConditionCode: ptrInt(conditionCode),
IsDay: ptrBool(isDay),
}, nil
}
func (r *ObservationRepository) ListCurrentConditions(ctx context.Context, window time.Duration) ([]ports.ObservationConditionMetric, error) {
rows, err := r.pool.Query(ctx, queryObservationConditions, windowMinutes(window))
func (r *ObservationRepository) ListRecentObservations(ctx context.Context, count int) ([]ports.ObservationRecordMetric, error) {
rows, err := r.pool.Query(ctx, queryRecentObservations, count)
if err != nil {
return nil, fmt.Errorf("query observation conditions: %w", err)
return nil, fmt.Errorf("query recent observations: %w", err)
}
defer rows.Close()
out := make([]ports.ObservationConditionMetric, 0)
out := make([]ports.ObservationRecordMetric, 0, count)
eventIDs := make([]string, 0, count)
for rows.Next() {
var eventID string
var stationID sql.NullString
var observedAt time.Time
var temperature sql.NullFloat64
var stationName sql.NullString
var timestamp time.Time
var conditionCode int
var isDay sql.NullBool
var textDescription sql.NullString
var providerRawDescription sql.NullString
var conditionText sql.NullString
var temperature sql.NullFloat64
var dewpoint sql.NullFloat64
var windDirection sql.NullFloat64
var windSpeed sql.NullFloat64
var windGust sql.NullFloat64
var pressure sql.NullFloat64
var visibility sql.NullFloat64
var relativeHumidity sql.NullFloat64
var apparent sql.NullFloat64
if err := rows.Scan(
&eventID,
&stationID,
&observedAt,
&temperature,
&stationName,
&timestamp,
&conditionCode,
&isDay,
&textDescription,
&providerRawDescription,
&conditionText,
&temperature,
&dewpoint,
&windDirection,
&windSpeed,
&windGust,
&pressure,
&visibility,
&relativeHumidity,
&apparent,
); err != nil {
return nil, fmt.Errorf("scan observation condition row: %w", err)
return nil, fmt.Errorf("scan recent observation row: %w", err)
}
out = append(out, ports.ObservationConditionMetric{
StationID: ptrString(stationID),
ObservedAt: observedAt,
TemperatureC: ptrFloat64(temperature),
TextDescription: ptrString(textDescription),
ProviderRawDescription: ptrString(providerRawDescription),
ConditionText: ptrString(conditionText),
eventIDs = append(eventIDs, eventID)
out = append(out, ports.ObservationRecordMetric{
EventID: eventID,
StationID: ptrString(stationID),
StationName: ptrString(stationName),
Timestamp: timestamp,
ConditionCode: conditionCode,
IsDay: ptrBool(isDay),
TextDescription: ptrString(textDescription),
TemperatureC: ptrFloat64(temperature),
DewpointC: ptrFloat64(dewpoint),
WindDirectionDegrees: ptrFloat64(windDirection),
WindSpeedKmh: ptrFloat64(windSpeed),
WindGustKmh: ptrFloat64(windGust),
BarometricPressurePa: ptrFloat64(pressure),
VisibilityMeters: ptrFloat64(visibility),
RelativeHumidityPercent: ptrFloat64(relativeHumidity),
ApparentTemperatureC: ptrFloat64(apparent),
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation conditions rows: %w", err)
return nil, fmt.Errorf("iterate recent observations rows: %w", err)
}
if len(out) == 0 {
return out, nil
}
presentWeatherByEventID, err := r.listObservationPresentWeather(ctx, eventIDs)
if err != nil {
return nil, err
}
for i := range out {
out[i].PresentWeather = presentWeatherByEventID[out[i].EventID]
}
return out, nil
}
func (r *ObservationRepository) ListCurrentPrecipitationEvents(ctx context.Context, window time.Duration) ([]string, error) {
rows, err := r.pool.Query(ctx, queryObservationPrecipitation, windowMinutes(window))
func (r *ObservationRepository) listObservationPresentWeather(ctx context.Context, eventIDs []string) (map[string][]ports.ObservationPresentWeatherMetric, error) {
rows, err := r.pool.Query(ctx, queryObservationPresentWeather, eventIDs)
if err != nil {
return nil, fmt.Errorf("query observation precipitation events: %w", err)
return nil, fmt.Errorf("query observation present weather: %w", err)
}
defer rows.Close()
out := make([]string, 0)
out := make(map[string][]ports.ObservationPresentWeatherMetric, len(eventIDs))
for rows.Next() {
var eventID string
var weatherIndex int
var rawText sql.NullString
if err := rows.Scan(&rawText); err != nil {
return nil, fmt.Errorf("scan observation precipitation row: %w", err)
if err := rows.Scan(&eventID, &weatherIndex, &rawText); err != nil {
return nil, fmt.Errorf("scan observation present weather row: %w", err)
}
var raw map[string]any
if rawText.Valid {
out = append(out, rawText.String)
if err := json.Unmarshal([]byte(rawText.String), &raw); err != nil {
return nil, fmt.Errorf("decode observation present weather raw payload: %w", err)
}
}
out[eventID] = append(out[eventID], ports.ObservationPresentWeatherMetric{
Raw: raw,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate observation precipitation rows: %w", err)
return nil, fmt.Errorf("iterate observation present weather rows: %w", err)
}
return out, nil

View File

@@ -1,33 +1,80 @@
package postgres
const (
queryObservationSummary = `
queryCurrentConditionsSummary = `
WITH windowed AS (
SELECT
temperature_c,
apparent_temperature_c,
dewpoint_c,
relative_humidity_percent,
wind_speed_kmh,
wind_direction_degrees,
condition_code,
is_day,
observed_at
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
)
SELECT
AVG(temperature_c) AS temperature_c,
AVG(apparent_temperature_c) AS apparent_temperature_c
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1);
AVG(apparent_temperature_c) AS apparent_temperature_c,
AVG(dewpoint_c) AS dewpoint_c,
AVG(relative_humidity_percent) AS relative_humidity_percent,
AVG(wind_speed_kmh) AS wind_speed_kmh,
CASE
WHEN COUNT(wind_direction_degrees) FILTER (WHERE wind_direction_degrees IS NOT NULL) = 0 THEN NULL
ELSE MOD(
DEGREES(
ATAN2(
AVG(SIN(RADIANS(wind_direction_degrees))),
AVG(COS(RADIANS(wind_direction_degrees)))
)
) + 360.0,
360.0
)
END AS wind_direction_degrees,
MAX(condition_code) AS condition_code,
(
SELECT is_day
FROM windowed
ORDER BY observed_at DESC
LIMIT 1
) AS is_day
FROM windowed;
`
queryObservationConditions = `
queryRecentObservations = `
SELECT
event_id,
station_id,
station_name,
observed_at,
temperature_c,
condition_code,
is_day,
text_description,
provider_raw_description,
condition_text
temperature_c,
dewpoint_c,
wind_direction_degrees,
wind_speed_kmh,
wind_gust_kmh,
barometric_pressure_pa,
visibility_meters,
relative_humidity_percent,
apparent_temperature_c
FROM observations
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
ORDER BY observed_at DESC;
ORDER BY observed_at DESC
LIMIT $1;
`
queryObservationPrecipitation = `
queryObservationPresentWeather = `
SELECT
event_id,
weather_index,
raw_text
FROM observation_present_weather
WHERE observed_at > CURRENT_TIMESTAMP - make_interval(mins => $1)
ORDER BY observed_at DESC;
WHERE event_id = ANY($1)
ORDER BY event_id ASC, weather_index ASC;
`
queryForecastPeriodsAt = `

View File

@@ -8,18 +8,31 @@ import (
)
func TestQueriesUseParametersNotHardcodedValues(t *testing.T) {
if !strings.Contains(queryObservationSummary, "make_interval(mins => $1)") {
t.Fatalf("observation summary query must use $1 minutes parameter")
if !strings.Contains(queryCurrentConditionsSummary, "make_interval(mins => $1)") {
t.Fatalf("current conditions summary query must use $1 minutes parameter")
}
if strings.Contains(queryObservationSummary, "30 minutes") {
t.Fatalf("observation summary query should not hardcode 30 minutes")
if strings.Contains(queryCurrentConditionsSummary, "30 minutes") {
t.Fatalf("current conditions summary query should not hardcode 30 minutes")
}
if !strings.Contains(queryCurrentConditionsSummary, "ATAN2(") {
t.Fatalf("current conditions summary query should compute circular mean for wind direction")
}
if !strings.Contains(queryCurrentConditionsSummary, "MAX(condition_code)") {
t.Fatalf("current conditions summary query should compute max condition code")
}
if !strings.Contains(queryCurrentConditionsSummary, "ORDER BY observed_at DESC") || !strings.Contains(queryCurrentConditionsSummary, "LIMIT 1") {
t.Fatalf("current conditions summary query should select latest is_day")
}
if !strings.Contains(queryObservationConditions, "make_interval(mins => $1)") {
t.Fatalf("observation conditions query must use $1 minutes parameter")
if !strings.Contains(queryRecentObservations, "LIMIT $1") {
t.Fatalf("recent observations query must use parameterized limit")
}
if !strings.Contains(queryObservationPrecipitation, "make_interval(mins => $1)") {
t.Fatalf("observation precipitation query must use $1 minutes parameter")
if strings.Contains(queryRecentObservations, "LIMIT 5") {
t.Fatalf("recent observations query should not hardcode limit=5")
}
if !strings.Contains(queryObservationPresentWeather, "event_id = ANY($1)") {
t.Fatalf("observation present weather query must use parameterized event list")
}
if !strings.Contains(queryForecastPeriodsAt, "LIMIT $2") {

View File

@@ -29,6 +29,14 @@ func ptrBool(v sql.NullBool) *bool {
return &b
}
func ptrInt(v sql.NullInt64) *int {
if !v.Valid {
return nil
}
i := int(v.Int64)
return &i
}
func ptrTime(v sql.NullTime) *time.Time {
if !v.Valid {
return nil