View Source

The actual code and article source behind this site. No minification. No transpilation. What you see is what runs.

↓ Download PHP edition
weather-full.php 441 lines
<?php
function weatherRound1(float $value): float
{
return round($value, 1);
}
function weatherWmo(int $code): array
{
static $codes = [
0 => ['label' => 'Clear sky', 'icon' => '☀️'],
1 => ['label' => 'Mainly clear', 'icon' => '🌤️'],
2 => ['label' => 'Partly cloudy', 'icon' => '⛅'],
3 => ['label' => 'Overcast', 'icon' => '☁️'],
45 => ['label' => 'Foggy', 'icon' => '🌫️'],
48 => ['label' => 'Icy fog', 'icon' => '🌫️'],
51 => ['label' => 'Light drizzle', 'icon' => '🌦️'],
53 => ['label' => 'Drizzle', 'icon' => '🌦️'],
55 => ['label' => 'Heavy drizzle', 'icon' => '🌧️'],
61 => ['label' => 'Light rain', 'icon' => '🌧️'],
63 => ['label' => 'Rain', 'icon' => '🌧️'],
65 => ['label' => 'Heavy rain', 'icon' => '🌧️'],
71 => ['label' => 'Light snow', 'icon' => '🌨️'],
73 => ['label' => 'Snow', 'icon' => '❄️'],
75 => ['label' => 'Heavy snow', 'icon' => '❄️'],
77 => ['label' => 'Snow grains', 'icon' => '🌨️'],
80 => ['label' => 'Light showers', 'icon' => '🌦️'],
81 => ['label' => 'Showers', 'icon' => '🌧️'],
82 => ['label' => 'Heavy showers', 'icon' => '🌧️'],
85 => ['label' => 'Snow showers', 'icon' => '🌨️'],
86 => ['label' => 'Heavy snow showers', 'icon' => '❄️'],
95 => ['label' => 'Thunderstorm', 'icon' => '⛈️'],
96 => ['label' => 'Thunderstorm w/ hail', 'icon' => '⛈️'],
99 => ['label' => 'Thunderstorm w/ heavy hail', 'icon' => '⛈️'],
];
return $codes[$code] ?? ['label' => 'Unknown', 'icon' => '🌡️'];
}
function weatherWindDirection(float $degrees): string
{
$directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
return $directions[((int)round($degrees / 45)) % 8];
}
function weatherDewPoint(float $tempC, float $humidity): float
{
$a = 17.625;
$b = 243.04;
$safeHumidity = max(0.1, min(100, $humidity));
$gamma = log($safeHumidity / 100) + ($a * $tempC) / ($b + $tempC);
return weatherRound1(($b * $gamma) / ($a - $gamma));
}
function weatherWetBulb(float $tempC, float $humidity): float
{
return $tempC * atan(0.151977 * sqrt($humidity + 8.313659))
+ atan($tempC + $humidity)
- atan($humidity - 1.676331)
+ 0.00391838 * pow($humidity, 1.5) * atan(0.023101 * $humidity)
- 4.686035;
}
function weatherDeltaT(float $tempC, float $humidity): float
{
return weatherRound1($tempC - weatherWetBulb($tempC, $humidity));
}
function weatherSprayRating(
float $deltaT,
float $wind,
float $tempC,
float $precipMm,
float $precipProbability
): array {
if ($precipMm > 0) return ['rating' => 'Poor — raining', 'cls' => 'spray-poor'];
if ($wind > 24) return ['rating' => 'Poor — wind too high', 'cls' => 'spray-poor'];
if ($deltaT < 1) return ['rating' => 'Poor — inversion risk', 'cls' => 'spray-poor'];
if ($deltaT > 12) return ['rating' => 'Poor — too hot/dry', 'cls' => 'spray-poor'];
if ($tempC < 5) return ['rating' => 'Poor — too cold', 'cls' => 'spray-poor'];
if ($deltaT < 2) return ['rating' => 'Marginal — low Delta T', 'cls' => 'spray-marginal'];
if ($deltaT > 10) return ['rating' => 'Marginal — high Delta T', 'cls' => 'spray-marginal'];
if ($wind > 15) return ['rating' => 'Marginal — windy', 'cls' => 'spray-marginal'];
if ($wind < 3) return ['rating' => 'Marginal — calm/inversion risk', 'cls' => 'spray-marginal'];
if ($tempC < 10) return ['rating' => 'Marginal — cool', 'cls' => 'spray-marginal'];
if ($precipProbability >= 50) return ['rating' => 'Marginal — rain likely', 'cls' => 'spray-marginal'];
if ($deltaT >= 2 && $deltaT <= 8 && $wind >= 3 && $wind <= 15 && $tempC >= 10 && $precipProbability < 30) {
return ['rating' => 'Ideal', 'cls' => 'spray-ideal'];
}
return ['rating' => 'Good', 'cls' => 'spray-good'];
}
function weatherFetch(string $url, int $timeout, array $headers = []): ?string
{
$headerLines = array_merge([
'User-Agent: dispelled.ca weather dashboard (local farm conditions)',
'Accept: application/json,text/html,application/xhtml+xml',
], $headers);
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $timeout,
'ignore_errors' => true,
'header' => implode("\r\n", $headerLines),
],
]);
$body = @file_get_contents($url, false, $context);
if ($body === false) return null;
$status = 0;
foreach ($http_response_header ?? [] as $line) {
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $line, $match)) {
$status = (int)$match[1];
}
}
return $status >= 200 && $status < 300 ? $body : null;
}
function weatherJsonObjectAt(string $text, int $start): ?array
{
$depth = 0;
$inString = false;
$escaped = false;
$length = strlen($text);
for ($i = $start; $i < $length; $i++) {
$char = $text[$i];
if ($inString) {
if ($escaped) {
$escaped = false;
} elseif ($char === '\\') {
$escaped = true;
} elseif ($char === '"') {
$inString = false;
}
continue;
}
if ($char === '"') {
$inString = true;
} elseif ($char === '{') {
$depth++;
} elseif ($char === '}') {
$depth--;
if ($depth === 0) {
$decoded = json_decode(substr($text, $start, $i - $start + 1), true);
return is_array($decoded) ? $decoded : null;
}
}
}
return null;
}
function weatherArdillRecordToObservation(array $record): ?array
{
if (!isset($record['obsTimeLocal'], $record['obsTimeUtc'], $record['imperial'])) return null;
$observed = strtotime((string)$record['obsTimeUtc']);
if ($observed === false || time() - $observed > 90 * 60 || !is_array($record['imperial'])) return null;
$values = $record['imperial'];
$number = static fn($value): ?float => is_numeric($value) ? (float)$value : null;
$fToC = static fn(?float $value): ?float => $value === null ? null : ($value - 32) * 5 / 9;
$miToKm = static fn(?float $value): ?float => $value === null ? null : $value * 1.609344;
$inToMm = static fn(?float $value): ?float => $value === null ? null : $value * 25.4;
$inHgToHpa = static fn(?float $value): ?float => $value === null ? null : $value * 33.8638866667;
$pressureLow = $number($values['pressureMin'] ?? $values['pressure'] ?? null);
$pressureHigh = $number($values['pressureMax'] ?? $values['pressure'] ?? null);
$averagePressure = ($pressureLow === null && $pressureHigh === null)
? null
: (($pressureLow ?? $pressureHigh) + ($pressureHigh ?? $pressureLow)) / 2;
return [
'observedAt' => (string)$record['obsTimeLocal'],
'tempC' => $fToC($number($values['tempAvg'] ?? $values['temp'] ?? null)),
'humidity' => $number($record['humidityAvg'] ?? $record['humidity'] ?? null),
'dewPointC' => $fToC($number($values['dewptAvg'] ?? $values['dewpt'] ?? null)),
'windKmh' => $miToKm($number($values['windspeedAvg'] ?? $values['windSpeed'] ?? null)),
'windGustKmh' => $miToKm($number($values['windgustAvg'] ?? $values['windGust'] ?? null)),
'windDirection' => $number($record['winddirAvg'] ?? $record['winddir'] ?? null),
'pressureHpa' => $inHgToHpa($averagePressure),
'precipRateMm' => $inToMm($number($values['precipRate'] ?? null)),
'precipTodayMm' => $inToMm($number($values['precipTotal'] ?? null)),
'uv' => $number($record['uvHigh'] ?? $record['uv'] ?? null),
];
}
function weatherArdillObservation(): ?array
{
$stationId = 'IARDIL4';
$body = weatherFetch("https://www.wunderground.com/dashboard/pws/{$stationId}", 10);
if ($body === null) return null;
$marker = '{"stationID":"' . $stationId . '"';
$start = strrpos($body, $marker);
if ($start !== false) {
$record = weatherJsonObjectAt($body, $start);
$observation = is_array($record) ? weatherArdillRecordToObservation($record) : null;
if ($observation !== null) return $observation;
}
if (!preg_match('/apiKey=([A-Za-z0-9]+)/', $body, $match)) return null;
$params = http_build_query([
'apiKey' => $match[1],
'stationId' => $stationId,
'numericPrecision' => 'decimal',
'format' => 'json',
'units' => 'e',
]);
$currentBody = weatherFetch("https://api.weather.com/v2/pws/observations/current?{$params}", 10);
if ($currentBody === null) return null;
$current = json_decode($currentBody, true);
$record = $current['observations'][0] ?? null;
return is_array($record) ? weatherArdillRecordToObservation($record) : null;
}
function getFullWeather(): ?array
{
$cacheFile = CACHE_DIR . '/weather_full.json';
$cached = null;
if (is_file($cacheFile)) {
$decoded = json_decode((string)file_get_contents($cacheFile), true);
if (is_array($decoded) && empty($decoded['error'])) {
$cached = $decoded;
if (time() - filemtime($cacheFile) < 900) return $cached;
}
}
$latitude = '49.9007';
$longitude = '-105.79557';
$timezone = new DateTimeZone('America/Regina');
$today = new DateTimeImmutable('today', $timezone);
$yesterday = $today->modify('-1 day');
$seasonStart = $today->format('Y') . '-05-01';
$fetchHistory = $yesterday->format('Y-m-d') >= $seasonStart;
$weatherParams = http_build_query([
'latitude' => $latitude,
'longitude' => $longitude,
'current' => 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m,precipitation,surface_pressure,cloud_cover,visibility,uv_index,is_day',
'hourly' => 'temperature_2m,relative_humidity_2m,dew_point_2m,precipitation_probability,precipitation,snowfall,weather_code,wind_speed_10m,wind_direction_10m,visibility,uv_index,shortwave_radiation,cape,lifted_index,freezing_level_height,soil_temperature_0cm,soil_temperature_6cm,soil_temperature_18cm,soil_moisture_0_to_1cm,soil_moisture_3_to_9cm,soil_moisture_9_to_27cm',
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,sunrise,sunset,uv_index_max,precipitation_sum,precipitation_hours,precipitation_probability_max,snowfall_sum,wind_speed_10m_max,wind_gusts_10m_max,wind_direction_10m_dominant,et0_fao_evapotranspiration,sunshine_duration,shortwave_radiation_sum',
'temperature_unit' => 'celsius',
'wind_speed_unit' => 'kmh',
'timezone' => 'America/Regina',
'forecast_days' => 7,
'past_days' => 7,
]);
$weatherBody = weatherFetch("https://api.open-meteo.com/v1/forecast?{$weatherParams}", 10);
if ($weatherBody === null) return $cached;
$wx = json_decode($weatherBody, true);
if (!is_array($wx) || empty($wx['current']) || empty($wx['daily']) || empty($wx['hourly'])) return $cached;
$history = null;
if ($fetchHistory) {
$historyParams = http_build_query([
'latitude' => $latitude,
'longitude' => $longitude,
'start_date' => $seasonStart,
'end_date' => $yesterday->format('Y-m-d'),
'daily' => 'temperature_2m_max,temperature_2m_min',
'timezone' => 'America/Regina',
]);
$historyBody = weatherFetch("https://archive-api.open-meteo.com/v1/archive?{$historyParams}", 15);
if ($historyBody !== null) $history = json_decode($historyBody, true);
}
$station = weatherArdillObservation();
$seasonGdd = 0.0;
$seasonGddDays = 0;
if (is_array($history['daily']['time'] ?? null)) {
foreach ($history['daily']['time'] as $index => $_date) {
$high = $history['daily']['temperature_2m_max'][$index] ?? null;
$low = $history['daily']['temperature_2m_min'][$index] ?? null;
if ($high !== null && $low !== null) {
$seasonGdd += max(0, ((float)$high + (float)$low) / 2 - 10);
$seasonGddDays++;
}
}
}
$current = $wx['current'];
$daily = $wx['daily'];
$hourlyData = $wx['hourly'];
$currentWmo = weatherWmo((int)$current['weather_code']);
$currentTemp = (float)($station['tempC'] ?? $current['temperature_2m']);
$currentHumidity = (float)($station['humidity'] ?? $current['relative_humidity_2m']);
$currentWind = (float)($station['windKmh'] ?? $current['wind_speed_10m']);
$currentWindDirection = (float)($station['windDirection'] ?? $current['wind_direction_10m']);
$currentPrecip = (float)($station['precipRateMm'] ?? $current['precipitation'] ?? 0);
$todayString = substr((string)$current['time'], 0, 10);
$precipHistory = [];
foreach ($daily['time'] as $index => $date) {
if ($date < $todayString) {
$precipHistory[] = ['date' => $date, 'precip' => weatherRound1((float)($daily['precipitation_sum'][$index] ?? 0))];
}
}
$sevenDayPastTotal = weatherRound1(array_sum(array_column($precipHistory, 'precip')));
$daysSinceRain = 0;
for ($index = count($precipHistory) - 1; $index >= 0; $index--) {
if ($precipHistory[$index]['precip'] >= 1) break;
$daysSinceRain++;
}
$currentDeltaT = weatherDeltaT($currentTemp, $currentHumidity);
$currentSpray = weatherSprayRating($currentDeltaT, round($currentWind), $currentTemp, $currentPrecip, 0);
$startIndex = 0;
foreach ($hourlyData['time'] as $index => $time) {
if ($time >= $current['time']) {
$startIndex = $index;
break;
}
}
$hourly = [];
$end = min($startIndex + 24, count($hourlyData['time']));
for ($index = $startIndex; $index < $end; $index++) {
$wmo = weatherWmo((int)$hourlyData['weather_code'][$index]);
$temp = (float)$hourlyData['temperature_2m'][$index];
$humidity = (float)($hourlyData['relative_humidity_2m'][$index] ?? 50);
$deltaT = weatherDeltaT($temp, $humidity);
$spray = weatherSprayRating(
$deltaT,
round((float)($hourlyData['wind_speed_10m'][$index] ?? 0)),
$temp,
(float)($hourlyData['precipitation'][$index] ?? 0),
(float)($hourlyData['precipitation_probability'][$index] ?? 0)
);
$hourly[] = [
'time' => substr((string)$hourlyData['time'][$index], 11, 5),
'temp' => (int)round($temp),
'rh' => (int)round($humidity),
'dew_point' => weatherRound1((float)($hourlyData['dew_point_2m'][$index] ?? 0)),
'delta_t' => $deltaT,
'spray_rating' => $spray['rating'],
'spray_cls' => $spray['cls'],
'precip_prob' => $hourlyData['precipitation_probability'][$index] ?? 0,
'precip' => weatherRound1((float)($hourlyData['precipitation'][$index] ?? 0)),
'snowfall' => weatherRound1((float)($hourlyData['snowfall'][$index] ?? 0)),
'wind' => (int)round((float)($hourlyData['wind_speed_10m'][$index] ?? 0)),
'wind_dir' => weatherWindDirection((float)($hourlyData['wind_direction_10m'][$index] ?? 0)),
'icon' => $wmo['icon'],
'uv' => weatherRound1((float)($hourlyData['uv_index'][$index] ?? 0)),
'shortwave' => (int)round((float)($hourlyData['shortwave_radiation'][$index] ?? 0)),
'cape' => (int)round((float)($hourlyData['cape'][$index] ?? 0)),
'lifted_index' => weatherRound1((float)($hourlyData['lifted_index'][$index] ?? 0)),
'freezing_level' => isset($hourlyData['freezing_level_height'][$index]) ? (int)round((float)$hourlyData['freezing_level_height'][$index]) : null,
'soil_temp_0' => weatherRound1((float)($hourlyData['soil_temperature_0cm'][$index] ?? 0)),
'soil_temp_6' => weatherRound1((float)($hourlyData['soil_temperature_6cm'][$index] ?? 0)),
'soil_temp_18' => weatherRound1((float)($hourlyData['soil_temperature_18cm'][$index] ?? 0)),
'soil_moist_0' => weatherRound1((float)($hourlyData['soil_moisture_0_to_1cm'][$index] ?? 0) * 100),
'soil_moist_3' => weatherRound1((float)($hourlyData['soil_moisture_3_to_9cm'][$index] ?? 0) * 100),
'soil_moist_9' => weatherRound1((float)($hourlyData['soil_moisture_9_to_27cm'][$index] ?? 0) * 100),
'visibility' => round((float)($hourlyData['visibility'][$index] ?? 0) / 100) / 10,
];
}
$forecast = [];
$gddCumulative = 0.0;
foreach ($daily['time'] as $index => $date) {
if ($date < $todayString) continue;
$wmo = weatherWmo((int)$daily['weather_code'][$index]);
$low = (int)round((float)$daily['temperature_2m_min'][$index]);
$high = (int)round((float)$daily['temperature_2m_max'][$index]);
$gdd = max(0, weatherRound1(($high + $low) / 2 - 10));
$gddCumulative += $gdd;
$forecast[] = [
'date' => $date,
'high' => $high,
'low' => $low,
'feels_high' => (int)round((float)$daily['apparent_temperature_max'][$index]),
'feels_low' => (int)round((float)$daily['apparent_temperature_min'][$index]),
'label' => $wmo['label'],
'icon' => $wmo['icon'],
'precip_prob' => $daily['precipitation_probability_max'][$index] ?? 0,
'precip_sum' => weatherRound1((float)($daily['precipitation_sum'][$index] ?? 0)),
'precip_hrs' => $daily['precipitation_hours'][$index] ?? 0,
'snowfall_sum' => weatherRound1((float)($daily['snowfall_sum'][$index] ?? 0)),
'wind_max' => (int)round((float)($daily['wind_speed_10m_max'][$index] ?? 0)),
'gust_max' => (int)round((float)($daily['wind_gusts_10m_max'][$index] ?? 0)),
'wind_dir' => weatherWindDirection((float)($daily['wind_direction_10m_dominant'][$index] ?? 0)),
'uv_max' => weatherRound1((float)($daily['uv_index_max'][$index] ?? 0)),
'sunrise' => !empty($daily['sunrise'][$index]) ? substr((string)$daily['sunrise'][$index], 11, 5) : '—',
'sunset' => !empty($daily['sunset'][$index]) ? substr((string)$daily['sunset'][$index], 11, 5) : '—',
'et0' => weatherRound1((float)($daily['et0_fao_evapotranspiration'][$index] ?? 0)),
'sunshine_hrs' => weatherRound1((float)($daily['sunshine_duration'][$index] ?? 0) / 3600),
'solar_sum' => weatherRound1((float)($daily['shortwave_radiation_sum'][$index] ?? 0)),
'gdd' => $gdd,
'gdd_cum' => weatherRound1($gddCumulative),
'frost' => $low <= 2,
'freeze' => $low <= 0,
];
}
if ($forecast) {
$seasonGdd += $forecast[0]['gdd'];
$seasonGddDays++;
}
$data = [
'location' => 'Ardill, SK',
'temp' => (int)round($currentTemp),
'feelsLike' => (int)round((float)$current['apparent_temperature']),
'dewPoint' => isset($station['dewPointC']) ? weatherRound1((float)$station['dewPointC']) : weatherDewPoint($currentTemp, $currentHumidity),
'deltaT' => $currentDeltaT,
'spray' => $currentSpray,
'humidity' => (int)round($currentHumidity),
'windSpeed' => (int)round($currentWind),
'windDir' => weatherWindDirection($currentWindDirection),
'windGusts' => (int)round((float)($station['windGustKmh'] ?? $current['wind_gusts_10m'] ?? 0)),
'precip' => weatherRound1($currentPrecip),
'precipLabel' => isset($station['precipRateMm']) ? 'Precip rate' : 'Precip now',
'precipUnit' => isset($station['precipRateMm']) ? 'mm/h' : 'mm',
'precipToday' => isset($station['precipTodayMm']) ? weatherRound1((float)$station['precipTodayMm']) : null,
'pressure' => (int)round((float)($station['pressureHpa'] ?? $current['surface_pressure'] ?? 0)),
'cloud' => $current['cloud_cover'] ?? 0,
'visibility' => round((float)($current['visibility'] ?? 0) / 100) / 10,
'uv' => weatherRound1((float)($station['uv'] ?? $current['uv_index'] ?? 0)),
'label' => $currentWmo['label'],
'icon' => $currentWmo['icon'],
'time' => $station['observedAt'] ?? $current['time'],
'sources' => [
'current' => $station
? ['label' => 'Ardill weather station IARDIL4', 'observedAt' => $station['observedAt'], 'url' => 'https://www.wunderground.com/dashboard/pws/IARDIL4']
: ['label' => 'Open-Meteo location model for Ardill', 'observedAt' => $current['time'], 'url' => null],
'enrichment' => 'Open-Meteo forecast, soil, history, and unavailable local fields for Ardill',
],
'precipHistory' => $precipHistory,
'sevenDayPastTotal' => $sevenDayPastTotal,
'daysSinceRain' => $daysSinceRain,
'forecast' => $forecast,
'hourly' => $hourly,
'seasonGdd' => weatherRound1($seasonGdd),
'seasonGddDays' => $seasonGddDays,
'seasonGddStart' => $seasonStart,
];
if (!is_dir(CACHE_DIR)) mkdir(CACHE_DIR, 0755, true);
file_put_contents($cacheFile, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX);
return $data;
}