[Perl] DAQ system for the FAPG.
feat add solar, cloud, soil, pressure and sun times
Fetch shortwave_radiation, cloud_cover, soil_temperature_6cm, surface_pressure hourly and sunrise/sunset daily from Open-Meteo. Auto-migrate existing weather tables. Add solar/cloud and soil/pressure charts to the weather page. Night shading now uses actual sunrise/sunset instead of fixed 21:00-06:00. Index weather summary includes sky condition for outfit decisions. Rate-limit manual refresh to 1 per 5 minutes (24 timer calls/day vs 10k limit).
Changed files
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Weather.pm
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Model/Weather.pm
- roles/dashboard/lib/FAPG/DAQ/Dashboard/Service/WeatherFetcher.pm
- roles/dashboard/public/js/dashboard/weather-forecast.js
- roles/dashboard/public/js/dashboard/weather-page.js
- roles/dashboard/t/09-weather.t
- roles/dashboard/templates/dashboard/weather.html.ep
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Weather.pm
@@ -42,25 +42,40 @@
42
42
unless ($latest) {
43
43
return $self->render(
44
44
json => {
45
Removed:
temperature => undef,
46
Removed:
humidity => undef,
47
Removed:
rain => undef,
48
Removed:
timestamp => undef
45
Added:
temperature => undef,
46
Added:
humidity => undef,
47
Added:
rain => undef,
48
Added:
solar_radiation => undef,
49
Added:
cloud_cover => undef,
50
Added:
soil_temperature => undef,
51
Added:
pressure => undef,
52
Added:
sunrise => undef,
53
Added:
sunset => undef,
54
Added:
timestamp => undef,
49
55
},
50
56
);
51
57
}
52
58
53
59
$self->render(
54
60
json => {
55
Removed:
temperature => $latest->{temperature},
56
Removed:
humidity => $latest->{humidity},
57
Removed:
rain => $latest->{rain},
58
Removed:
timestamp => utc_timestamp( $latest->{epoch} ),
61
Added:
temperature => $latest->{temperature},
62
Added:
humidity => $latest->{humidity},
63
Added:
rain => $latest->{rain},
64
Added:
solar_radiation => $latest->{solar_radiation},
65
Added:
cloud_cover => $latest->{cloud_cover},
66
Added:
soil_temperature => $latest->{soil_temperature},
67
Added:
pressure => $latest->{pressure},
68
Added:
sunrise => $latest->{sunrise},
69
Added:
sunset => $latest->{sunset},
70
Added:
timestamp => utc_timestamp( $latest->{epoch} ),
59
71
},
60
72
);
61
73
}
62
74
63
Removed:
# POST /api/weather/refresh (manual trigger)
75
Added:
my $REFRESH_COOLDOWN_SECONDS = 300;
76
Added:
my $last_refresh_epoch = 0;
77
Added:
78
Added:
# POST /api/weather/refresh (manual trigger, rate-limited)
64
79
sub refresh ($self) {
65
80
my $config = $self->app->config->{weather} // {};
66
81
@@ -73,6 +88,20 @@
73
88
);
74
89
}
75
90
91
Added:
my $now = time;
92
Added:
my $wait = $REFRESH_COOLDOWN_SECONDS - ( $now - $last_refresh_epoch );
93
Added:
if ( $wait > 0 ) {
94
Added:
return $self->render(
95
Added:
status => 429,
96
Added:
json => {
97
Added:
status => 'rate_limited',
98
Added:
message => "Please wait ${wait}s before refreshing again",
99
Added:
retry_after => $wait,
100
Added:
},
101
Added:
);
102
Added:
}
103
Added:
$last_refresh_epoch = $now;
104
Added:
76
105
my ( $rows, $stored );
77
106
my $success = eval {
78
107
$rows = $self->app->weather_fetcher->fetch;
@@ -104,10 +133,16 @@
104
133
105
134
sub _format_point ($row) {
106
135
return {
107
Removed:
timestamp => utc_timestamp( $row->{epoch} ),
108
Removed:
temperature => $row->{temperature},
109
Removed:
humidity => $row->{humidity},
110
Removed:
rain => $row->{rain},
136
Added:
timestamp => utc_timestamp( $row->{epoch} ),
137
Added:
temperature => $row->{temperature},
138
Added:
humidity => $row->{humidity},
139
Added:
rain => $row->{rain},
140
Added:
solar_radiation => $row->{solar_radiation},
141
Added:
cloud_cover => $row->{cloud_cover},
142
Added:
soil_temperature => $row->{soil_temperature},
143
Added:
pressure => $row->{pressure},
144
Added:
sunrise => $row->{sunrise},
145
Added:
sunset => $row->{sunset},
111
146
};
112
147
}
113
148
roles/dashboard/lib/FAPG/DAQ/Dashboard/Model/Weather.pm
@@ -9,17 +9,47 @@
9
9
$self->sqlite->db->query(
10
10
q{
11
11
CREATE TABLE IF NOT EXISTS weather_hourly (
12
Removed:
epoch INTEGER PRIMARY KEY,
13
Removed:
temperature REAL,
14
Removed:
humidity REAL,
15
Removed:
rain REAL,
16
Removed:
fetched_at TEXT NOT NULL
12
Added:
epoch INTEGER PRIMARY KEY,
13
Added:
temperature REAL,
14
Added:
humidity REAL,
15
Added:
rain REAL,
16
Added:
solar_radiation REAL,
17
Added:
cloud_cover REAL,
18
Added:
soil_temperature REAL,
19
Added:
pressure REAL,
20
Added:
sunrise TEXT,
21
Added:
sunset TEXT,
22
Added:
fetched_at TEXT NOT NULL
17
23
)
18
24
}
19
25
);
26
Added:
$self->_migrate_columns;
20
27
return $self;
21
28
}
22
29
30
Added:
sub _migrate_columns ($self) {
31
Added:
my $db = $self->sqlite->db;
32
Added:
my $info = $db->query('PRAGMA table_info(weather_hourly)')->hashes;
33
Added:
my %cols = map { $_->{name} => 1 } @$info;
34
Added:
35
Added:
my @new = (
36
Added:
[ solar_radiation => 'REAL' ],
37
Added:
[ cloud_cover => 'REAL' ],
38
Added:
[ soil_temperature => 'REAL' ],
39
Added:
[ pressure => 'REAL' ],
40
Added:
[ sunrise => 'TEXT' ],
41
Added:
[ sunset => 'TEXT' ],
42
Added:
);
43
Added:
44
Added:
for my $col (@new) {
45
Added:
next if $cols{ $col->[0] };
46
Added:
$db->query(
47
Added:
"ALTER TABLE weather_hourly ADD COLUMN $col->[0] $col->[1]");
48
Added:
}
49
Added:
50
Added:
return;
51
Added:
}
52
Added:
23
53
sub store_batch ( $self, $rows ) {
24
54
return unless $rows && @$rows;
25
55
@@ -30,12 +60,20 @@
30
60
for my $row (@$rows) {
31
61
$db->query(
32
62
q{INSERT OR REPLACE INTO weather_hourly
33
Removed:
(epoch, temperature, humidity, rain, fetched_at)
34
Removed:
VALUES (?, ?, ?, ?, ?)},
63
Added:
(epoch, temperature, humidity, rain,
64
Added:
solar_radiation, cloud_cover, soil_temperature, pressure,
65
Added:
sunrise, sunset, fetched_at)
66
Added:
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)},
35
67
$row->{epoch},
36
68
$row->{temperature},
37
69
$row->{humidity},
38
70
$row->{rain},
71
Added:
$row->{solar_radiation},
72
Added:
$row->{cloud_cover},
73
Added:
$row->{soil_temperature},
74
Added:
$row->{pressure},
75
Added:
$row->{sunrise},
76
Added:
$row->{sunset},
39
77
$fetched_at,
40
78
);
41
79
}
@@ -46,7 +84,9 @@
46
84
47
85
sub get_range ( $self, $from_epoch, $to_epoch ) {
48
86
return $self->sqlite->db->query(
49
Removed:
q{SELECT epoch, temperature, humidity, rain
87
Added:
q{SELECT epoch, temperature, humidity, rain,
88
Added:
solar_radiation, cloud_cover, soil_temperature, pressure,
89
Added:
sunrise, sunset
50
90
FROM weather_hourly
51
91
WHERE epoch >= ? AND epoch <= ?
52
92
ORDER BY epoch},
@@ -57,7 +97,9 @@
57
97
58
98
sub get_forecast ( $self, $from_epoch ) {
59
99
return $self->sqlite->db->query(
60
Removed:
q{SELECT epoch, temperature, humidity, rain
100
Added:
q{SELECT epoch, temperature, humidity, rain,
101
Added:
solar_radiation, cloud_cover, soil_temperature, pressure,
102
Added:
sunrise, sunset
61
103
FROM weather_hourly
62
104
WHERE epoch >= ?
63
105
ORDER BY epoch},
@@ -67,7 +109,9 @@
67
109
68
110
sub latest_at ( $self, $epoch ) {
69
111
return $self->sqlite->db->query(
70
Removed:
q{SELECT epoch, temperature, humidity, rain
112
Added:
q{SELECT epoch, temperature, humidity, rain,
113
Added:
solar_radiation, cloud_cover, soil_temperature, pressure,
114
Added:
sunrise, sunset
71
115
FROM weather_hourly
72
116
WHERE epoch <= ?
73
117
ORDER BY epoch DESC
@@ -78,7 +122,9 @@
78
122
79
123
sub latest ($self) {
80
124
return $self->sqlite->db->query(
81
Removed:
q{SELECT epoch, temperature, humidity, rain
125
Added:
q{SELECT epoch, temperature, humidity, rain,
126
Added:
solar_radiation, cloud_cover, soil_temperature, pressure,
127
Added:
sunrise, sunset
82
128
FROM weather_hourly
83
129
ORDER BY epoch DESC
84
130
LIMIT 1},
roles/dashboard/lib/FAPG/DAQ/Dashboard/Service/WeatherFetcher.pm
@@ -15,9 +15,14 @@
15
15
sub fetch ($self) {
16
16
my $url = Mojo::URL->new(BASE_URL);
17
17
$url->query(
18
Removed:
latitude => $self->latitude,
19
Removed:
longitude => $self->longitude,
20
Removed:
hourly => 'temperature_2m,relative_humidity_2m,rain',
18
Added:
latitude => $self->latitude,
19
Added:
longitude => $self->longitude,
20
Added:
hourly => join( ',',
21
Added:
'temperature_2m', 'relative_humidity_2m',
22
Added:
'rain', 'shortwave_radiation',
23
Added:
'cloud_cover', 'soil_temperature_6cm',
24
Added:
'surface_pressure', ),
25
Added:
daily => 'sunrise,sunset',
21
26
past_days => 7,
22
27
forecast_days => 2,
23
28
timezone => 'UTC',
@@ -39,23 +44,50 @@
39
44
sub _parse_response ( $self, $json ) {
40
45
return [] unless $json && $json->{hourly};
41
46
42
Removed:
my $hourly = $json->{hourly};
43
Removed:
my $times = $hourly->{time} || [];
44
Removed:
my $temps = $hourly->{temperature_2m} || [];
45
Removed:
my $humids = $hourly->{relative_humidity_2m} || [];
46
Removed:
my $rains = $hourly->{rain} || [];
47
Added:
my $hourly = $json->{hourly};
48
Added:
my $times = $hourly->{time} || [];
49
Added:
my $temps = $hourly->{temperature_2m} || [];
50
Added:
my $humids = $hourly->{relative_humidity_2m} || [];
51
Added:
my $rains = $hourly->{rain} || [];
52
Added:
my $radiation = $hourly->{shortwave_radiation} || [];
53
Added:
my $clouds = $hourly->{cloud_cover} || [];
54
Added:
my $soil_temp = $hourly->{soil_temperature_6cm} || [];
55
Added:
my $pressure = $hourly->{surface_pressure} || [];
47
56
57
Added:
# Build a date→sunrise/sunset lookup from daily data
58
Added:
my %sun;
59
Added:
if ( $json->{daily} ) {
60
Added:
my $dates = $json->{daily}{time} || [];
61
Added:
my $sunrises = $json->{daily}{sunrise} || [];
62
Added:
my $sunsets = $json->{daily}{sunset} || [];
63
Added:
for my $i ( 0 .. $#$dates ) {
64
Added:
$sun{ $dates->[$i] } = {
65
Added:
sunrise => $sunrises->[$i],
66
Added:
sunset => $sunsets->[$i],
67
Added:
};
68
Added:
}
69
Added:
}
70
Added:
48
71
my @rows;
49
72
for my $i ( 0 .. $#$times ) {
50
73
my $epoch = _iso_to_epoch( $times->[$i] );
51
74
next unless defined $epoch;
52
75
76
Added:
my $date = substr( $times->[$i], 0, 10 );
77
Added:
my $day = $sun{$date} // {};
78
Added:
53
79
push @rows,
54
80
{
55
Removed:
epoch => $epoch,
56
Removed:
temperature => $temps->[$i],
57
Removed:
humidity => $humids->[$i],
58
Removed:
rain => $rains->[$i],
81
Added:
epoch => $epoch,
82
Added:
temperature => $temps->[$i],
83
Added:
humidity => $humids->[$i],
84
Added:
rain => $rains->[$i],
85
Added:
solar_radiation => $radiation->[$i],
86
Added:
cloud_cover => $clouds->[$i],
87
Added:
soil_temperature => $soil_temp->[$i],
88
Added:
pressure => $pressure->[$i],
89
Added:
sunrise => $day->{sunrise},
90
Added:
sunset => $day->{sunset},
59
91
};
60
92
}
61
93
roles/dashboard/public/js/dashboard/weather-forecast.js
@@ -28,13 +28,28 @@
28
28
29
29
const temps = dayPoints.map(p => p.temperature).filter(t => t != null);
30
30
const rains = dayPoints.map(p => p.rain || 0);
31
Added:
const clouds = dayPoints.map(p => p.cloud_cover).filter(c => c != null);
31
32
const totalRain = rains.reduce((a, b) => a + b, 0);
32
33
const minTemp = Math.round(Math.min(...temps));
33
34
const maxTemp = Math.round(Math.max(...temps));
35
Added:
const avgCloud = clouds.length ? Math.round(clouds.reduce((a, b) => a + b, 0) / clouds.length) : null;
34
36
35
37
const label = now.getHours() >= 16 ? "Tomorrow" : "Today";
38
Added:
39
Added:
// Build a natural sentence with outfit-relevant info
36
40
let sentence = `${label} ${minTemp}–${maxTemp}°C`;
37
Removed:
if (totalRain > 0.5) {
41
Added:
42
Added:
// Sky condition
43
Added:
if (avgCloud !== null) {
44
Added:
if (avgCloud < 25) sentence += ", sunny";
45
Added:
else if (avgCloud < 60) sentence += ", partly cloudy";
46
Added:
else sentence += ", overcast";
47
Added:
}
48
Added:
49
Added:
// Rain
50
Added:
if (totalRain > 5) {
51
Added:
sentence += `, heavy rain (${totalRain.toFixed(0)} mm).`;
52
Added:
} else if (totalRain > 0.5) {
38
53
sentence += `, ${totalRain.toFixed(1)} mm rain.`;
39
54
} else {
40
55
sentence += ", dry.";
@@ -43,53 +58,69 @@
43
58
summaryEl.textContent = sentence;
44
59
}
45
60
46
Removed:
// Chart.js plugin to shade night periods (21:00–06:00)
61
Added:
// Chart.js plugin to shade night periods using sunrise/sunset data
47
62
const nightShadePlugin = {
48
63
id: "nightShade",
49
64
beforeDraw(chart) {
50
65
const { ctx, chartArea, scales } = chart;
51
66
const xScale = scales.x;
52
67
const timestamps = chart._weatherTimestamps;
68
Added:
const sunEvents = chart._weatherSunEvents;
53
69
if (!xScale || !timestamps || !timestamps.length) return;
54
70
55
Removed:
ctx.save();
56
Removed:
ctx.fillStyle = "rgba(100, 116, 139, 0.14)";
71
Added:
// Build night intervals from sunrise/sunset pairs
72
Added:
const nights = [];
73
Added:
if (sunEvents && sunEvents.length) {
74
Added:
// Each point carries the sunrise/sunset for its day
75
Added:
let nightStart = null;
76
Added:
for (let i = 0; i < timestamps.length; i++) {
77
Added:
const t = timestamps[i];
78
Added:
const sun = sunEvents[i];
79
Added:
if (!sun || !sun.sunrise || !sun.sunset) continue;
80
Added:
const sunrise = new Date(sun.sunrise).getTime();
81
Added:
const sunset = new Date(sun.sunset).getTime();
82
Added:
const isNight = t < sunrise || t >= sunset;
57
83
58
Removed:
let inNight = false;
59
Removed:
let nightStart = 0;
60
Removed:
61
Removed:
for (let i = 0; i <= timestamps.length; i++) {
62
Removed:
const hour = i < timestamps.length ? new Date(timestamps[i]).getHours() : -1;
63
Removed:
const isNight = hour >= 21 || (hour >= 0 && hour < 6);
64
Removed:
65
Removed:
if (isNight && !inNight) {
66
Removed:
nightStart = i;
67
Removed:
inNight = true;
68
Removed:
} else if (!isNight && inNight) {
69
Removed:
// Draw the night block
70
Removed:
const x1 = xScale.getPixelForValue(nightStart);
71
Removed:
const x2 = xScale.getPixelForValue(i);
72
Removed:
ctx.fillRect(
73
Removed:
Math.max(x1, chartArea.left),
74
Removed:
chartArea.top,
75
Removed:
Math.min(x2, chartArea.right) - Math.max(x1, chartArea.left),
76
Removed:
chartArea.bottom - chartArea.top
77
Removed:
);
78
Removed:
inNight = false;
84
Added:
if (isNight && nightStart === null) {
85
Added:
nightStart = i;
86
Added:
} else if (!isNight && nightStart !== null) {
87
Added:
nights.push([nightStart, i]);
88
Added:
nightStart = null;
89
Added:
}
79
90
}
91
Added:
if (nightStart !== null) {
92
Added:
nights.push([nightStart, timestamps.length]);
93
Added:
}
94
Added:
} else {
95
Added:
// Fallback to fixed 21:00–06:00 if no sun data
96
Added:
let nightStart = null;
97
Added:
for (let i = 0; i <= timestamps.length; i++) {
98
Added:
const hour = i < timestamps.length ? new Date(timestamps[i]).getHours() : -1;
99
Added:
const isNight = hour >= 21 || (hour >= 0 && hour < 6);
100
Added:
if (isNight && nightStart === null) {
101
Added:
nightStart = i;
102
Added:
} else if (!isNight && nightStart !== null) {
103
Added:
nights.push([nightStart, i]);
104
Added:
nightStart = null;
105
Added:
}
106
Added:
}
107
Added:
if (nightStart !== null) {
108
Added:
nights.push([nightStart, timestamps.length]);
109
Added:
}
80
110
}
81
111
82
Removed:
// If still in night at the end
83
Removed:
if (inNight) {
84
Removed:
const x1 = xScale.getPixelForValue(nightStart);
112
Added:
ctx.save();
113
Added:
ctx.fillStyle = "rgba(100, 116, 139, 0.14)";
114
Added:
for (const [start, end] of nights) {
115
Added:
const x1 = xScale.getPixelForValue(start);
116
Added:
const x2 = xScale.getPixelForValue(Math.min(end, timestamps.length - 1));
85
117
ctx.fillRect(
86
118
Math.max(x1, chartArea.left),
87
119
chartArea.top,
88
Removed:
chartArea.right - Math.max(x1, chartArea.left),
120
Added:
Math.min(x2, chartArea.right) - Math.max(x1, chartArea.left),
89
121
chartArea.bottom - chartArea.top
90
122
);
91
123
}
92
Removed:
93
124
ctx.restore();
94
125
}
95
126
};
@@ -247,13 +278,21 @@
247
278
},
248
279
plugins: [nightShadePlugin, nowLinePlugin]
249
280
});
250
Removed:
// Store timestamps for the now-line plugin
281
Added:
// Store timestamps and sun events for plugins
251
282
chart._weatherTimestamps = timestamps;
283
Added:
chart._weatherSunEvents = points.map(p => ({
284
Added:
sunrise: p.sunrise,
285
Added:
sunset: p.sunset
286
Added:
}));
252
287
} else {
253
288
chart.data.labels = labels;
254
289
chart.data.datasets[0].data = temps;
255
290
chart.data.datasets[1].data = rains;
256
291
chart._weatherTimestamps = timestamps;
292
Added:
chart._weatherSunEvents = points.map(p => ({
293
Added:
sunrise: p.sunrise,
294
Added:
sunset: p.sunset
295
Added:
}));
257
296
chart.update();
258
297
}
259
298
}
roles/dashboard/public/js/dashboard/weather-page.js
@@ -4,58 +4,25 @@
4
4
import { createTimeframeState, timeframeToHours } from "./timeframe.js";
5
5
import { initialiseGraphControls } from "./graph-controls.js";
6
6
7
Removed:
let chart = null;
7
Added:
let charts = {};
8
8
9
Removed:
10
Removed:
export function weatherScaleOptions(compact) {
11
Removed:
return {
12
Removed:
x: {
13
Removed:
ticks: {
14
Removed:
maxTicksLimit: compact ? 6 : 10,
15
Removed:
maxRotation: compact ? 0 : 45,
16
Removed:
minRotation: 0,
17
Removed:
font: { size: 10 }
18
Removed:
}
19
Removed:
},
20
Removed:
yTemp: {
21
Removed:
type: "linear",
22
Removed:
position: "left",
23
Removed:
title: { display: !compact, text: "°C", color: "#dc2626" },
24
Removed:
ticks: { color: "#dc2626", font: { size: 10 } },
25
Removed:
grid: { display: true }
26
Removed:
},
27
Removed:
yHumid: {
28
Removed:
type: "linear",
29
Removed:
position: "right",
30
Removed:
display: !compact,
31
Removed:
min: 0,
32
Removed:
max: 100,
33
Removed:
title: { display: true, text: "%", color: "#0ea5e9" },
34
Removed:
ticks: { color: "#0ea5e9", font: { size: 10 } },
35
Removed:
grid: { drawOnChartArea: false }
36
Removed:
},
37
Removed:
yRain: {
38
Removed:
type: "linear",
39
Removed:
position: "right",
40
Removed:
display: !compact,
41
Removed:
beginAtZero: true,
42
Removed:
title: { display: true, text: "mm", color: "#64748b" },
43
Removed:
ticks: { color: "#64748b", font: { size: 10 } },
44
Removed:
grid: { drawOnChartArea: false },
45
Removed:
afterFit(axis) { axis.paddingLeft = 10; }
46
Removed:
}
47
Removed:
};
48
Removed:
}
49
Removed:
async function loadCombined(timeframe) {
50
Removed:
const canvas = document.getElementById("chart-weather-combined");
51
Removed:
if (!canvas) return;
52
Removed:
9
Added:
async function loadAll(timeframe) {
53
10
const hours = timeframeToHours(timeframe);
54
11
const data = await fetchWeatherHourly(hours);
55
12
const labels = data.points.map(point =>
56
13
formatChartTime(point.timestamp, timeframe.selected())
57
14
);
58
15
const compact = window.matchMedia("(max-width: 575.98px)").matches;
16
Added:
17
Added:
loadCombined(data, labels, compact);
18
Added:
loadSolar(data, labels, compact);
19
Added:
loadGround(data, labels, compact);
20
Added:
}
21
Added:
22
Added:
function loadCombined(data, labels, compact) {
23
Added:
const canvas = document.getElementById("chart-weather-combined");
24
Added:
if (!canvas) return;
25
Added:
59
26
const temps = data.points.map(p => p.temperature);
60
27
const humids = data.points.map(p => p.humidity);
61
28
const rains = data.points.map(p => p.rain || 0);
@@ -99,8 +66,8 @@
99
66
}
100
67
];
101
68
102
Removed:
if (!chart) {
103
Removed:
chart = new Chart(canvas, {
69
Added:
if (!charts.combined) {
70
Added:
charts.combined = new Chart(canvas, {
104
71
type: "bar",
105
72
data: { labels, datasets },
106
73
options: {
@@ -110,37 +77,198 @@
110
77
interaction: { mode: "index", intersect: false },
111
78
scales: weatherScaleOptions(compact),
112
79
plugins: {
113
Removed:
legend: {
114
Removed:
display: true,
115
Removed:
labels: { boxWidth: compact ? 24 : 40, font: { size: 10 } }
80
Added:
legend: { display: true, labels: { boxWidth: compact ? 24 : 40, font: { size: 10 } } },
81
Added:
tooltip: tooltipOptions({ title: () => "" })
82
Added:
}
83
Added:
}
84
Added:
});
85
Added:
} else {
86
Added:
charts.combined.data.labels = labels;
87
Added:
charts.combined.data.datasets[0].data = temps;
88
Added:
charts.combined.data.datasets[1].data = humids;
89
Added:
charts.combined.data.datasets[2].data = rains;
90
Added:
charts.combined.update();
91
Added:
}
92
Added:
}
93
Added:
94
Added:
function loadSolar(data, labels, compact) {
95
Added:
const canvas = document.getElementById("chart-weather-solar");
96
Added:
if (!canvas) return;
97
Added:
98
Added:
const radiation = data.points.map(p => p.solar_radiation || 0);
99
Added:
const clouds = data.points.map(p => p.cloud_cover);
100
Added:
101
Added:
const datasets = [
102
Added:
{
103
Added:
type: "line",
104
Added:
label: "Solar radiation (W/m²)",
105
Added:
data: radiation,
106
Added:
borderColor: "#f59e0b",
107
Added:
backgroundColor: "rgba(245, 158, 11, 0.08)",
108
Added:
tension: 0.3,
109
Added:
pointRadius: 0,
110
Added:
pointHoverRadius: 3,
111
Added:
yAxisID: "yRadiation",
112
Added:
fill: true,
113
Added:
order: 1
114
Added:
},
115
Added:
{
116
Added:
type: "line",
117
Added:
label: "Cloud cover (%)",
118
Added:
data: clouds,
119
Added:
borderColor: "#64748b",
120
Added:
backgroundColor: "rgba(100, 116, 139, 0.06)",
121
Added:
tension: 0.3,
122
Added:
pointRadius: 0,
123
Added:
pointHoverRadius: 3,
124
Added:
yAxisID: "yCloud",
125
Added:
fill: true,
126
Added:
order: 2
127
Added:
}
128
Added:
];
129
Added:
130
Added:
if (!charts.solar) {
131
Added:
charts.solar = new Chart(canvas, {
132
Added:
type: "line",
133
Added:
data: { labels, datasets },
134
Added:
options: {
135
Added:
responsive: true,
136
Added:
maintainAspectRatio: false,
137
Added:
animation: false,
138
Added:
interaction: { mode: "index", intersect: false },
139
Added:
scales: {
140
Added:
x: { ticks: { maxTicksLimit: compact ? 6 : 10, maxRotation: compact ? 0 : 45, minRotation: 0, font: { size: 10 } } },
141
Added:
yRadiation: {
142
Added:
type: "linear",
143
Added:
position: "left",
144
Added:
beginAtZero: true,
145
Added:
title: { display: !compact, text: "W/m²", color: "#f59e0b" },
146
Added:
ticks: { color: "#f59e0b", font: { size: 10 } },
147
Added:
grid: { display: true }
116
148
},
149
Added:
yCloud: {
150
Added:
type: "linear",
151
Added:
position: "right",
152
Added:
display: !compact,
153
Added:
min: 0,
154
Added:
max: 100,
155
Added:
title: { display: true, text: "%", color: "#64748b" },
156
Added:
ticks: { color: "#64748b", font: { size: 10 } },
157
Added:
grid: { drawOnChartArea: false }
158
Added:
}
159
Added:
},
160
Added:
plugins: {
161
Added:
legend: { display: true, labels: { boxWidth: compact ? 24 : 40, font: { size: 10 } } },
117
162
tooltip: tooltipOptions({ title: () => "" })
118
163
}
119
164
}
120
165
});
121
166
} else {
122
Removed:
chart.data.labels = labels;
123
Removed:
chart.data.datasets[0].data = temps;
124
Removed:
chart.data.datasets[1].data = humids;
125
Removed:
chart.data.datasets[2].data = rains;
126
Removed:
chart.update();
167
Added:
charts.solar.data.labels = labels;
168
Added:
charts.solar.data.datasets[0].data = radiation;
169
Added:
charts.solar.data.datasets[1].data = clouds;
170
Added:
charts.solar.update();
127
171
}
128
172
}
129
173
174
Added:
function loadGround(data, labels, compact) {
175
Added:
const canvas = document.getElementById("chart-weather-ground");
176
Added:
if (!canvas) return;
177
Added:
178
Added:
const soilTemp = data.points.map(p => p.soil_temperature);
179
Added:
const pressure = data.points.map(p => p.pressure);
180
Added:
181
Added:
const datasets = [
182
Added:
{
183
Added:
type: "line",
184
Added:
label: "Soil temp (°C)",
185
Added:
data: soilTemp,
186
Added:
borderColor: "#854d0e",
187
Added:
backgroundColor: "rgba(133, 77, 14, 0.08)",
188
Added:
tension: 0.3,
189
Added:
pointRadius: 0,
190
Added:
pointHoverRadius: 3,
191
Added:
yAxisID: "ySoil",
192
Added:
fill: true,
193
Added:
order: 1
194
Added:
},
195
Added:
{
196
Added:
type: "line",
197
Added:
label: "Pressure (hPa)",
198
Added:
data: pressure,
199
Added:
borderColor: "#7c3aed",
200
Added:
backgroundColor: "rgba(124, 58, 237, 0.06)",
201
Added:
tension: 0.3,
202
Added:
pointRadius: 0,
203
Added:
pointHoverRadius: 3,
204
Added:
yAxisID: "yPressure",
205
Added:
fill: true,
206
Added:
order: 2
207
Added:
}
208
Added:
];
209
Added:
210
Added:
if (!charts.ground) {
211
Added:
charts.ground = new Chart(canvas, {
212
Added:
type: "line",
213
Added:
data: { labels, datasets },
214
Added:
options: {
215
Added:
responsive: true,
216
Added:
maintainAspectRatio: false,
217
Added:
animation: false,
218
Added:
interaction: { mode: "index", intersect: false },
219
Added:
scales: {
220
Added:
x: { ticks: { maxTicksLimit: compact ? 6 : 10, maxRotation: compact ? 0 : 45, minRotation: 0, font: { size: 10 } } },
221
Added:
ySoil: {
222
Added:
type: "linear",
223
Added:
position: "left",
224
Added:
title: { display: !compact, text: "°C", color: "#854d0e" },
225
Added:
ticks: { color: "#854d0e", font: { size: 10 } },
226
Added:
grid: { display: true }
227
Added:
},
228
Added:
yPressure: {
229
Added:
type: "linear",
230
Added:
position: "right",
231
Added:
display: !compact,
232
Added:
title: { display: true, text: "hPa", color: "#7c3aed" },
233
Added:
ticks: { color: "#7c3aed", font: { size: 10 } },
234
Added:
grid: { drawOnChartArea: false }
235
Added:
}
236
Added:
},
237
Added:
plugins: {
238
Added:
legend: { display: true, labels: { boxWidth: compact ? 24 : 40, font: { size: 10 } } },
239
Added:
tooltip: tooltipOptions({ title: () => "" })
240
Added:
}
241
Added:
}
242
Added:
});
243
Added:
} else {
244
Added:
charts.ground.data.labels = labels;
245
Added:
charts.ground.data.datasets[0].data = soilTemp;
246
Added:
charts.ground.data.datasets[1].data = pressure;
247
Added:
charts.ground.update();
248
Added:
}
249
Added:
}
250
Added:
251
Added:
export function weatherScaleOptions(compact) {
252
Added:
return {
253
Added:
x: { ticks: { maxTicksLimit: compact ? 6 : 10, maxRotation: compact ? 0 : 45, minRotation: 0, font: { size: 10 } } },
254
Added:
yTemp: { type: "linear", position: "left", title: { display: !compact, text: "°C", color: "#dc2626" }, ticks: { color: "#dc2626", font: { size: 10 } }, grid: { display: true } },
255
Added:
yHumid: { type: "linear", position: "right", display: !compact, min: 0, max: 100, title: { display: true, text: "%", color: "#0ea5e9" }, ticks: { color: "#0ea5e9", font: { size: 10 } }, grid: { drawOnChartArea: false } },
256
Added:
yRain: { type: "linear", position: "right", display: !compact, beginAtZero: true, title: { display: true, text: "mm", color: "#64748b" }, ticks: { color: "#64748b", font: { size: 10 } }, grid: { drawOnChartArea: false }, afterFit(axis) { axis.paddingLeft = 10; } }
257
Added:
};
258
Added:
}
259
Added:
130
260
export function createWeatherCharts() {
131
261
const hasCanvas = document.getElementById("chart-weather-combined");
132
262
if (!hasCanvas) return { reload: () => {} };
133
263
134
264
const timeframe = createTimeframeState();
135
265
136
Removed:
function loadAll() {
137
Removed:
return loadCombined(timeframe);
266
Added:
function refresh() {
267
Added:
return loadAll(timeframe);
138
268
}
139
269
140
Removed:
initialiseGraphControls(timeframe, () => loadAll());
141
Removed:
loadAll();
270
Added:
initialiseGraphControls(timeframe, () => refresh());
271
Added:
refresh();
142
272
143
Removed:
return {
144
Removed:
reload() { loadAll(); }
145
Removed:
};
273
Added:
return { reload() { refresh(); } };
146
274
}
roles/dashboard/t/09-weather.t
@@ -139,6 +139,8 @@
139
139
->status_is(200)
140
140
->text_is( 'title', 'FAPG DAQ Weather' )
141
141
->element_exists('canvas#chart-weather-combined')
142
Added:
->element_exists('canvas#chart-weather-solar')
143
Added:
->element_exists('canvas#chart-weather-ground')
142
144
->element_exists('[data-default-timeframe="day"]');
143
145
};
144
146
roles/dashboard/templates/dashboard/weather.html.ep
@@ -18,5 +18,27 @@
18
18
</div>
19
19
</div>
20
20
</div>
21
Added:
22
Added:
<div class="col-12" id="weather-solar">
23
Added:
<div class="card">
24
Added:
%= include 'dashboard/chart-card-header', title => 'Solar Radiation & Cloud Cover', info_id => 'info-solar', info_text => 'Shortwave solar radiation (W/m²) drives photosynthesis and greenhouse heating. Cloud cover (%) indicates how much light reaches the plants. Correlates with dissolved oxygen production in the fish tanks.'
25
Added:
<div class="card-body p-2 p-sm-3">
26
Added:
<div class="chart-wrap">
27
Added:
<canvas id="chart-weather-solar" role="img" aria-label="Solar radiation and cloud cover over time"></canvas>
28
Added:
</div>
29
Added:
</div>
30
Added:
</div>
31
Added:
</div>
32
Added:
33
Added:
<div class="col-12" id="weather-ground">
34
Added:
<div class="card">
35
Added:
%= include 'dashboard/chart-card-header', title => 'Soil Temperature & Atmospheric Pressure', info_id => 'info-ground', info_text => 'Soil temperature at 6 cm depth (°C) is relevant for outdoor root vegetables and predicting water temperature lag. Atmospheric pressure (hPa) drops correlate with incoming weather fronts and can affect fish behaviour.'
36
Added:
<div class="card-body p-2 p-sm-3">
37
Added:
<div class="chart-wrap">
38
Added:
<canvas id="chart-weather-ground" role="img" aria-label="Soil temperature and atmospheric pressure over time"></canvas>
39
Added:
</div>
40
Added:
</div>
41
Added:
</div>
42
Added:
</div>
21
43
</div>
22
44
</section>