Skip to main content

pvlib/
location.rs

1use chrono::DateTime;
2use chrono_tz::Tz;
3
4use crate::atmosphere;
5use crate::clearsky;
6use crate::solarposition::{self, SolarPosition};
7
8/// Represents a physical location on Earth.
9///
10/// `pvlib-python` equivalently uses `Location` class with attributes:
11/// latitude, longitude, tz, altitude, and name.
12#[derive(Debug, Clone, PartialEq)]
13pub struct Location {
14    pub latitude: f64,
15    pub longitude: f64,
16    pub tz: Tz,
17    pub altitude: f64,
18    pub name: String,
19}
20
21/// Error produced by [`Location::try_new`] when a coordinate is out of range.
22#[derive(Debug, thiserror::Error, PartialEq)]
23pub enum LocationError {
24    #[error("latitude {0} is outside the valid range [-90, 90]")]
25    Latitude(f64),
26    #[error("longitude {0} is outside the valid range [-180, 180]")]
27    Longitude(f64),
28    #[error("altitude {0} is not a finite number")]
29    Altitude(f64),
30}
31
32impl Location {
33    /// Create a new Location instance.
34    ///
35    /// Inputs are **not** validated — pass garbage latitude or longitude and
36    /// you will get garbage solar-position output. Use [`Location::try_new`]
37    /// at trust boundaries (weather API input, user-supplied config) for a
38    /// validated constructor.
39    ///
40    /// # Arguments
41    ///
42    /// * `latitude` - Latitude in decimal degrees. Positive north of equator, negative to south.
43    /// * `longitude` - Longitude in decimal degrees. Positive east of prime meridian, negative to west.
44    /// * `tz` - Timezone as a `chrono_tz::Tz` enum variant.
45    /// * `altitude` - Altitude from sea level in meters.
46    /// * `name` - Name of the location.
47    pub fn new(latitude: f64, longitude: f64, tz: Tz, altitude: f64, name: &str) -> Self {
48        Self {
49            latitude,
50            longitude,
51            tz,
52            altitude,
53            name: name.to_string(),
54        }
55    }
56
57    /// Create a new Location with validated coordinates.
58    ///
59    /// Returns `Err(LocationError)` if latitude is outside `[-90, 90]`,
60    /// longitude is outside `[-180, 180]`, or altitude is non-finite.
61    pub fn try_new(
62        latitude: f64,
63        longitude: f64,
64        tz: Tz,
65        altitude: f64,
66        name: &str,
67    ) -> Result<Self, LocationError> {
68        if !(-90.0..=90.0).contains(&latitude) || !latitude.is_finite() {
69            return Err(LocationError::Latitude(latitude));
70        }
71        if !(-180.0..=180.0).contains(&longitude) || !longitude.is_finite() {
72            return Err(LocationError::Longitude(longitude));
73        }
74        if !altitude.is_finite() {
75            return Err(LocationError::Altitude(altitude));
76        }
77        Ok(Self::new(latitude, longitude, tz, altitude, name))
78    }
79
80    /// Calculate the solar position for this location at the given time.
81    ///
82    /// Convenience wrapper around `solarposition::get_solarposition`.
83    pub fn get_solarposition(&self, time: DateTime<Tz>) -> Result<SolarPosition, spa::SpaError> {
84        solarposition::get_solarposition(self, time)
85    }
86
87    /// Calculate clear sky irradiance for this location at the given time.
88    ///
89    /// # Arguments
90    /// * `time` - Date and time with timezone.
91    /// * `model` - Clear sky model: "ineichen", "haurwitz", or "simplified_solis".
92    ///
93    /// # Returns
94    /// GHI, DNI, DHI in W/m^2. Returns zeros if the sun is below the horizon.
95    pub fn get_clearsky(&self, time: DateTime<Tz>, model: &str) -> clearsky::ClearSkyIrradiance {
96        let solar_pos = match self.get_solarposition(time) {
97            Ok(sp) => sp,
98            Err(_) => return clearsky::ClearSkyIrradiance { ghi: 0.0, dni: 0.0, dhi: 0.0 },
99        };
100
101        match model {
102            "haurwitz" => {
103                let ghi = clearsky::haurwitz(solar_pos.zenith);
104                clearsky::ClearSkyIrradiance { ghi, dni: 0.0, dhi: 0.0 }
105            }
106            "simplified_solis" => {
107                let apparent_elevation = 90.0 - solar_pos.zenith;
108                clearsky::simplified_solis(apparent_elevation, 0.1, 1.0, atmosphere::alt2pres(self.altitude))
109            }
110            _ => {
111                // Default to ineichen
112                let (_am_rel, am_abs) = self.get_airmass(time);
113                if am_abs.is_nan() || am_abs <= 0.0 {
114                    return clearsky::ClearSkyIrradiance { ghi: 0.0, dni: 0.0, dhi: 0.0 };
115                }
116                let month = {
117                    use chrono::Datelike;
118                    time.month()
119                };
120                let linke_turbidity = clearsky::lookup_linke_turbidity(self.latitude, self.longitude, month);
121                clearsky::ineichen(solar_pos.zenith, am_abs, linke_turbidity, self.altitude, 1364.0)
122            }
123        }
124    }
125
126    /// Calculate relative and absolute airmass for this location at the given time.
127    ///
128    /// Uses Kasten-Young model for relative airmass and site pressure derived
129    /// from altitude for absolute airmass.
130    ///
131    /// # Returns
132    /// `(airmass_relative, airmass_absolute)`. Values may be NaN if the sun is
133    /// below the horizon.
134    pub fn get_airmass(&self, time: DateTime<Tz>) -> (f64, f64) {
135        let solar_pos = match self.get_solarposition(time) {
136            Ok(sp) => sp,
137            Err(_) => return (f64::NAN, f64::NAN),
138        };
139
140        let am_rel = atmosphere::get_relative_airmass(solar_pos.zenith);
141        let pressure = atmosphere::alt2pres(self.altitude);
142        let am_abs = atmosphere::get_absolute_airmass(am_rel, pressure);
143        (am_rel, am_abs)
144    }
145}
146
147/// Lookup altitude for a given latitude and longitude.
148///
149/// This is a simplified approximation. Most populated areas are near sea level,
150/// so this returns 0.0 as a default. For accurate altitude data, use SRTM or
151/// similar elevation datasets.
152///
153/// # Arguments
154/// * `_latitude` - Latitude in decimal degrees.
155/// * `_longitude` - Longitude in decimal degrees.
156///
157/// # Returns
158/// Estimated altitude in meters above sea level.
159pub fn lookup_altitude(_latitude: f64, _longitude: f64) -> f64 {
160    // A proper implementation would query SRTM or similar elevation data.
161    // For now, return 0.0 (sea level) as a safe default.
162    0.0
163}