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", uvi: "#f4d35e",
light: "#b8f2e6", light: "#b8f2e6",
precip: "#4ea8de", precip: "#4ea8de",
rain: "#4ea8de",
rainStart: "#f77f00",
}; };
function formatNumber(value, digits) { function formatNumber(value, digits) {
@@ -74,6 +76,69 @@ function safeDate(value) {
return Number.isNaN(dt.getTime()) ? null : dt; 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) { function startOfDay(date, tz) {
if (tz === "utc") { if (tz === "utc") {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
@@ -476,6 +541,38 @@ function renderDashboard(data) {
}; };
upsertChart("chart-power", powerChart); 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 = { const precipChart = {
type: "bar", type: "bar",
data: { data: {

View File

@@ -146,6 +146,15 @@
<canvas id="chart-power"></canvas> <canvas id="chart-power"></canvas>
</div> </div>
</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-card wide" data-chart="chart-precip">
<div class="chart-header"> <div class="chart-header">
<div class="chart-title">Precipitation (forecast)</div> <div class="chart-title">Precipitation (forecast)</div>

View File

@@ -21,6 +21,8 @@ type ObservationPoint struct {
LightLux *float64 `json:"light_lux,omitempty"` LightLux *float64 `json:"light_lux,omitempty"`
BatteryMV *float64 `json:"battery_mv,omitempty"` BatteryMV *float64 `json:"battery_mv,omitempty"`
SupercapV *float64 `json:"supercap_v,omitempty"` SupercapV *float64 `json:"supercap_v,omitempty"`
RainMM *float64 `json:"rain_mm,omitempty"`
RainStart *int64 `json:"rain_start,omitempty"`
} }
type ForecastPoint struct { type ForecastPoint struct {
@@ -66,7 +68,9 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start,
max(uvi) AS uvi_max, max(uvi) AS uvi_max,
max(light_lux) AS light_lux_max, max(light_lux) AS light_lux_max,
avg(battery_mv) AS battery_mv_avg, 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 FROM observations_ws90
WHERE site = $1 WHERE site = $1
AND ts >= $2 AND ts >= $2
@@ -87,8 +91,10 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start,
ts time.Time ts time.Time
temp, rh, wind, gust sql.NullFloat64 temp, rh, wind, gust sql.NullFloat64
dir, uvi, light, battery, supercap 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 return nil, err
} }
points = append(points, ObservationPoint{ points = append(points, ObservationPoint{
@@ -102,6 +108,8 @@ func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, start,
LightLux: nullFloatPtr(light), LightLux: nullFloatPtr(light),
BatteryMV: nullFloatPtr(battery), BatteryMV: nullFloatPtr(battery),
SupercapV: nullFloatPtr(supercap), SupercapV: nullFloatPtr(supercap),
RainMM: nullFloatPtr(rainMM),
RainStart: nullIntPtr(rainStart),
}) })
} }
if rows.Err() != nil { if rows.Err() != nil {
@@ -123,7 +131,9 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo
uvi, uvi,
light_lux, light_lux,
battery_mv, battery_mv,
supercap_v supercap_v,
rain_mm,
rain_start
FROM observations_ws90 FROM observations_ws90
WHERE site = $1 WHERE site = $1
ORDER BY ts DESC ORDER BY ts DESC
@@ -134,8 +144,10 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo
ts time.Time ts time.Time
temp, rh, wind, gust sql.NullFloat64 temp, rh, wind, gust sql.NullFloat64
dir, uvi, light, battery, supercap 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@@ -154,6 +166,8 @@ func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPo
LightLux: nullFloatPtr(light), LightLux: nullFloatPtr(light),
BatteryMV: nullFloatPtr(battery), BatteryMV: nullFloatPtr(battery),
SupercapV: nullFloatPtr(supercap), SupercapV: nullFloatPtr(supercap),
RainMM: nullFloatPtr(rainMM),
RainStart: nullIntPtr(rainStart),
}, nil }, nil
} }
@@ -289,3 +303,11 @@ func nullFloatPtr(v sql.NullFloat64) *float64 {
val := v.Float64 val := v.Float64
return &val return &val
} }
func nullIntPtr(v sql.NullInt64) *int64 {
if !v.Valid {
return nil
}
val := v.Int64
return &val
}