fontdelbosc-eltemps/includes/class-fontdelbosc-api-openmeteo.php
2026-10-12 11:55:23 +00:00

379 lines
11 KiB
PHP

<?php
/**
* Open-Meteo API handler. No API key required; used as universal fallback
* and as the only source for historical (ERA5) data.
*
* @package FontdelboscEltemps
*/
defined( 'ABSPATH' ) || exit;
/**
* Handles Open-Meteo forecast and ERA5 archive requests.
*/
final class Fontdelbosc_Api_OpenMeteo {
/**
* Forecast endpoint.
*/
private const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
/**
* ERA5 archive endpoint.
*/
private const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
/**
* WMO weather interpretation code to condition key.
*
* @var array<int,string>
*/
private const WMO_MAP = array(
0 => 'clear',
1 => 'mainly_clear',
2 => 'partly_cloudy',
3 => 'overcast',
45 => 'fog',
48 => 'fog',
51 => 'drizzle_light',
53 => 'drizzle',
55 => 'drizzle_heavy',
61 => 'rain_light',
63 => 'rain',
65 => 'rain_heavy',
71 => 'snow_light',
73 => 'snow',
75 => 'snow_heavy',
80 => 'rain_showers_light',
81 => 'rain_showers',
82 => 'rain_showers_heavy',
95 => 'thunderstorm',
96 => 'thunderstorm_hail',
99 => 'thunderstorm_hail',
);
/**
* Returns normalized current conditions.
*
* @return array<string,mixed>|null
*/
public static function get_current(): ?array {
$url = add_query_arg(
array(
'latitude' => FONTDELBOSC_ELTEMPS_LAT,
'longitude' => FONTDELBOSC_ELTEMPS_LON,
'current' => 'temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_direction_10m',
'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE,
'forecast_days' => 1,
),
self::FORECAST_URL
);
$data = self::request( $url );
$time = Fontdelbosc_Payload::str( Fontdelbosc_Payload::path( $data, 'current', 'time' ) );
if ( null === $time ) {
return null;
}
return array(
'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'temperature_2m' ) ),
'apparent' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'apparent_temperature' ) ),
'humidity' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'current', 'relative_humidity_2m' ) ),
'precipitation' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'precipitation' ) ),
'wind_speed' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'current', 'wind_speed_10m' ) ),
'wind_direction' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'current', 'wind_direction_10m' ) ),
'condition' => self::condition( Fontdelbosc_Payload::path( $data, 'current', 'weather_code' ) ),
'source' => 'open-meteo',
'time' => self::timestamp( $time ),
);
}
/**
* Returns the normalized hourly forecast, from the current hour onwards.
*
* @return array<string,mixed>|null
*/
public static function get_hourly(): ?array {
$url = add_query_arg(
array(
'latitude' => FONTDELBOSC_ELTEMPS_LAT,
'longitude' => FONTDELBOSC_ELTEMPS_LON,
'hourly' => 'temperature_2m,precipitation_probability,weather_code,wind_speed_10m',
'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE,
'forecast_days' => 3,
),
self::FORECAST_URL
);
$data = self::request( $url );
$times = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'hourly', 'time' ) );
$now = self::now_key();
$hours = array();
foreach ( $times as $index => $iso_time ) {
$raw = Fontdelbosc_Payload::str( $iso_time );
if ( null === $raw ) {
continue;
}
$time = str_replace( 'T', ' ', $raw );
if ( $time < $now ) {
continue;
}
$hours[] = array(
'time' => self::timestamp( $time ),
'hour' => (int) substr( $time, 11, 2 ),
'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'hourly', 'temperature_2m', $index ) ),
'pop' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'hourly', 'precipitation_probability', $index ) ),
'condition' => self::condition( Fontdelbosc_Payload::path( $data, 'hourly', 'weather_code', $index ) ),
'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'hourly', 'wind_speed_10m', $index ) ),
);
}
if ( array() === $hours ) {
return null;
}
return array(
'source' => 'open-meteo',
'hours' => $hours,
);
}
/**
* Returns the normalized daily forecast.
*
* @return array<string,mixed>|null
*/
public static function get_daily(): ?array {
$url = add_query_arg(
array(
'latitude' => FONTDELBOSC_ELTEMPS_LAT,
'longitude' => FONTDELBOSC_ELTEMPS_LON,
'daily' => 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,wind_speed_10m_max',
'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE,
'forecast_days' => 7,
),
self::FORECAST_URL
);
$data = self::request( $url );
$times = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'daily', 'time' ) );
$days = array();
foreach ( $times as $index => $date ) {
$date = Fontdelbosc_Payload::str( $date );
if ( null === $date ) {
continue;
}
$days[] = array(
'date' => $date,
'condition' => self::condition( Fontdelbosc_Payload::path( $data, 'daily', 'weather_code', $index ) ),
'temp_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_max', $index ) ),
'temp_min' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_min', $index ) ),
'precipitation' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'precipitation_sum', $index ) ),
'pop' => Fontdelbosc_Payload::int( Fontdelbosc_Payload::path( $data, 'daily', 'precipitation_probability_max', $index ) ),
'wind_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'wind_speed_10m_max', $index ) ),
);
}
if ( array() === $days ) {
return null;
}
return array(
'source' => 'open-meteo',
'days' => $days,
);
}
/**
* Returns the normalized daily history for a given month (ERA5).
*
* @param int $year Year (1940 or later).
* @param int $month Month (1-12).
* @return array<string,mixed>|null
*/
public static function get_historical( int $year, int $month ): ?array {
try {
$start = new DateTimeImmutable( sprintf( '%04d-%02d-01', $year, $month ), new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) );
} catch ( Exception ) {
return null;
}
$url = add_query_arg(
array(
'latitude' => FONTDELBOSC_ELTEMPS_LAT,
'longitude' => FONTDELBOSC_ELTEMPS_LON,
'start_date' => $start->format( 'Y-m-d' ),
'end_date' => $start->modify( 'last day of this month' )->format( 'Y-m-d' ),
'daily' => 'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
'timezone' => FONTDELBOSC_ELTEMPS_TIMEZONE,
),
self::ARCHIVE_URL
);
$data = self::request( $url );
$times = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'daily', 'time' ) );
$days = array();
foreach ( $times as $index => $date ) {
$date = Fontdelbosc_Payload::str( $date );
$max = Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_max', $index ) );
$min = Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'temperature_2m_min', $index ) );
if ( null === $date || ( null === $max && null === $min ) ) {
continue;
}
$days[] = array(
'date' => $date,
'temp_max' => $max,
'temp_min' => $min,
'precipitation' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'precipitation_sum', $index ) ),
'wind_max' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $data, 'daily', 'wind_speed_10m_max', $index ) ),
);
}
if ( array() === $days ) {
return null;
}
return array(
'source' => 'open-meteo',
'year' => $year,
'month' => $month,
'days' => $days,
'stats' => self::stats( $days ),
);
}
/**
* Computes monthly statistics for a set of normalized history days.
*
* @param array<int,array<string,mixed>> $days Normalized days.
* @return array<string,float|null>
*/
private static function stats( array $days ): array {
$maxes = array();
$mins = array();
$precip_sum = 0.0;
$wind_max = null;
foreach ( $days as $day ) {
$max = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'temp_max' ) );
$min = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'temp_min' ) );
$pre = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'precipitation' ) );
$win = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $day, 'wind_max' ) );
if ( null !== $max ) {
$maxes[] = $max;
}
if ( null !== $min ) {
$mins[] = $min;
}
if ( null !== $pre ) {
$precip_sum += $pre;
}
if ( null !== $win ) {
$wind_max = max( (float) $wind_max, $win );
}
}
return array(
'avg_max' => array() === $maxes ? null : round( array_sum( $maxes ) / count( $maxes ), 1 ),
'avg_min' => array() === $mins ? null : round( array_sum( $mins ) / count( $mins ), 1 ),
'total_precip' => round( $precip_sum, 1 ),
'max_wind' => $wind_max,
);
}
/**
* Maps a WMO code to a condition key.
*
* @param mixed $code WMO weather code.
* @return string|null
*/
private static function condition( mixed $code ): ?string {
if ( ! is_numeric( $code ) ) {
return null;
}
return self::WMO_MAP[ (int) $code ] ?? 'cloudy';
}
/**
* Converts a local (Europe/Madrid) ISO string to a Unix timestamp.
*
* @param string $iso ISO date-time string.
* @return int
*/
private static function timestamp( string $iso ): int {
try {
return ( new DateTimeImmutable( $iso, new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->getTimestamp();
} catch ( Exception ) {
return time();
}
}
/**
* Returns the current hour key used to filter hourly series.
*
* @return string Format: Y-m-d H:00.
*/
private static function now_key(): string {
return ( new DateTimeImmutable( 'now', new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) ) )->format( 'Y-m-d H:00' );
}
/**
* Performs a GET request and decodes the JSON response.
*
* @param string $url Request URL.
* @return array<mixed>|null
*/
private static function request( string $url ): ?array {
$response = wp_remote_get(
$url,
array(
'timeout' => 5,
'headers' => array(
'Accept' => 'application/json',
),
)
);
if ( is_wp_error( $response ) ) {
self::log( 'Open-Meteo request failed: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
self::log( 'Open-Meteo returned HTTP ' . $code );
return null;
}
$data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
return is_array( $data ) ? $data : null;
}
/**
* Writes a message to the error log with the plugin prefix.
*
* @param string $message Message.
* @return void
*/
private static function log( string $message ): void {
error_log( '[fontdelbosc-eltemps] ' . $message );
}
}