[Perl] DAQ system for the FAPG.
feat add DO vs ORP correlation scatter chart
Add a scatter plot that correlates Dissolved Oxygen (x-axis) against ORP (y-axis) using paired rollup data from the same time buckets. Server: - New endpoint GET /api/correlation/:probe_x/:probe_y that joins rollups by bucket_epoch and returns {x, y} points - Supports timeframe and range params like the series endpoint Client: - New correlation.js module renders a Chart.js scatter chart - Added to the probes page alongside individual probe charts - Refreshes with the dashboard cycle and timeframe control changes
Changed files
- roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
- roles/dashboard/public/js/dashboard.js
- roles/dashboard/public/js/dashboard/api.js
- roles/dashboard/public/js/dashboard/correlation.js
- roles/dashboard/templates/dashboard/correlation-card.html.ep
- roles/dashboard/templates/dashboard/probes.html.ep
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
@@ -81,6 +81,7 @@
81
81
82
82
$r->get('/api/readings/:probe/series')->to('Reading#series');
83
83
$r->get('/api/readings/:probe')->to('Reading#list');
84
Added:
$r->get('/api/correlation/:probe_x/:probe_y')->to('Reading#correlation');
84
85
$r->get('/api/status')->to('Reading#status_list');
85
86
$r->get('/api/status/:probe')->to('Reading#status');
86
87
}
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Reading.pm
@@ -337,6 +337,79 @@
337
337
return $points;
338
338
}
339
339
340
Added:
sub correlation ($self) {
341
Added:
my $probe_x = $self->param('probe_x') // '';
342
Added:
my $probe_y = $self->param('probe_y') // '';
343
Added:
344
Added:
my %known = map { $_->{key} => 1 } $self->probes->@*;
345
Added:
346
Added:
return $self->render(
347
Added:
status => 404,
348
Added:
json => { error => "Unknown probe type: $probe_x", },
349
Added:
) unless $known{$probe_x};
350
Added:
351
Added:
return $self->render(
352
Added:
status => 404,
353
Added:
json => { error => "Unknown probe type: $probe_y", },
354
Added:
) unless $known{$probe_y};
355
Added:
356
Added:
my $timeframe = $self->param('timeframe') // 'day';
357
Added:
my $range = $self->param('range') // 1;
358
Added:
359
Added:
return $self->render(
360
Added:
status => 400,
361
Added:
json => { error => 'Range must be an integer from 1 to 24', },
362
Added:
) if $range !~ /\A\d+\z/ || $range < 1 || $range > $MAX_SERIES_RANGE;
363
Added:
364
Added:
my $window = series_window( $timeframe, $range );
365
Added:
366
Added:
return $self->render(
367
Added:
status => 400,
368
Added:
json => { error => "Unknown timeframe: $timeframe", },
369
Added:
) unless defined $window;
370
Added:
371
Added:
my $rollup_seconds
372
Added:
= $window->{bucket_stride} >= 3_600 ? 3_600 : $MINUTE_SECONDS;
373
Added:
374
Added:
my $rows = $self->sqlite->db->query(
375
Added:
q{
376
Added:
SELECT
377
Added:
a.bucket_epoch,
378
Added:
a.value_total / a.sample_count AS x,
379
Added:
b.value_total / b.sample_count AS y
380
Added:
FROM reading_rollups a
381
Added:
JOIN reading_rollups b
382
Added:
ON b.probe = ?
383
Added:
AND b.bucket_seconds = a.bucket_seconds
384
Added:
AND b.bucket_epoch = a.bucket_epoch
385
Added:
WHERE a.probe = ?
386
Added:
AND a.bucket_seconds = ?
387
Added:
AND a.bucket_epoch >= ?
388
Added:
AND a.bucket_epoch < ?
389
Added:
ORDER BY a.bucket_epoch
390
Added:
},
391
Added:
$probe_y,
392
Added:
$probe_x,
393
Added:
$rollup_seconds,
394
Added:
$window->{start_epoch},
395
Added:
$window->{end_epoch},
396
Added:
)->hashes->to_array;
397
Added:
398
Added:
my @points = map {
399
Added:
{ x => 0 + $_->{x}, y => 0 + $_->{y} }
400
Added:
} @$rows;
401
Added:
402
Added:
$self->render(
403
Added:
json => {
404
Added:
probe_x => $probe_x,
405
Added:
probe_y => $probe_y,
406
Added:
timeframe => $timeframe,
407
Added:
range => 0 + $range,
408
Added:
points => \@points,
409
Added:
},
410
Added:
);
411
Added:
}
412
Added:
340
413
sub utc_timestamp ($epoch) {
341
414
return strftime( '%Y-%m-%dT%H:%M:%SZ', gmtime $epoch );
342
415
}
roles/dashboard/public/js/dashboard.js
@@ -1,5 +1,6 @@
1
1
import { initialiseGraphControls } from "./dashboard/graph-controls.js";
2
2
import { createProbeCharts } from "./dashboard/charts.js";
3
Added:
import { createCorrelationChart } from "./dashboard/correlation.js";
3
4
import { DASHBOARD_REFRESH_MS } from "./dashboard/constants.js";
4
5
import { loadQuickReadings } from "./dashboard/quick-readings.js";
5
6
import { createStatusLoader } from "./dashboard/status.js";
@@ -7,13 +8,22 @@
7
8
8
9
const timeframe = createTimeframeState();
9
10
const probes = createProbeCharts(timeframe);
11
Added:
const correlation = createCorrelationChart(timeframe, "do", "orp", {
12
Added:
labelX: "Dissolved Oxygen",
13
Added:
labelY: "ORP",
14
Added:
unitX: "mg/L",
15
Added:
unitY: "mV"
16
Added:
});
10
17
const loadStatuses = createStatusLoader();
11
18
12
19
document.querySelectorAll(".tab-button").forEach(button => {
13
20
button.addEventListener("click", () => probes.activateTab(button.dataset.probe));
14
21
});
15
22
16
Removed:
initialiseGraphControls(timeframe, probes.reloadDisplayed);
23
Added:
initialiseGraphControls(timeframe, () => {
24
Added:
probes.reloadDisplayed();
25
Added:
correlation.reload();
26
Added:
});
17
27
18
28
window.addEventListener("hashchange", () => {
19
29
const probe = probes.probeFromLocation();
@@ -40,6 +50,7 @@
40
50
41
51
refreshInFlight = Promise.allSettled([
42
52
probes.loadDisplayed(),
53
Added:
correlation.reload(),
43
54
loadQuickReadings(),
44
55
loadStatuses()
45
56
]).finally(() => {
roles/dashboard/public/js/dashboard/api.js
@@ -41,3 +41,10 @@
41
41
readings: readings.readings || []
42
42
};
43
43
}
44
Added:
45
Added:
export async function fetchCorrelation(probeX, probeY, timeframe, range, signal = null) {
46
Added:
const params = new URLSearchParams({ timeframe, range: String(range) });
47
Added:
const options = signal ? { signal } : {};
48
Added:
const payload = await fetchJson(`/api/correlation/${probeX}/${probeY}?${params}`, options);
49
Added:
return payload.points || [];
50
Added:
}
roles/dashboard/public/js/dashboard/correlation.js
@@ -0,0 +1,77 @@
1
Added:
import { fetchCorrelation } from "./api.js";
2
Added:
import { PRIMARY_CHART_COLOR } from "./constants.js";
3
Added:
4
Added:
let chart = null;
5
Added:
let activeRequest = null;
6
Added:
7
Added:
function createScatterChart(canvas, points, labelX, labelY, unitX, unitY) {
8
Added:
return new Chart(canvas, {
9
Added:
type: "scatter",
10
Added:
data: {
11
Added:
datasets: [{
12
Added:
label: `${labelX} vs ${labelY}`,
13
Added:
data: points,
14
Added:
backgroundColor: PRIMARY_CHART_COLOR,
15
Added:
borderColor: PRIMARY_CHART_COLOR,
16
Added:
pointRadius: 3,
17
Added:
pointHoverRadius: 5
18
Added:
}]
19
Added:
},
20
Added:
options: {
21
Added:
responsive: true,
22
Added:
maintainAspectRatio: false,
23
Added:
animation: false,
24
Added:
scales: {
25
Added:
x: {
26
Added:
title: { display: true, text: `${labelX} (${unitX})` }
27
Added:
},
28
Added:
y: {
29
Added:
title: { display: true, text: `${labelY} (${unitY})` }
30
Added:
}
31
Added:
},
32
Added:
plugins: {
33
Added:
tooltip: {
34
Added:
callbacks: {
35
Added:
label: context => {
36
Added:
const point = context.raw;
37
Added:
return `${labelX}: ${point.x.toFixed(2)} ${unitX} ${labelY}: ${point.y.toFixed(2)} ${unitY}`;
38
Added:
}
39
Added:
}
40
Added:
}
41
Added:
}
42
Added:
}
43
Added:
});
44
Added:
}
45
Added:
46
Added:
export function createCorrelationChart(timeframe, probeX, probeY, config) {
47
Added:
const canvas = document.getElementById("chart-correlation");
48
Added:
if (!canvas) return { reload: () => {} };
49
Added:
50
Added:
async function load() {
51
Added:
if (activeRequest) activeRequest.abort();
52
Added:
53
Added:
const controller = new AbortController();
54
Added:
activeRequest = controller;
55
Added:
56
Added:
try {
57
Added:
const points = await fetchCorrelation(
58
Added:
probeX, probeY, timeframe.selected(), timeframe.range(), controller.signal
59
Added:
);
60
Added:
61
Added:
if (!chart) {
62
Added:
chart = createScatterChart(canvas, points, config.labelX, config.labelY, config.unitX, config.unitY);
63
Added:
} else {
64
Added:
chart.data.datasets[0].data = points;
65
Added:
chart.update();
66
Added:
}
67
Added:
} catch (error) {
68
Added:
if (error.name === "AbortError") return;
69
Added:
} finally {
70
Added:
if (activeRequest === controller) activeRequest = null;
71
Added:
}
72
Added:
}
73
Added:
74
Added:
load();
75
Added:
76
Added:
return { reload: load };
77
Added:
}
roles/dashboard/templates/dashboard/correlation-card.html.ep
@@ -0,0 +1,13 @@
1
Added:
<!-- -*- mode: web; -*- -->
2
Added:
3
Added:
<div class="card">
4
Added:
<div class="card-header">
5
Added:
<h2 class="h5 mb-0">Dissolved Oxygen vs ORP</h2>
6
Added:
<p class="text-muted small mb-0 mt-1">Correlation scatter plot</p>
7
Added:
</div>
8
Added:
<div class="card-body">
9
Added:
<div class="chart-wrap">
10
Added:
<canvas id="chart-correlation"></canvas>
11
Added:
</div>
12
Added:
</div>
13
Added:
</div>
roles/dashboard/templates/dashboard/probes.html.ep
@@ -10,4 +10,7 @@
10
10
%= include 'dashboard/probe-card', probe => $probe
11
11
</div>
12
12
% }
13
Added:
<div class="col-12 col-md-6 col-xl-3">
14
Added:
%= include 'dashboard/correlation-card'
15
Added:
</div>
13
16
</div>