use core::{
fmt::{Display, Formatter},
mem::MaybeUninit,
};
use supernovas_ffi::{make_itrf_site, novas_on_surface};
use super::Weather;
use crate::{
Angle, Coordinate,
error::{Error, Result},
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Site {
latitude: Angle,
longitude: Angle,
height: Coordinate,
weather: Weather,
}
impl Site {
#[must_use]
pub fn new(latitude: Angle, longitude: Angle, height: Coordinate) -> Self {
Site {
latitude,
longitude,
height,
weather: Weather::default(),
}
}
pub fn from_degrees(latitude_deg: f64, longitude_deg: f64, height_m: f64) -> Result<Self> {
if latitude_deg.is_finite() && !(-90.0..=90.0).contains(&latitude_deg) {
return Err(Error::OutOfRange("geodetic latitude"));
}
Ok(Site::new(
Angle::from_degrees(latitude_deg)?,
Angle::from_degrees(longitude_deg)?,
Coordinate::from_meters(height_m)?,
))
}
#[must_use]
pub fn with_weather(mut self, weather: Weather) -> Self {
self.weather = weather;
self
}
#[must_use]
pub fn latitude(self) -> Angle {
self.latitude
}
#[must_use]
pub fn longitude(self) -> Angle {
self.longitude
}
#[must_use]
pub fn height(self) -> Coordinate {
self.height
}
#[must_use]
pub fn weather(self) -> Weather {
self.weather
}
pub(crate) fn as_on_surface(self) -> Result<novas_on_surface> {
if let Some(t) = self.weather.temperature()
&& !(-120.0..=70.0).contains(&t.celsius())
{
return Err(Error::OutOfRange("ambient temperature"));
}
if let Some(p) = self.weather.pressure()
&& !(0.0..=1200.0).contains(&p.mbar())
{
return Err(Error::OutOfRange("atmospheric pressure"));
}
if let Some(h) = self.weather.humidity_percent()
&& !(0.0..=100.0).contains(&h)
{
return Err(Error::OutOfRange("relative humidity"));
}
let mut loc = MaybeUninit::<novas_on_surface>::zeroed();
let rc = unsafe {
make_itrf_site(
self.latitude.deg(),
self.longitude.deg(),
self.height.m(),
loc.as_mut_ptr(),
)
};
if rc != 0 {
return Err(Error::ffi(rc));
}
let mut loc = unsafe { loc.assume_init() };
if let Some(t) = self.weather.temperature() {
loc.temperature = t.celsius();
}
if let Some(p) = self.weather.pressure() {
loc.pressure = p.mbar();
}
if let Some(h) = self.weather.humidity_percent() {
loc.humidity = h;
}
Ok(loc)
}
}
impl Display for Site {
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
write!(
f,
"lat={} lon={} h={}",
self.latitude, self.longitude, self.height
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_degrees_round_trip() {
let s = Site::from_degrees(34.0, -118.0, 100.0).unwrap();
assert!((s.latitude().deg() - 34.0).abs() < 1e-12);
assert!((s.longitude().deg() - -118.0).abs() < 1e-12);
assert!((s.height().m() - 100.0).abs() < 1e-12);
}
#[test]
fn rejects_out_of_range_latitude() {
assert!(matches!(
Site::from_degrees(200.0, 10.0, 0.0),
Err(Error::OutOfRange("geodetic latitude"))
));
assert!(matches!(
Site::from_degrees(95.0, 10.0, 0.0),
Err(Error::OutOfRange(_))
));
assert!(Site::from_degrees(90.0, 0.0, 0.0).is_ok());
assert!(Site::from_degrees(-90.0, 0.0, 0.0).is_ok());
}
#[test]
fn longitude_is_unrestricted() {
assert!(Site::from_degrees(0.0, 350.0, 0.0).is_ok());
assert!(Site::from_degrees(0.0, -200.0, 0.0).is_ok());
}
#[test]
fn weather_defaults_to_empty() {
let s = Site::from_degrees(0.0, 0.0, 0.0).unwrap();
assert!(s.weather().temperature().is_none());
}
#[test]
fn with_weather_attaches_it() {
let s = Site::from_degrees(34.0, -118.0, 100.0)
.unwrap()
.with_weather(Weather::standard());
assert!((s.weather().temperature().unwrap().celsius() - 15.0).abs() < 1e-12);
}
#[test]
fn as_on_surface_fills_missing_weather_with_location_defaults() {
let s = Site::from_degrees(34.0, -118.0, 100.0).unwrap();
let raw = s.as_on_surface().unwrap();
assert!((raw.latitude - 34.0).abs() < 1e-12);
assert!((raw.longitude - -118.0).abs() < 1e-12);
assert!((raw.height - 100.0).abs() < 1e-12);
assert!((-120.0..=70.0).contains(&raw.temperature));
assert!((0.0..=1200.0).contains(&raw.pressure));
assert!((0.0..=100.0).contains(&raw.humidity));
}
#[test]
fn as_on_surface_includes_weather_when_set() {
let s = Site::from_degrees(0.0, 0.0, 0.0)
.unwrap()
.with_weather(Weather::standard());
let raw = s.as_on_surface().unwrap();
assert!((raw.temperature - 15.0).abs() < 1e-12);
assert!((raw.pressure - 1013.25).abs() < 1e-12);
assert!((raw.humidity - 50.0).abs() < 1e-12);
}
#[test]
fn as_on_surface_keeps_defaults_for_partially_set_weather() {
let w = Weather::new(
Some(crate::Temperature::from_celsius(-5.0).unwrap()),
None,
None,
)
.unwrap();
let s = Site::from_degrees(34.0, -118.0, 100.0)
.unwrap()
.with_weather(w);
let raw = s.as_on_surface().unwrap();
assert!((raw.temperature - -5.0).abs() < 1e-12);
assert!((0.0..=1200.0).contains(&raw.pressure));
assert!((0.0..=100.0).contains(&raw.humidity));
}
#[test]
fn as_on_surface_rejects_implausible_weather() {
let hot = Weather::new(
Some(crate::Temperature::from_celsius(200.0).unwrap()),
None,
None,
)
.unwrap();
let s = Site::from_degrees(0.0, 0.0, 0.0).unwrap().with_weather(hot);
assert!(matches!(
s.as_on_surface(),
Err(Error::OutOfRange("ambient temperature"))
));
let soaked = Weather::new(None, None, Some(150.0)).unwrap();
let s = Site::from_degrees(0.0, 0.0, 0.0)
.unwrap()
.with_weather(soaked);
assert!(matches!(
s.as_on_surface(),
Err(Error::OutOfRange("relative humidity"))
));
}
}