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