Fix chart smoothing accuracy

Commit
230825a8cd1890e8433c90d355c9df8484539ff2
Author
Codex <codex@openai.com>
Author date
Committer
Codex <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
index 16fe4a78..3f74c1fa 100644..100644
@@ -97,12 +97,18 @@
97 97
98 98 my $timeframe = $self->param('timeframe') // 'day';
99 99 my $range = $self->param('range') // 1;
100 Added: my $smooth = $self->param('smooth') // 0;
100 101
101 102 return $self->render(
102 103 status => 400,
103 104 json => { error => 'Range must be an integer from 1 to 24', },
104 105 ) if $range !~ /\A\d+\z/ || $range < 1 || $range > $MAX_SERIES_RANGE;
105 106
107 Added: return $self->render(
108 Added: status => 400,
109 Added: json => { error => 'Smooth must be either 0 or 1', },
110 Added: ) if $smooth !~ /\A[01]\z/;
111 Added:
106 112 my $window = series_window( $timeframe, $range );
107 113
108 114 return $self->render(
@@ -114,23 +120,38 @@
114 120 my $points = series_points($window);
115 121 my %index = map { $points->[$_]{timestamp} => $_ } 0 .. $points->$#*;
116 122
123 Added: my $chart_value_sql = $smooth
124 Added: ? q{AVG(value) OVER (
125 Added: ORDER BY COALESCE(received_at, timestamp), id
126 Added: ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING
127 Added: )}
128 Added: : 'value';
129 Added:
117 130 my $rows = $self->sqlite->db->query(
118 131 q{
132 Added: WITH raw_series AS (
133 Added: SELECT
134 Added: id,
135 Added: COALESCE(received_at, timestamp) AS sample_at,
136 Added: }
137 Added: . $chart_value_sql . q{ AS chart_value
138 Added: FROM readings
139 Added: WHERE probe = ?
140 Added: AND COALESCE(received_at, timestamp) >= ?
141 Added: AND COALESCE(received_at, timestamp) < ?
142 Added: )
119 143 SELECT
120 Removed: strftime(?, COALESCE(received_at, timestamp)) AS bucket,
121 Removed: AVG(value) AS value,
144 Added: strftime(?, sample_at) AS bucket,
145 Added: AVG(chart_value) AS value,
122 146 COUNT(*) AS count
123 Removed: FROM readings
124 Removed: WHERE probe = ?
125 Removed: AND COALESCE(received_at, timestamp) >= ?
126 Removed: AND COALESCE(received_at, timestamp) < ?
147 Added: FROM raw_series
127 148 GROUP BY bucket
128 149 ORDER BY bucket
129 150 },
130 Removed: $window->{bucket_sql},
131 151 $probe,
132 152 $window->{start_iso},
133 153 $window->{end_iso},
154 Added: $window->{bucket_sql},
134 155 )->hashes->to_array;
135 156
136 157 for my $row ( $rows->@* ) {
@@ -151,6 +172,7 @@
151 172 probe => $probe,
152 173 timeframe => $timeframe,
153 174 range => 0 + $range,
175 Added: smooth => 0 + $smooth,
154 176 readings => $points,
155 177 },
156 178 );
roles/dashboard/public/js/dashboard.js
index 8e8a05f1..07a9d282 100644..100644
@@ -55,7 +55,6 @@
55 55 precision: 0
56 56 }
57 57 };
58 Removed: const SMOOTHING_WINDOW = 7;
59 58 const MAX_TIMEFRAME_RANGE = 24;
60 59 const PRIMARY_CHART_COLOR = "#276749";
61 60 let activeTimeframe = DEFAULT_TIMEFRAME;
@@ -232,34 +231,6 @@
232 231 };
233 232 }
234 233
235 Removed: function movingAverage(values, windowSize = SMOOTHING_WINDOW) {
236 Removed: if (values.length < 3 || windowSize < 3) {
237 Removed: return values;
238 Removed: }
239 Removed:
240 Removed: const radius = Math.floor(windowSize / 2);
241 Removed:
242 Removed: return values.map((value, index) => {
243 Removed: if (!Number.isFinite(value)) {
244 Removed: return value;
245 Removed: }
246 Removed:
247 Removed: const start = Math.max(0, index - radius);
248 Removed: const end = Math.min(values.length, index + radius + 1);
249 Removed: const neighbors = values.slice(start, end).filter(Number.isFinite);
250 Removed:
251 Removed: if (!neighbors.length) {
252 Removed: return value;
253 Removed: }
254 Removed:
255 Removed: return neighbors.reduce((sum, neighbor) => sum + neighbor, 0) / neighbors.length;
256 Removed: });
257 Removed: }
258 Removed:
259 Removed: function chartValues(values) {
260 Removed: return smoothingEnabled ? movingAverage(values) : values;
261 Removed: }
262 Removed:
263 234 function statusLabel(state) {
264 235 return {
265 236 online: "Online",
@@ -649,7 +620,8 @@
649 620 try {
650 621 const seriesParams = new URLSearchParams({
651 622 timeframe: selectedTimeframe(),
652 Removed: range: String(selectedTimeframeRange())
623 Added: range: String(selectedTimeframeRange()),
624 Added: smooth: smoothingEnabled ? "1" : "0"
653 625 });
654 626 const readingsParams = new URLSearchParams({
655 627 limit: "10"
@@ -673,8 +645,7 @@
673 645 const readings = readingsPayload.readings || [];
674 646
675 647 const labels = rows.map(labelForRow);
676 Removed: const rawValues = rows.map(row => Number(row.value));
677 Removed: const values = chartValues(rawValues);
648 Added: const values = rows.map(row => row.value === null ? null : Number(row.value));
678 649 const latest = [...readings].reverse().find(row => Number.isFinite(Number(row.value)));
679 650 const unit = rows.length ? rows[rows.length - 1].unit : "";
680 651 const yBounds = yAxisBounds(probe, values);
roles/dashboard/t/04-readings-series.t
index 6d60bb76..57775de6 100644..100644
@@ -12,6 +12,7 @@
12 12 my $t = test_app();
13 13
14 14 seed_series_readings($t);
15 Added: seed_smoothing_readings($t);
15 16
16 17 $t->get_ok('/api/readings/ec/series?timeframe=hour')
17 18 ->status_is(200)
@@ -40,9 +41,24 @@
40 41 ->json_is( '/readings/13/count' => 2 )
41 42 ->json_hasnt('/readings/14');
42 43
44 Added: $t->get_ok('/api/readings/orp/series?timeframe=hour&smooth=0')
45 Added: ->status_is(200)
46 Added: ->json_is( '/smooth' => 0 )
47 Added: ->json_is( '/readings/56/value' => 0 )
48 Added: ->json_is( '/readings/57/value' => 70 )
49 Added: ->json_is( '/readings/58/value' => 0 );
50 Added:
51 Added: $t->get_ok('/api/readings/orp/series?timeframe=hour&smooth=1')
52 Added: ->status_is(200)
53 Added: ->json_is( '/smooth' => 1 )
54 Added: ->json_is( '/readings/56/value' => 5 )
55 Added: ->json_is( '/readings/57/value' => 10 )
56 Added: ->json_is( '/readings/58/value' => 5 );
57 Added:
43 58 $t->get_ok('/api/readings/ec/series?timeframe=century')->status_is(400);
44 59 $t->get_ok('/api/readings/ec/series?timeframe=week&range=0')->status_is(400);
45 60 $t->get_ok('/api/readings/ec/series?timeframe=week&range=25')->status_is(400);
61 Added: $t->get_ok('/api/readings/ec/series?timeframe=week&smooth=yes')->status_is(400);
46 62
47 63 done_testing;
48 64
@@ -78,6 +94,37 @@
78 94 );
79 95
80 96 return;
97 Added: }
98 Added:
99 Added: sub seed_smoothing_readings {
100 Added: my ($t) = @_;
101 Added: my $bucket = minute_floor(time);
102 Added: my $db = $t->app->sqlite->db;
103 Added:
104 Added: my @readings = (
105 Added: ( map { [ $bucket - 180 + $_, 0 ] } 10 .. 15 ),
106 Added: [ $bucket - 120 + 10, 70 ],
107 Added: ( map { [ $bucket - 60 + $_, 0 ] } 10 .. 15 ),
108 Added: );
109 Added:
110 Added: for my $reading (@readings) {
111 Added: my $timestamp = utc_timestamp( $reading->[0] );
112 Added:
113 Added: $db->query(
114 Added: q{
115 Added: INSERT INTO readings (received_at, timestamp, node, probe, value, unit)
116 Added: VALUES (?, ?, ?, ?, ?, ?)
117 Added: },
118 Added: $timestamp,
119 Added: $timestamp,
120 Added: 'fapg-daq-zero-orp-01',
121 Added: 'orp',
122 Added: $reading->[1],
123 Added: 'mV',
124 Added: );
125 Added: }
126 Added:
127 Added: return;
81 128 }
82 129
83 130 sub utc_timestamp {