insights: normalise rate-of-change to relative y-axis

Each probe's derivative is divided by its optimal range span (goodMax - goodMin) and expressed as a percentage per hour. This makes magnitudes comparable across probes with very different absolute scales (e.g. pH 0.8 range vs ORP 150 mV range).

Commit
b23442841f2cc3cbde0afeae4e6bfb9d0fd6b1fd
Author
GPT-5 medium <codex@openai.com>
Author date
Committer
GPT-5 medium <codex@openai.com>
Committer date
Changed files
roles/dashboard/public/js/dashboard/insights.js
index b2967c9f..7a5b2c45 100644..100644
@@ -32,6 +32,14 @@
32 32 caretSize: 0
33 33 };
34 34
35 Added: // Optimal ranges used to normalise derivatives to % per hour
36 Added: const OPTIMAL_SPANS = {
37 Added: ph: READING_RANGES.ph.goodMax - READING_RANGES.ph.goodMin,
38 Added: do: READING_RANGES.do.goodMax - READING_RANGES.do.goodMin,
39 Added: orp: READING_RANGES.orp.goodMax - READING_RANGES.orp.goodMin,
40 Added: ec: READING_RANGES.ec.goodMax - READING_RANGES.ec.goodMin,
41 Added: };
42 Added:
35 43 let charts = {};
36 44
37 45 async function fetchJson(url, signal) {
@@ -108,17 +116,23 @@
108 116 const refProbe = probeKeys.find(p => data.probes[p].length > 0);
109 117 const labels = refProbe ? data.probes[refProbe].map(p => formatDateTime(p.timestamp)) : [];
110 118
111 Removed: const datasets = probeKeys.map(probe => ({
112 Removed: label: probe.toUpperCase(),
113 Removed: data: data.probes[probe].map(p => p.value),
114 Removed: borderColor: PROBE_COLORS[probe]?.border || "#64748b",
115 Removed: backgroundColor: PROBE_COLORS[probe]?.background || "rgba(100,116,139,0.1)",
116 Removed: tension: 0.3,
117 Removed: pointRadius: 0,
118 Removed: pointHoverRadius: 3,
119 Removed: fill: false
120 Removed: }));
119 Added: const datasets = probeKeys.map(probe => {
120 Added: const span = OPTIMAL_SPANS[probe] || 1;
121 Added: const points = data.probes[probe] || [];
121 122
123 Added: return {
124 Added: label: probe.toUpperCase(),
125 Added: // Normalise: (Δ per hour) / optimal_span * 100 = % of range per hour
126 Added: data: points.map(p => (p.value / span) * 100),
127 Added: borderColor: PROBE_COLORS[probe]?.border || "#64748b",
128 Added: backgroundColor: PROBE_COLORS[probe]?.background || "rgba(100,116,139,0.1)",
129 Added: tension: 0.3,
130 Added: pointRadius: 0,
131 Added: pointHoverRadius: 3,
132 Added: fill: false
133 Added: };
134 Added: });
135 Added:
122 136 if (!charts.derivatives) {
123 137 charts.derivatives = new Chart(canvas, {
124 138 type: "line",
@@ -130,13 +144,22 @@
130 144 scales: {
131 145 x: { display: true, ticks: { maxTicksLimit: 8, font: { size: 10 } } },
132 146 y: {
133 Removed: title: { display: true, text: "Δ per hour" },
134 Removed: grid: { color: ctx => ctx.tick.value === 0 ? "#94a3b8" : "rgba(0,0,0,0.05)" }
147 Added: title: { display: true, text: "% of optimal range / hour" },
148 Added: grid: { color: ctx => ctx.tick.value === 0 ? "#94a3b8" : "rgba(0,0,0,0.05)" },
149 Added: ticks: {
150 Added: callback: value => `${value > 0 ? "+" : ""}${value.toFixed(1)}%`
151 Added: }
135 152 }
136 153 },
137 154 plugins: {
138 155 legend: { display: true },
139 Removed: tooltip: { ...TOOLTIP_DEFAULTS, callbacks: { title: () => "" } }
156 Added: tooltip: {
157 Added: ...TOOLTIP_DEFAULTS,
158 Added: callbacks: {
159 Added: title: () => "",
160 Added: label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y > 0 ? "+" : ""}${ctx.parsed.y.toFixed(2)}%/h`
161 Added: }
162 Added: }
140 163 }
141 164 }
142 165 });
roles/dashboard/templates/dashboard/insights.html.ep
index 8656d8b0..464112f7 100644..100644
@@ -53,12 +53,13 @@
53 53 <div class="card-header">
54 54 <h3 class="h6 mb-0">Rate of Change</h3>
55 55 <p class="text-body-secondary small mb-0 mt-1">
56 Removed: How fast each parameter is moving
56 Added: Normalised rate of change per probe (% of optimal range per hour).
57 Added: Values near zero mean the parameter is steady.
57 58 </p>
58 59 </div>
59 60 <div class="card-body">
60 61 <div class="chart-wrap">
61 Removed: <canvas id="chart-derivatives" role="img" aria-label="Rate of change per probe"></canvas>
62 Added: <canvas id="chart-derivatives" role="img" aria-label="Normalised rate of change per probe"></canvas>
62 63 </div>
63 64 </div>
64 65 </div>