[Perl] DAQ system for the FAPG.
Fix chart smoothing accuracy
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
@@ -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
@@ -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
@@ -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 {