379 lines
11 KiB
PHP
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 );
|
|
}
|
|
}
|