show rainfall data

This commit is contained in:
2026-01-28 16:04:07 +11:00
parent d941a62365
commit 3d6687e3d7
3 changed files with 132 additions and 4 deletions

View File

@@ -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: {

View File

@@ -146,6 +146,15 @@
<canvas id="chart-power"></canvas>
</div>
</div>
<div class="chart-card" data-chart="chart-rain">
<div class="chart-header">
<div class="chart-title" id="chart-rain-title">Rain (24h rolling)</div>
<button class="chart-link" data-chart="chart-rain" title="Copy chart link">Share</button>
</div>
<div class="chart-canvas">
<canvas id="chart-rain"></canvas>
</div>
</div>
<div class="chart-card wide" data-chart="chart-precip">
<div class="chart-header">
<div class="chart-title">Precipitation (forecast)</div>

View File

@@ -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
}