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}