|null */ public static function get_hourly( string $api_key ): ?array { $payload = self::request( '/prediccio/v1/municipi/' . self::MUNICIPAL_CODE . '/prediccio', $api_key ); $series = array(); foreach ( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $payload, 0, 'prediccions' ) ) as $prediction ) { foreach ( Fontdelbosc_Payload::arr( Fontdelbosc_Payload::get( $prediction, 'variables' ) ) as $name => $points ) { foreach ( Fontdelbosc_Payload::arr( $points ) as $point ) { $iso = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $point, 'data' ) ); $key = null === $iso ? null : self::hour_key( $iso ); if ( null !== $key ) { $series[ (string) $name ][ $key ] = $point; } } } } $keys = array(); foreach ( $series as $points ) { $keys = array_merge( $keys, array_keys( $points ) ); } $keys = array_values( array_unique( $keys ) ); sort( $keys ); $now = self::now_key(); $hours = array(); foreach ( $keys as $key ) { if ( $key < $now ) { continue; } $hours[] = array( 'time' => self::key_timestamp( $key ), 'hour' => (int) substr( $key, 11, 2 ), 'temperature' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $series, 'temperatura', $key, 'valor' ) ), 'pop' => Fontdelbosc_Payload::int( self::coalesce( Fontdelbosc_Payload::path( $series, 'probabilitatPrecipitacio', $key, 'valor' ), Fontdelbosc_Payload::path( $series, 'probabilitatDePrecipitacio', $key, 'valor' ) ) ), 'condition' => self::condition( Fontdelbosc_Payload::path( $series, 'cel', $key ) ), 'wind' => Fontdelbosc_Payload::float( Fontdelbosc_Payload::path( $series, 'vent', $key, 'valor' ) ), ); } if ( array() === $hours ) { return null; } return array( 'source' => 'meteocat', 'hours' => $hours, ); } /** * Returns normalized current conditions from the configured XEMA station. * * @param string $api_key Meteocat API key. * @param string $station_code XEMA station code. * @return array|null */ public static function get_current( string $api_key, string $station_code ): ?array { if ( '' === $station_code ) { return null; } $temperature = self::station_variable( $api_key, $station_code, 't' ); $humidity = self::station_variable( $api_key, $station_code, 'hr' ); $wind = self::station_variable( $api_key, $station_code, 'v' ); if ( null === $temperature && null === $humidity && null === $wind ) { return null; } return array( 'temperature' => $temperature, 'apparent' => null, 'humidity' => $humidity, 'precipitation' => null, 'wind_speed' => null === $wind ? null : round( $wind * 3.6, 1 ), 'wind_direction' => null, 'condition' => null, 'source' => 'meteocat', 'time' => time(), ); } /** * Finds the XEMA station closest to Font del Bosc. * * @param string $api_key Meteocat API key. * @return array{codi:string,nom:string,distance_km:float}|null */ public static function find_nearest_station( string $api_key ): ?array { $stations = self::request( '/xema/v1/estacions/metadades', $api_key ); if ( null === $stations ) { return null; } $best = null; $distance = INF; foreach ( Fontdelbosc_Payload::arr( $stations ) as $station ) { $codi = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $station, 'codi' ) ); $lat = self::to_float( Fontdelbosc_Payload::path( $station, 'coordenades', 'latitud' ) ); $lon = self::to_float( Fontdelbosc_Payload::path( $station, 'coordenades', 'longitud' ) ); if ( null === $codi || '' === $codi || null === $lat || null === $lon ) { continue; } $current = self::haversine( FONTDELBOSC_ELTEMPS_LAT, FONTDELBOSC_ELTEMPS_LON, $lat, $lon ); if ( $current < $distance ) { $distance = $current; $best = array( 'codi' => $codi, 'nom' => Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $station, 'nom' ) ) ?? '', 'distance_km' => round( $current, 1 ), ); } } return $best; } /** * Returns the latest numeric value of a XEMA station variable. * * @param string $api_key Meteocat API key. * @param string $station Station code. * @param string $variable Variable code (t, hr, v). * @return float|null */ private static function station_variable( string $api_key, string $station, string $variable ): ?float { $data = self::request( '/xema/v1/estacions/' . rawurlencode( $station ) . '/dades/' . rawurlencode( $variable ), $api_key ); $measures = Fontdelbosc_Payload::arr( Fontdelbosc_Payload::path( $data, 'variables', 0, 'mesures' ) ); foreach ( array_reverse( $measures ) as $measure ) { $value = Fontdelbosc_Payload::float( Fontdelbosc_Payload::get( $measure, 'valor' ) ); if ( null !== $value ) { return $value; } } return null; } /** * Maps a Meteocat "cel" data point to a condition key, preferring the * descriptive Catalan "estat" text over the numeric symbol. * * @param mixed $point Cel data point. * @return string|null */ private static function condition( mixed $point ): ?string { if ( ! is_array( $point ) ) { return null; } $estat = Fontdelbosc_Payload::str( Fontdelbosc_Payload::get( $point, 'estat' ) ) ?? ''; if ( '' !== $estat ) { $estat = mb_strtolower( $estat ); $keywords = array( 'tempesta' => 'thunderstorm', 'calabruix' => 'thunderstorm_hail', 'aiguaneu' => 'snow', 'neu' => 'snow', 'plugim' => 'drizzle', 'ruixat' => 'rain_showers', 'pluja' => 'rain', 'boira' => 'fog', 'parcialment' => 'partly_cloudy', 'molt ennuvolat' => 'overcast', 'cobert' => 'overcast', 'ennuvolat' => 'cloudy', 'núvol' => 'cloudy', 'sol' => 'clear', 'clar' => 'clear', ); foreach ( $keywords as $needle => $condition ) { if ( str_contains( $estat, $needle ) ) { return $condition; } } } $symbol = Fontdelbosc_Payload::int( Fontdelbosc_Payload::get( $point, 'simbol' ) ); if ( null === $symbol ) { return null; } $map = array( 1 => 'clear', 2 => 'partly_cloudy', 3 => 'partly_cloudy', 4 => 'cloudy', 5 => 'drizzle', 6 => 'drizzle', 7 => 'drizzle', 8 => 'overcast', 9 => 'rain_light', 10 => 'rain', 11 => 'thunderstorm', 12 => 'thunderstorm', 13 => 'rain_showers', 14 => 'rain_showers', 15 => 'rain_showers', 16 => 'rain', ); return $map[ $symbol ] ?? 'cloudy'; } /** * Returns the first non-null value of a pair. * * @param mixed $first First value. * @param mixed $second Second value. * @return mixed */ private static function coalesce( mixed $first, mixed $second ): mixed { return null !== $first ? $first : $second; } /** * Converts a Meteocat ISO date-time to an hour key in local time. * * @param string $iso ISO date-time string. * @return string|null Format: Y-m-d H:00. */ private static function hour_key( string $iso ): ?string { try { return ( new DateTimeImmutable( $iso ) )->setTimezone( new DateTimeZone( FONTDELBOSC_ELTEMPS_TIMEZONE ) )->format( 'Y-m-d H:00' ); } catch ( Exception ) { return null; } } /** * Converts an hour key to a Unix timestamp. * * @param string $key Hour key (Y-m-d H:00). * @return int */ private static function key_timestamp( string $key ): int { try { return ( new DateTimeImmutable( $key, 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' ); } /** * Converts a coordinate value to float, handling decimal commas. * * @param mixed $value Raw value. * @return float|null */ private static function to_float( mixed $value ): ?float { if ( is_int( $value ) || is_float( $value ) ) { return (float) $value; } if ( is_string( $value ) ) { $normalized = str_replace( ',', '.', trim( $value ) ); return is_numeric( $normalized ) ? (float) $normalized : null; } return null; } /** * Haversine distance in kilometers between two coordinates. * * @param float $lat1 Latitude A. * @param float $lon1 Longitude A. * @param float $lat2 Latitude B. * @param float $lon2 Longitude B. * @return float */ private static function haversine( float $lat1, float $lon1, float $lat2, float $lon2 ): float { $radians = M_PI / 180; $radius = 6371; $dlat = ( $lat2 - $lat1 ) * $radians; $dlon = ( $lon2 - $lon1 ) * $radians; $a = ( sin( $dlat / 2 ) ** 2 ) + ( cos( $lat1 * $radians ) * cos( $lat2 * $radians ) * ( sin( $dlon / 2 ) ** 2 ) ); return 2 * $radius * asin( sqrt( $a ) ); } /** * Performs an authenticated GET request and decodes the JSON response. * * @param string $path API path. * @param string $api_key Meteocat API key. * @return array|null */ private static function request( string $path, string $api_key ): ?array { if ( '' === $api_key ) { return null; } $response = wp_remote_get( self::BASE_URL . $path, array( 'timeout' => 5, 'headers' => array( 'X-Api-Key' => $api_key, 'Accept' => 'application/json', ), ) ); if ( is_wp_error( $response ) ) { self::log( 'Meteocat request failed: ' . $response->get_error_message() ); return null; } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { self::log( 'Meteocat returned HTTP ' . $code . ' for ' . $path ); 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 ); } }