// SPDX-License-Identifier: MIT OR Apache-2.0
//! Weather data types and fetching from the Open-Meteo API.
use serde::{Deserialize, Serialize};
use crate::client::http_client;
use crate::codes::{CompassDirection, WeatherCondition};
use crate::error::Result;
use crate::geo::is_japan_bounds;
use crate::units::{MeasurementSystem, TemperatureUnit};
use crate::weather_jma::override_current_temp;
/// Current weather conditions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrentWeather {
/// Temperature in the requested unit (Fahrenheit or Celsius).
pub temperature: f32,
/// Raw WMO weather code from the API.
pub weathercode: i32,
/// Parsed weather condition from the WMO code.
pub condition: WeatherCondition,
/// Wind speed in the requested unit (mph or km/h).
pub windspeed: f32,
/// Relative humidity as a percentage (0-100).
pub humidity: i32,
/// Apparent temperature accounting for wind chill and heat index.
pub feels_like: f32,
/// Wind bearing in degrees (0-360).
pub wind_direction: i32,
/// Wind bearing as a compass direction.
pub compass_direction: CompassDirection,
/// Wind gust speed in the requested unit.
pub wind_gusts: f32,
/// UV index (0-11+).
pub uv_index: f32,
/// Visibility in meters. Convert with [`MeasurementSystem::convert_visibility`].
pub visibility: f32,
/// Surface pressure in hPa. Convert with [`PressureUnit::convert`].
pub pressure: f32,
/// Cloud cover as a percentage (0-100).
pub cloud_cover: i32,
/// Dew point in the requested temperature unit.
pub dew_point: f32,
}
/// Daily forecast data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DailyForecast {
/// ISO date string (e.g. "2025-11-25").
pub date: String,
/// High temperature for the day.
pub temp_max: f32,
/// Low temperature for the day.
pub temp_min: f32,
/// Raw WMO weather code.
pub weathercode: i32,
/// Parsed weather condition.
pub condition: WeatherCondition,
/// Sunrise time as an ISO timestamp (local time, no timezone).
pub sunrise: String,
/// Sunset time as an ISO timestamp (local time, no timezone).
pub sunset: String,
}
/// Hourly forecast data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HourlyForecast {
/// ISO timestamp for this hour (local time, no timezone).
pub time: String,
/// Temperature in the requested unit.
pub temperature: f32,
/// Raw WMO weather code.
pub weathercode: i32,
/// Parsed weather condition.
pub condition: WeatherCondition,
/// Chance of precipitation as a percentage (0-100).
pub precipitation_probability: i32,
/// Precipitation amount for this hour, in the requested unit (mm or inch).
pub precipitation: f32,
/// Wind speed in the requested unit (mph or km/h).
pub windspeed: f32,
/// Wind gust speed in the requested unit.
pub wind_gusts: f32,
}
/// Complete weather data from a single fetch.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeatherData {
/// Current conditions at the requested location.
pub current: CurrentWeather,
/// Next 24 hours, one entry per hour.
pub hourly: Vec<HourlyForecast>,
/// 7-day forecast, one entry per day.
pub forecast: Vec<DailyForecast>,
/// Seconds east of UTC for the requested location, from `timezone=auto`.
/// The `sunrise`/`sunset` and hourly `time` strings are in this offset's
/// local frame. Pass to [`crate::time::is_night_time`] so day/night is
/// computed at the location, not on the machine running the code.
pub utc_offset_seconds: i32,
}
/// Fetches weather data from the Open-Meteo API.
///
/// Core calls `.api_param()` internally so callers pass typed units
/// instead of raw strings.
pub async fn fetch_weather(
latitude: f64,
longitude: f64,
temperature_unit: TemperatureUnit,
measurement_system: MeasurementSystem,
) -> Result<WeatherData> {
let url = format!(
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}¤t=temperature_2m,weathercode,windspeed_10m,relative_humidity_2m,apparent_temperature,wind_direction_10m,wind_gusts_10m,uv_index,visibility,surface_pressure,cloud_cover,dewpoint_2m&hourly=temperature_2m,weathercode,precipitation_probability,precipitation,windspeed_10m,wind_gusts_10m&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset&temperature_unit={}&windspeed_unit={}&precipitation_unit={}&timezone=auto&forecast_days=7&forecast_hours=24",
latitude,
longitude,
temperature_unit.api_param(),
measurement_system.wind_speed_api_param(),
measurement_system.precipitation_api_param(),
);
let response = http_client()?.get(&url).send().await?.error_for_status()?;
let data: OpenMeteoResponse = response.json().await?;
// Japan: swap the current temperature for AMeDAS ground truth. Any
// failure falls through to Open-Meteo's value.
let current_temperature = if is_japan_bounds(latitude, longitude) {
match override_current_temp(latitude, longitude, temperature_unit).await {
Some(t) => {
tracing::debug!(
"AMeDAS override: {} -> {} ({:?})",
data.current.temperature_2m,
t,
temperature_unit
);
t
}
None => data.current.temperature_2m,
}
} else {
data.current.temperature_2m
};
// Open-Meteo returns each hourly field as its own parallel array. They are
// normally equal length, but a partial/degraded response can return a
// shorter array for some field — so pull every value with `.get()` and drop
// any row that's missing one, rather than indexing and risking a panic.
let hourly: Vec<_> = data
.hourly
.time
.iter()
.take(24)
.enumerate()
.filter_map(|(i, time)| {
let weathercode = *data.hourly.weathercode.get(i)?;
Some(HourlyForecast {
time: time.clone(),
temperature: *data.hourly.temperature_2m.get(i)?,
weathercode,
condition: WeatherCondition::from_code(weathercode),
precipitation_probability: *data.hourly.precipitation_probability.get(i)?,
precipitation: *data.hourly.precipitation.get(i)?,
windspeed: *data.hourly.windspeed_10m.get(i)?,
wind_gusts: *data.hourly.wind_gusts_10m.get(i)?,
})
})
.collect();
let forecast: Vec<_> = data
.daily
.time
.iter()
.enumerate()
.filter_map(|(i, date)| {
let weathercode = *data.daily.weathercode.get(i)?;
Some(DailyForecast {
date: date.clone(),
temp_max: *data.daily.temperature_2m_max.get(i)?,
temp_min: *data.daily.temperature_2m_min.get(i)?,
weathercode,
condition: WeatherCondition::from_code(weathercode),
sunrise: data.daily.sunrise.get(i)?.clone(),
sunset: data.daily.sunset.get(i)?.clone(),
})
})
.collect();
Ok(WeatherData {
current: CurrentWeather {
temperature: current_temperature,
weathercode: data.current.weathercode,
condition: WeatherCondition::from_code(data.current.weathercode),
windspeed: data.current.windspeed_10m,
humidity: data.current.relative_humidity_2m,
feels_like: data.current.apparent_temperature,
wind_direction: data.current.wind_direction_10m,
compass_direction: CompassDirection::from_degrees(data.current.wind_direction_10m),
wind_gusts: data.current.wind_gusts_10m,
uv_index: data.current.uv_index,
visibility: data.current.visibility,
pressure: data.current.surface_pressure,
cloud_cover: data.current.cloud_cover,
dew_point: data.current.dewpoint_2m,
},
hourly,
forecast,
utc_offset_seconds: data.utc_offset_seconds,
})
}
/// Open-Meteo API response structure.
#[derive(Debug, Deserialize)]
struct OpenMeteoResponse {
/// Seconds east of UTC for the location, returned because we request
/// `timezone=auto`. Defaults to 0 (UTC) if the field is ever absent.
#[serde(default)]
utc_offset_seconds: i32,
current: CurrentData,
hourly: HourlyData,
daily: DailyData,
}
#[derive(Debug, Deserialize)]
struct CurrentData {
temperature_2m: f32,
weathercode: i32,
windspeed_10m: f32,
relative_humidity_2m: i32,
apparent_temperature: f32,
wind_direction_10m: i32,
wind_gusts_10m: f32,
uv_index: f32,
visibility: f32,
surface_pressure: f32,
cloud_cover: i32,
dewpoint_2m: f32,
}
#[derive(Debug, Deserialize)]
struct HourlyData {
time: Vec<String>,
temperature_2m: Vec<f32>,
weathercode: Vec<i32>,
precipitation_probability: Vec<i32>,
precipitation: Vec<f32>,
windspeed_10m: Vec<f32>,
wind_gusts_10m: Vec<f32>,
}
#[derive(Debug, Deserialize)]
struct DailyData {
time: Vec<String>,
temperature_2m_max: Vec<f32>,
temperature_2m_min: Vec<f32>,
weathercode: Vec<i32>,
sunrise: Vec<String>,
sunset: Vec<String>,
}