diff --git a/cmd/ingestd/web/app.js b/cmd/ingestd/web/app.js index 43971cb..82770fd 100644 --- a/cmd/ingestd/web/app.js +++ b/cmd/ingestd/web/app.js @@ -17,6 +17,8 @@ const colors = { uvi: "#f4d35e", light: "#b8f2e6", precip: "#4ea8de", + rain: "#4ea8de", + rainStart: "#f77f00", }; function formatNumber(value, digits) { @@ -74,6 +76,69 @@ function safeDate(value) { return Number.isNaN(dt.getTime()) ? null : dt; } +function hourBucketMs(ts, tz) { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return null; + if (tz === "utc") { + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours()); + } + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime(); +} + +function computeRainIncrements(points) { + const out = []; + let prev = null; + for (const p of points) { + const t = new Date(p.ts).getTime(); + if (Number.isNaN(t)) continue; + if (p.rain_mm === null || p.rain_mm === undefined) { + out.push({ x: t, y: null }); + continue; + } + let delta = null; + if (prev !== null) { + delta = p.rain_mm - prev; + if (delta < 0) delta = 0; + } + prev = p.rain_mm; + out.push({ x: t, y: delta }); + } + return out; +} + +function computeRollingSum(points, windowMs) { + const out = []; + let sum = 0; + let j = 0; + const values = points.map((p) => ({ + x: p.x, + y: p.y == null ? 0 : p.y, + })); + for (let i = 0; i < values.length; i++) { + const cur = values[i]; + sum += cur.y; + while (values[j].x < cur.x - windowMs) { + sum -= values[j].y; + j += 1; + } + out.push({ x: cur.x, y: sum }); + } + return out; +} + +function computeHourlySums(points, tz) { + const buckets = new Map(); + for (const p of points) { + if (p.y == null) continue; + const bucket = hourBucketMs(p.x, tz); + if (bucket === null) continue; + buckets.set(bucket, (buckets.get(bucket) || 0) + p.y); + } + return Array.from(buckets.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([x, y]) => ({ x, y })); +} + function startOfDay(date, tz) { if (tz === "utc") { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); @@ -476,6 +541,38 @@ function renderDashboard(data) { }; upsertChart("chart-power", powerChart); + const rainOptions = baseOptions(range); + rainOptions.scales.y1 = { + position: "right", + ticks: { color: "#a4c4c4" }, + grid: { drawOnChartArea: false }, + }; + + const rainIncs = computeRainIncrements(obsFiltered); + const rainRolling = computeRollingSum(rainIncs, 24 * 60 * 60 * 1000); + const rainHourly = computeHourlySums(rainIncs, state.tz); + const rainTitle = document.getElementById("chart-rain-title"); + const isHourly = state.range === "6h"; + if (rainTitle) { + rainTitle.textContent = isHourly ? "Rain (hourly sums)" : "Rain (24h rolling)"; + } + + const rainChart = { + type: "line", + data: { + datasets: [ + { + label: isHourly ? "rain hourly sum (mm)" : "rain rolling 24h (mm)", + data: isHourly ? rainHourly : rainRolling, + borderColor: colors.rain, + yAxisID: "y", + }, + ], + }, + options: rainOptions, + }; + upsertChart("chart-rain", rainChart); + const precipChart = { type: "bar", data: { diff --git a/cmd/ingestd/web/index.html b/cmd/ingestd/web/index.html index 319e0b0..0eecf39 100644 --- a/cmd/ingestd/web/index.html +++ b/cmd/ingestd/web/index.html @@ -146,6 +146,15 @@ +
+
+
Rain (24h rolling)
+ +
+
+ +
+
Precipitation (forecast)
diff --git a/internal/db/series.go b/internal/db/series.go index e62f21c..a4057ac 100644 --- a/internal/db/series.go +++ b/internal/db/series.go @@ -21,6 +21,8 @@ type ObservationPoint struct { 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 { @@ -66,7 +68,9 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start, 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(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 @@ -87,8 +91,10 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start, ts time.Time temp, rh, 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); err != nil { + if err := rows.Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap, &rainMM, &rainStart); err != nil { return nil, err } points = append(points, ObservationPoint{ @@ -102,6 +108,8 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start, LightLux: nullFloatPtr(light), BatteryMV: nullFloatPtr(battery), SupercapV: nullFloatPtr(supercap), + RainMM: nullFloatPtr(rainMM), + RainStart: nullIntPtr(rainStart), }) } if rows.Err() != nil { @@ -123,7 +131,9 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo uvi, light_lux, battery_mv, - supercap_v + supercap_v, + rain_mm, + rain_start FROM observations_ws90 WHERE site = $1 ORDER BY ts DESC @@ -134,8 +144,10 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo ts time.Time temp, rh, 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) + err := d.Pool.QueryRow(ctx, query, site).Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light, &battery, &supercap, &rainMM, &rainStart) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -154,6 +166,8 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo LightLux: nullFloatPtr(light), BatteryMV: nullFloatPtr(battery), SupercapV: nullFloatPtr(supercap), + RainMM: nullFloatPtr(rainMM), + RainStart: nullIntPtr(rainStart), }, nil } @@ -289,3 +303,11 @@ func nullFloatPtr(v sql.NullFloat64) *float64 { val := v.Float64 return &val } + +func nullIntPtr(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + val := v.Int64 + return &val +}