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

Commit
d78df04bdd94658979c9beba78a7cda8c5807179
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard.pm
index 6124a4c7..a71ee739 100644..100644
@@ -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
index 9eb60624..2ad71f84 100644..100644
@@ -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
index 736dfd98..35f66d64 100644..100644
@@ -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
index d5b346bf..435636c7 100644..100644
@@ -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
index 00000000..3e7e4678 000000..100644
@@ -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
index 00000000..16961ffd 000000..100644
@@ -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
index 059f04c7..b2eedf75 100644..100644
@@ -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>