Skip to content

Commit ce45341

Browse files
committed
Implement uniform downsampling for sensor history queries
1 parent 210c5f4 commit ce45341

3 files changed

Lines changed: 76 additions & 34 deletions

File tree

‎backend/src/lib.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ struct SensorHistoryQuery {
6666
sensor_id: Option<String>,
6767
/// Number of hours of history to return (default: 24, max: 720)
6868
hours: Option<u32>,
69+
/// Maximum number of data points to return per sensor via uniform sampling (optional)
70+
max_points: Option<u32>,
6971
}
7072

7173
#[utoipa::path(
@@ -82,7 +84,7 @@ async fn sensor_history(
8284
query: web::Query<SensorHistoryQuery>,
8385
) -> HttpResponse {
8486
let hours = query.hours.unwrap_or(24).min(720);
85-
match state.storage.query_readings(query.sensor_id.as_deref(), hours).await {
87+
match state.storage.query_readings(query.sensor_id.as_deref(), hours, query.max_points).await {
8688
Ok(readings) => HttpResponse::Ok().json(readings),
8789
Err(e) => {
8890
tracing::error!("Failed to query sensor history: {e}");

‎backend/src/storage.rs‎

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -80,32 +80,69 @@ impl Storage {
8080
&self,
8181
sensor_id: Option<&str>,
8282
hours: u32,
83+
max_points: Option<u32>,
8384
) -> rusqlite::Result<Vec<SensorReading>> {
8485
let conn = self.conn.lock().await;
8586
let cutoff = format!("-{hours} hours");
8687

87-
if let Some(sid) = sensor_id {
88-
let mut stmt = conn.prepare_cached(
89-
"SELECT sensor_id, sensor_name, temperature, room_type, recorded_at
90-
FROM sensor_readings
91-
WHERE sensor_id = ?1 AND recorded_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?2)
92-
ORDER BY recorded_at ASC",
93-
)?;
94-
let rows = stmt
95-
.query_map(rusqlite::params![sid, cutoff], row_to_reading)?
96-
.collect::<rusqlite::Result<Vec<_>>>()?;
97-
Ok(rows)
98-
} else {
99-
let mut stmt = conn.prepare_cached(
100-
"SELECT sensor_id, sensor_name, temperature, room_type, recorded_at
101-
FROM sensor_readings
102-
WHERE recorded_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?1)
103-
ORDER BY recorded_at ASC",
104-
)?;
105-
let rows = stmt
106-
.query_map(rusqlite::params![cutoff], row_to_reading)?
107-
.collect::<rusqlite::Result<Vec<_>>>()?;
108-
Ok(rows)
88+
match (sensor_id, max_points) {
89+
(Some(sid), Some(max)) => {
90+
let mut stmt = conn.prepare_cached(
91+
"SELECT sensor_id, sensor_name, temperature, room_type, recorded_at
92+
FROM (
93+
SELECT sensor_id, sensor_name, temperature, room_type, recorded_at,
94+
ROW_NUMBER() OVER (ORDER BY recorded_at) AS rn,
95+
COUNT(*) OVER () AS total
96+
FROM sensor_readings
97+
WHERE sensor_id = ?1
98+
AND recorded_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?2)
99+
)
100+
WHERE (rn - 1) % MAX(1, (total + ?3 - 1) / ?3) = 0
101+
ORDER BY recorded_at ASC",
102+
)?;
103+
let rows = stmt.query_map(rusqlite::params![sid, cutoff, max], row_to_reading)?
104+
.collect::<rusqlite::Result<Vec<_>>>();
105+
rows
106+
}
107+
(Some(sid), None) => {
108+
let mut stmt = conn.prepare_cached(
109+
"SELECT sensor_id, sensor_name, temperature, room_type, recorded_at
110+
FROM sensor_readings
111+
WHERE sensor_id = ?1 AND recorded_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?2)
112+
ORDER BY recorded_at ASC",
113+
)?;
114+
let rows = stmt.query_map(rusqlite::params![sid, cutoff], row_to_reading)?
115+
.collect::<rusqlite::Result<Vec<_>>>();
116+
rows
117+
}
118+
(None, Some(max)) => {
119+
let mut stmt = conn.prepare_cached(
120+
"SELECT sensor_id, sensor_name, temperature, room_type, recorded_at
121+
FROM (
122+
SELECT sensor_id, sensor_name, temperature, room_type, recorded_at,
123+
ROW_NUMBER() OVER (PARTITION BY sensor_id ORDER BY recorded_at) AS rn,
124+
COUNT(*) OVER (PARTITION BY sensor_id) AS total
125+
FROM sensor_readings
126+
WHERE recorded_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?1)
127+
)
128+
WHERE (rn - 1) % MAX(1, (total + ?2 - 1) / ?2) = 0
129+
ORDER BY recorded_at ASC",
130+
)?;
131+
let rows = stmt.query_map(rusqlite::params![cutoff, max], row_to_reading)?
132+
.collect::<rusqlite::Result<Vec<_>>>();
133+
rows
134+
}
135+
(None, None) => {
136+
let mut stmt = conn.prepare_cached(
137+
"SELECT sensor_id, sensor_name, temperature, room_type, recorded_at
138+
FROM sensor_readings
139+
WHERE recorded_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?1)
140+
ORDER BY recorded_at ASC",
141+
)?;
142+
let rows = stmt.query_map(rusqlite::params![cutoff], row_to_reading)?
143+
.collect::<rusqlite::Result<Vec<_>>>();
144+
rows
145+
}
109146
}
110147
}
111148

‎frontend/src/components/History.tsx‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ type SensorReading = {
4444
};
4545

4646
const RANGES = [
47-
{ label: "6h", hours: 6 },
48-
{ label: "24h", hours: 24 },
49-
{ label: "7pv", hours: 168 },
50-
{ label: "30pv", hours: 720 },
47+
{ label: "6h", hours: 6, maxPoints: undefined },
48+
{ label: "24h", hours: 24, maxPoints: 288 },
49+
{ label: "7pv", hours: 168, maxPoints: 336 },
50+
{ label: "30pv", hours: 720, maxPoints: 720 },
5151
] as const;
5252

5353
// Base hue ranges per room type to keep visual grouping
@@ -82,19 +82,22 @@ type HistoryProps = {
8282
const History: React.FC<HistoryProps> = ({ className }) => {
8383
const theme = useTheme();
8484
const [readings, setReadings] = useState<SensorReading[]>([]);
85-
const [hours, setHours] = useState(24);
85+
const [range, setRange] = useState<(typeof RANGES)[number]>(RANGES[1]);
8686
const [loading, setLoading] = useState(true);
8787

8888
useEffect(() => {
8989
setLoading(true);
90-
fetch(api(`/api/history/sensors?hours=${hours}`))
90+
const params = new URLSearchParams({ hours: String(range.hours) });
91+
if (range.maxPoints !== undefined)
92+
params.set("max_points", String(range.maxPoints));
93+
fetch(api(`/api/history/sensors?${params}`))
9194
.then((r) => r.json())
9295
.then((data: SensorReading[]) => {
9396
setReadings(data);
9497
setLoading(false);
9598
})
9699
.catch(() => setLoading(false));
97-
}, [hours]);
100+
}, [range]);
98101

99102
// Group readings by sensor
100103
const bySensor = new Map<
@@ -186,7 +189,7 @@ const History: React.FC<HistoryProps> = ({ className }) => {
186189
maxTicksLimit: 8,
187190
callback: (val) => {
188191
const d = new Date(val as number);
189-
return hours <= 24
192+
return range.hours <= 24
190193
? format(d, "HH.mm", { locale: fi })
191194
: format(d, "EEEEEE d.M.", { locale: fi });
192195
},
@@ -224,18 +227,18 @@ const History: React.FC<HistoryProps> = ({ className }) => {
224227
{RANGES.map((r) => (
225228
<button
226229
key={r.hours}
227-
onClick={() => setHours(r.hours)}
230+
onClick={() => setRange(r)}
228231
css={{
229232
cursor: "pointer",
230233
padding: "4px 12px",
231234
borderRadius: 4,
232235
border: `1px solid ${theme.colors.border}`,
233236
backgroundColor:
234-
hours === r.hours
237+
range.hours === r.hours
235238
? theme.colors.text.main
236239
: theme.colors.background.light,
237240
color:
238-
hours === r.hours
241+
range.hours === r.hours
239242
? theme.colors.background.main
240243
: theme.colors.text.main,
241244
...theme.typography.body2,

0 commit comments

Comments
 (0)