diff --git a/cmd/ingestd/main.go b/cmd/ingestd/main.go index 535dcef..9a6535a 100644 --- a/cmd/ingestd/main.go +++ b/cmd/ingestd/main.go @@ -165,6 +165,8 @@ func main() { source = binding.Topic } + latest.UpdateBarometer(ts, p.PressureHPA) + if err := d.InsertBarometer(ctx, db.InsertBarometerParams{ TS: ts, Site: cfg.Site.Name, @@ -250,6 +252,7 @@ func runWundergroundUploader(ctx context.Context, latest *mqttingest.Latest, sta UVI: snap.P.UVI, RainLastHourMM: snap.RainLastHourMM, DailyRainMM: snap.DailyRainMM, + PressureHPA: snap.PressureHPA, DateUTC: "now", } diff --git a/cmd/ingestd/web/app.js b/cmd/ingestd/web/app.js index dfe4db1..400bcb8 100644 --- a/cmd/ingestd/web/app.js +++ b/cmd/ingestd/web/app.js @@ -14,6 +14,7 @@ const colors = { forecast: "#f4b942", gust: "#ff7d6b", humidity: "#7ee081", + pressure: "#8fb8de", uvi: "#f4d35e", light: "#b8f2e6", precip: "#4ea8de", @@ -483,6 +484,18 @@ function renderDashboard(data) { }; upsertChart("chart-rh", rhChart); + const pressureChart = { + type: "line", + data: { + datasets: [ + { label: "obs pressure hPa", data: series(obsFiltered, "pressure_hpa"), borderColor: colors.pressure }, + { label: "forecast msl hPa", data: series(forecast, "pressure_msl_hpa"), borderColor: colors.forecast }, + ], + }, + options: baseOptions(range), + }; + upsertChart("chart-pressure", pressureChart); + const lightOptions = baseOptions(range); lightOptions.scales.y.ticks.color = colors.uvi; lightOptions.scales.y.title = { display: true, text: "UVI", color: colors.uvi }; diff --git a/cmd/ingestd/web/index.html b/cmd/ingestd/web/index.html index 5bdbfdf..b095608 100644 --- a/cmd/ingestd/web/index.html +++ b/cmd/ingestd/web/index.html @@ -128,6 +128,15 @@ +
+
+
Pressure (obs vs forecast)
+ +
+
+ +
+
UV Index and Light
diff --git a/internal/db/series.go b/internal/db/series.go index 10d0aa1..59bdede 100644 --- a/internal/db/series.go +++ b/internal/db/series.go @@ -11,18 +11,19 @@ import ( ) type ObservationPoint struct { - TS time.Time `json:"ts"` - TempC *float64 `json:"temp_c,omitempty"` - RH *float64 `json:"rh,omitempty"` - WindMS *float64 `json:"wind_m_s,omitempty"` - WindGustMS *float64 `json:"wind_gust_m_s,omitempty"` - WindDirDeg *float64 `json:"wind_dir_deg,omitempty"` - UVI *float64 `json:"uvi,omitempty"` - LightLux *float64 `json:"light_lux,omitempty"` - BatteryMV *float64 `json:"battery_mv,omitempty"` - SupercapV *float64 `json:"supercap_v,omitempty"` - RainMM *float64 `json:"rain_mm,omitempty"` - RainStart *int64 `json:"rain_start,omitempty"` + TS time.Time `json:"ts"` + TempC *float64 `json:"temp_c,omitempty"` + RH *float64 `json:"rh,omitempty"` + PressureHPA *float64 `json:"pressure_hpa,omitempty"` + WindMS *float64 `json:"wind_m_s,omitempty"` + WindGustMS *float64 `json:"wind_gust_m_s,omitempty"` + WindDirDeg *float64 `json:"wind_dir_deg,omitempty"` + UVI *float64 `json:"uvi,omitempty"` + LightLux *float64 `json:"light_lux,omitempty"` + BatteryMV *float64 `json:"battery_mv,omitempty"` + SupercapV *float64 `json:"supercap_v,omitempty"` + RainMM *float64 `json:"rain_mm,omitempty"` + RainStart *int64 `json:"rain_start,omitempty"` } type ForecastPoint struct { @@ -59,26 +60,54 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start, } query := fmt.Sprintf(` + WITH ws AS ( + SELECT + time_bucket(INTERVAL '%s', ts) AS bucket, + avg(temperature_c) AS temp_c_avg, + avg(humidity) AS rh_avg, + avg(wind_avg_m_s) AS wind_avg_ms_avg, + max(wind_max_m_s) AS wind_gust_ms_max, + avg(wind_dir_deg) AS wind_dir_deg_avg, + max(uvi) AS uvi_max, + max(light_lux) AS light_lux_max, + avg(battery_mv) AS battery_mv_avg, + avg(supercap_v) AS supercap_v_avg, + avg(rain_mm) AS rain_mm_avg, + max(rain_start) AS rain_start_max + FROM observations_ws90 + WHERE site = $1 + AND ts >= $2 + AND ts <= $3 + GROUP BY bucket + ), + baro AS ( + SELECT + time_bucket(INTERVAL '%s', ts) AS bucket, + avg(pressure_hpa) AS pressure_hpa_avg + FROM observations_baro + WHERE site = $1 + AND ts >= $2 + AND ts <= $3 + GROUP BY bucket + ) SELECT - time_bucket(INTERVAL '%s', ts) AS bucket, - avg(temperature_c) AS temp_c_avg, - avg(humidity) AS rh_avg, - avg(wind_avg_m_s) AS wind_avg_ms_avg, - max(wind_max_m_s) AS wind_gust_ms_max, - avg(wind_dir_deg) AS wind_dir_deg_avg, - max(uvi) AS uvi_max, - max(light_lux) AS light_lux_max, - avg(battery_mv) AS battery_mv_avg, - avg(supercap_v) AS supercap_v_avg, - avg(rain_mm) AS rain_mm_avg, - max(rain_start) AS rain_start_max - FROM observations_ws90 - WHERE site = $1 - AND ts >= $2 - AND ts <= $3 - GROUP BY bucket + COALESCE(ws.bucket, baro.bucket) AS bucket, + ws.temp_c_avg, + ws.rh_avg, + baro.pressure_hpa_avg, + ws.wind_avg_ms_avg, + ws.wind_gust_ms_max, + ws.wind_dir_deg_avg, + ws.uvi_max, + ws.light_lux_max, + ws.battery_mv_avg, + ws.supercap_v_avg, + ws.rain_mm_avg, + ws.rain_start_max + FROM ws + FULL OUTER JOIN baro ON ws.bucket = baro.bucket ORDER BY bucket ASC - `, interval) + `, interval, interval) rows, err := d.Pool.Query(ctx, query, site, start, end) if err != nil { @@ -90,27 +119,28 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start, for rows.Next() { var ( ts time.Time - temp, rh, wind, gust sql.NullFloat64 + temp, rh, pressure, wind, gust sql.NullFloat64 dir, uvi, light, battery, supercap sql.NullFloat64 rainMM sql.NullFloat64 rainStart sql.NullInt64 ) - if err := rows.Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap, &rainMM, &rainStart); err != nil { + if err := rows.Scan(&ts, &temp, &rh, &pressure, &wind, &gust, &dir, &uvi, &light, &battery, &supercap, &rainMM, &rainStart); err != nil { return nil, err } points = append(points, ObservationPoint{ - TS: ts, - TempC: nullFloatPtr(temp), - RH: nullFloatPtr(rh), - WindMS: nullFloatPtr(wind), - WindGustMS: nullFloatPtr(gust), - WindDirDeg: nullFloatPtr(dir), - UVI: nullFloatPtr(uvi), - LightLux: nullFloatPtr(light), - BatteryMV: nullFloatPtr(battery), - SupercapV: nullFloatPtr(supercap), - RainMM: nullFloatPtr(rainMM), - RainStart: nullIntPtr(rainStart), + TS: ts, + TempC: nullFloatPtr(temp), + RH: nullFloatPtr(rh), + PressureHPA: nullFloatPtr(pressure), + WindMS: nullFloatPtr(wind), + WindGustMS: nullFloatPtr(gust), + WindDirDeg: nullFloatPtr(dir), + UVI: nullFloatPtr(uvi), + LightLux: nullFloatPtr(light), + BatteryMV: nullFloatPtr(battery), + SupercapV: nullFloatPtr(supercap), + RainMM: nullFloatPtr(rainMM), + RainStart: nullIntPtr(rainStart), }) } if rows.Err() != nil { @@ -126,6 +156,7 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo ts, temperature_c, humidity, + (SELECT pressure_hpa FROM observations_baro WHERE site = $1 ORDER BY ts DESC LIMIT 1) AS pressure_hpa, wind_avg_m_s, wind_max_m_s, wind_dir_deg, @@ -143,12 +174,12 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo var ( ts time.Time - temp, rh, wind, gust sql.NullFloat64 + temp, rh, pressure, wind, gust sql.NullFloat64 dir, uvi, light, battery, supercap sql.NullFloat64 rainMM sql.NullFloat64 rainStart sql.NullInt64 ) - err := d.Pool.QueryRow(ctx, query, site).Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap, &rainMM, &rainStart) + err := d.Pool.QueryRow(ctx, query, site).Scan(&ts, &temp, &rh, &pressure, &wind, &gust, &dir, &uvi, &light, &battery, &supercap, &rainMM, &rainStart) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -157,18 +188,19 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo } return &ObservationPoint{ - TS: ts, - TempC: nullFloatPtr(temp), - RH: nullFloatPtr(rh), - WindMS: nullFloatPtr(wind), - WindGustMS: nullFloatPtr(gust), - WindDirDeg: nullFloatPtr(dir), - UVI: nullFloatPtr(uvi), - LightLux: nullFloatPtr(light), - BatteryMV: nullFloatPtr(battery), - SupercapV: nullFloatPtr(supercap), - RainMM: nullFloatPtr(rainMM), - RainStart: nullIntPtr(rainStart), + TS: ts, + TempC: nullFloatPtr(temp), + RH: nullFloatPtr(rh), + PressureHPA: nullFloatPtr(pressure), + WindMS: nullFloatPtr(wind), + WindGustMS: nullFloatPtr(gust), + WindDirDeg: nullFloatPtr(dir), + UVI: nullFloatPtr(uvi), + LightLux: nullFloatPtr(light), + BatteryMV: nullFloatPtr(battery), + SupercapV: nullFloatPtr(supercap), + RainMM: nullFloatPtr(rainMM), + RainStart: nullIntPtr(rainStart), }, nil } diff --git a/internal/mqttingest/barometer.go b/internal/mqttingest/barometer.go index 71b4529..63eafbd 100644 --- a/internal/mqttingest/barometer.go +++ b/internal/mqttingest/barometer.go @@ -38,13 +38,13 @@ func pressureHPAFromPayload(raw map[string]any) (float64, bool) { ); ok { return v, true } - if v, ok := findFloat(raw, "pressure_pa"); ok { + if v, ok := findFloat(raw, "pressure_pa", "pa"); ok { return v / 100.0, true } if v, ok := findFloat(raw, "pressure_kpa"); ok { return v * 10.0, true } - if v, ok := findFloat(raw, "pressure_inhg", "barometer_inhg"); ok { + if v, ok := findFloat(raw, "pressure_inhg", "barometer_inhg", "altim"); ok { return v * 33.8638866667, true } return 0, false diff --git a/internal/mqttingest/latest.go b/internal/mqttingest/latest.go index 9b0922c..7109344 100644 --- a/internal/mqttingest/latest.go +++ b/internal/mqttingest/latest.go @@ -16,8 +16,10 @@ const ( type Latest struct { mu sync.RWMutex - lastTS time.Time - last *WS90Payload + lastTS time.Time + last *WS90Payload + baroTS time.Time + baroHPA *float64 // Rain tracking mode rainMode @@ -144,6 +146,7 @@ type Snapshot struct { RainLastHourMM float64 DailyRainMM float64 + PressureHPA *float64 } func (l *Latest) Snapshot() (Snapshot, bool) { @@ -162,10 +165,25 @@ func (l *Latest) Snapshot() (Snapshot, bool) { daySum += rp.mm } + var pressure *float64 + if l.baroHPA != nil { + v := *l.baroHPA + pressure = &v + } + return Snapshot{ TS: l.lastTS, P: *l.last, RainLastHourMM: hourSum, DailyRainMM: daySum, + PressureHPA: pressure, }, true } + +func (l *Latest) UpdateBarometer(ts time.Time, pressureHPA float64) { + l.mu.Lock() + defer l.mu.Unlock() + + l.baroTS = ts + l.baroHPA = &pressureHPA +} diff --git a/internal/providers/wunderground.go b/internal/providers/wunderground.go index 61e2946..876807f 100644 --- a/internal/providers/wunderground.go +++ b/internal/providers/wunderground.go @@ -33,6 +33,7 @@ type WUUpload struct { RainLastHourMM float64 DailyRainMM float64 + PressureHPA *float64 DateUTC string // "now" recommended } @@ -63,6 +64,11 @@ func (c *WundergroundClient) Upload(ctx context.Context, u WUUpload) (string, er // UV index q.Set("UV", fmt.Sprintf("%.2f", u.UVI)) + // Barometric pressure (inHg) + if u.PressureHPA != nil { + q.Set("baromin", fmt.Sprintf("%.4f", hPaToInHg(*u.PressureHPA))) + } + // NOTE: your WS90 payload provides light in lux, not W/m^2. // WU expects solarradiation in W/m^2, so we omit it unless you add a conversion/actual sensor field. if u.SolarWm2 != nil { @@ -110,3 +116,6 @@ func (c *WundergroundClient) Upload(ctx context.Context, u WUUpload) (string, er func cToF(c float64) float64 { return (c * 9.0 / 5.0) + 32.0 } func msToMph(ms float64) float64 { return ms * 2.2369362920544 } func mmToIn(mm float64) float64 { return mm / 25.4 } +func hPaToInHg(hpa float64) float64 { + return hpa * 0.0295299830714 +}