use crate::error::{Error, Result};
pub const WGS84_A: f64 = 6_378_137.0;
pub const WGS84_E: f64 = 0.081_819_190_842_621_5;
pub const UPS_K0: f64 = 0.994;
pub const UPS_FALSE_EASTING: f64 = 2_000_000.0;
pub const UPS_FALSE_NORTHING: f64 = 2_000_000.0;
pub const UPS_MIN_LAT_DEG: f64 = 80.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpsHemisphere {
North,
South,
}
#[derive(Debug, Clone, Copy)]
pub struct UpsCoordinate {
pub easting: f64,
pub northing: f64,
pub hemisphere: UpsHemisphere,
}
#[derive(Debug, Clone)]
pub struct PolarStereographicParams {
pub semimajor_a: f64,
pub eccentricity_e: f64,
pub scale_factor_k0: f64,
pub false_easting: f64,
pub false_northing: f64,
pub hemisphere: UpsHemisphere,
}
impl PolarStereographicParams {
pub fn wgs84_ups_north() -> Self {
Self {
semimajor_a: WGS84_A,
eccentricity_e: WGS84_E,
scale_factor_k0: UPS_K0,
false_easting: UPS_FALSE_EASTING,
false_northing: UPS_FALSE_NORTHING,
hemisphere: UpsHemisphere::North,
}
}
pub fn wgs84_ups_south() -> Self {
Self {
semimajor_a: WGS84_A,
eccentricity_e: WGS84_E,
scale_factor_k0: UPS_K0,
false_easting: UPS_FALSE_EASTING,
false_northing: UPS_FALSE_NORTHING,
hemisphere: UpsHemisphere::South,
}
}
}
pub fn polar_stereo_w(e: f64) -> f64 {
let term1 = (1.0 + e).powf(1.0 + e);
let term2 = (1.0 - e).powf(1.0 - e);
(term1 * term2).sqrt()
}
pub fn polar_stereographic_forward(
params: &PolarStereographicParams,
lon_deg: f64,
lat_deg: f64,
) -> (f64, f64) {
let a = params.semimajor_a;
let e = params.eccentricity_e;
let k0 = params.scale_factor_k0;
let fe = params.false_easting;
let fn_ = params.false_northing;
let lambda = lon_deg.to_radians();
let phi_abs = match params.hemisphere {
UpsHemisphere::North => lat_deg.to_radians(),
UpsHemisphere::South => (-lat_deg).to_radians(),
};
let sin_phi = phi_abs.sin();
let e_conformal = ((1.0 + e * sin_phi) / (1.0 - e * sin_phi)).powf(e / 2.0);
let t = (std::f64::consts::FRAC_PI_4 - phi_abs / 2.0).tan() * e_conformal;
let w = polar_stereo_w(e);
let rho = 2.0 * a * k0 * t / w;
let sin_lambda = lambda.sin();
let cos_lambda = lambda.cos();
let easting = fe + rho * sin_lambda;
let northing = match params.hemisphere {
UpsHemisphere::North => fn_ - rho * cos_lambda,
UpsHemisphere::South => fn_ + rho * cos_lambda,
};
(easting, northing)
}
pub fn polar_stereographic_inverse(
params: &PolarStereographicParams,
easting: f64,
northing: f64,
) -> (f64, f64) {
let a = params.semimajor_a;
let e = params.eccentricity_e;
let k0 = params.scale_factor_k0;
let fe = params.false_easting;
let fn_ = params.false_northing;
let de = easting - fe;
let dn = northing - fn_;
let rho = (de * de + dn * dn).sqrt();
let w = polar_stereo_w(e);
let t = rho * w / (2.0 * a * k0);
let mut phi = std::f64::consts::FRAC_PI_2 - 2.0 * t.atan();
for _ in 0..10 {
let sin_phi = phi.sin();
let factor = ((1.0 - e * sin_phi) / (1.0 + e * sin_phi)).powf(e / 2.0);
let phi_new = std::f64::consts::FRAC_PI_2 - 2.0 * (t * factor).atan();
let delta = (phi_new - phi).abs();
phi = phi_new;
if delta < 1e-12 {
break;
}
}
let (lambda, lat_rad) = match params.hemisphere {
UpsHemisphere::North => (de.atan2(-dn), phi),
UpsHemisphere::South => (de.atan2(dn), -phi),
};
(lambda.to_degrees(), lat_rad.to_degrees())
}
pub fn ups_from_geographic(lon_deg: f64, lat_deg: f64) -> Result<UpsCoordinate> {
if lat_deg.abs() < UPS_MIN_LAT_DEG {
return Err(Error::InvalidCoordinate {
reason: format!(
"UPS coverage requires |latitude| >= 80°, got ({}, {})",
lon_deg, lat_deg
),
});
}
let hemisphere = if lat_deg >= 0.0 {
UpsHemisphere::North
} else {
UpsHemisphere::South
};
let params = match hemisphere {
UpsHemisphere::North => PolarStereographicParams::wgs84_ups_north(),
UpsHemisphere::South => PolarStereographicParams::wgs84_ups_south(),
};
let (easting, northing) = polar_stereographic_forward(¶ms, lon_deg, lat_deg);
Ok(UpsCoordinate {
easting,
northing,
hemisphere,
})
}
pub fn ups_to_geographic(coord: &UpsCoordinate) -> (f64, f64) {
let params = match coord.hemisphere {
UpsHemisphere::North => PolarStereographicParams::wgs84_ups_north(),
UpsHemisphere::South => PolarStereographicParams::wgs84_ups_south(),
};
polar_stereographic_inverse(¶ms, coord.easting, coord.northing)
}
pub fn ups_zone_letter(lon_deg: f64, lat_deg: f64) -> Option<char> {
if lat_deg >= 84.0 {
Some(if lon_deg < 0.0 { 'Y' } else { 'Z' })
} else if lat_deg <= -80.0 {
Some(if lon_deg < 0.0 { 'A' } else { 'B' })
} else {
None
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_polar_stereo_w_sanity() {
let w = polar_stereo_w(WGS84_E);
assert!(
(1.003..1.004).contains(&w),
"W for WGS-84 should be ≈1.003xx, got {}",
w
);
}
#[test]
fn test_north_pole_forward() {
let params = PolarStereographicParams::wgs84_ups_north();
let (e, n) = polar_stereographic_forward(¶ms, 0.0, 90.0);
assert!((e - 2_000_000.0).abs() < 1.0, "easting at N pole: {}", e);
assert!((n - 2_000_000.0).abs() < 1.0, "northing at N pole: {}", n);
}
#[test]
fn test_south_pole_forward() {
let params = PolarStereographicParams::wgs84_ups_south();
let (e, n) = polar_stereographic_forward(¶ms, 0.0, -90.0);
assert!((e - 2_000_000.0).abs() < 1.0, "easting at S pole: {}", e);
assert!((n - 2_000_000.0).abs() < 1.0, "northing at S pole: {}", n);
}
#[test]
fn test_round_trip_north() {
let params = PolarStereographicParams::wgs84_ups_north();
let (lon0, lat0) = (45.0_f64, 85.0_f64);
let (e, n) = polar_stereographic_forward(¶ms, lon0, lat0);
let (lon1, lat1) = polar_stereographic_inverse(¶ms, e, n);
assert!(
(lon1 - lon0).abs() < 1e-9,
"longitude drift: {}",
lon1 - lon0
);
assert!(
(lat1 - lat0).abs() < 1e-9,
"latitude drift: {}",
lat1 - lat0
);
}
#[test]
fn test_round_trip_south() {
let params = PolarStereographicParams::wgs84_ups_south();
let (lon0, lat0) = (45.0_f64, -85.0_f64);
let (e, n) = polar_stereographic_forward(¶ms, lon0, lat0);
let (lon1, lat1) = polar_stereographic_inverse(¶ms, e, n);
assert!(
(lon1 - lon0).abs() < 1e-9,
"longitude drift: {}",
lon1 - lon0
);
assert!(
(lat1 - lat0).abs() < 1e-9,
"latitude drift: {}",
lat1 - lat0
);
}
#[test]
fn test_ups_from_geographic_rejects_low_lat() {
assert!(ups_from_geographic(0.0, 45.0).is_err());
assert!(ups_from_geographic(0.0, -45.0).is_err());
assert!(ups_from_geographic(0.0, 79.9).is_err());
}
#[test]
fn test_ups_from_geographic_accepts_polar() {
assert!(ups_from_geographic(0.0, 80.0).is_ok());
assert!(ups_from_geographic(0.0, -80.0).is_ok());
assert!(ups_from_geographic(0.0, 90.0).is_ok());
assert!(ups_from_geographic(0.0, -90.0).is_ok());
}
}