#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::many_single_char_names,
clippy::similar_names,
clippy::unreadable_literal,
clippy::expect_used,
clippy::module_name_repetitions
)]
use core::f64::consts::PI;
use std::collections::BTreeSet;
use std::sync::OnceLock;
const GPT3_GRID_TEXT: &str = include_str!("../assets/gpt3_5.grd");
const DEG_TO_MAS: f64 = 3_600_000.0;
const G0_STANDARD_GRAVITY: f64 = 9.80665;
const DRY_AIR_MOLAR_MASS: f64 = 28.965e-3;
const UNIVERSAL_GAS_CONSTANT: f64 = 8.3143;
const VIRTUAL_TEMPERATURE_COEFF: f64 = 0.6077;
const HECTO_PASCAL_TO_PASCAL: f64 = 100.0;
const MISSING_COLUMN: &str = "gpt3 grid: missing expected column in header";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Gpt3Result {
pub ah: f64,
pub aw: f64,
pub pressure: f64,
pub temperature: f64,
pub water_vapor_pressure: f64,
pub tm: f64,
pub lambda: f64,
pub gnh: f64,
pub geh: f64,
pub gnw: f64,
pub gew: f64,
}
#[must_use]
pub fn gpt3(latitude: f64, longitude: f64, day_of_year: f64, height: f64) -> Gpt3Result {
grid().evaluate(latitude, longitude, day_of_year, height)
}
#[derive(Clone, Copy)]
struct SeasonalModel {
a0: f64,
a1: f64,
b1: f64,
a2: f64,
b2: f64,
}
impl SeasonalModel {
#[inline]
fn evaluate(self, h: Harmonics) -> f64 {
self.a0 + self.a1 * h.cos1 + self.b1 * h.sin1 + self.a2 * h.cos2 + self.b2 * h.sin2
}
}
#[derive(Clone, Copy)]
struct Harmonics {
cos1: f64,
sin1: f64,
cos2: f64,
sin2: f64,
}
impl Harmonics {
fn from_day_of_year(day_of_year: f64) -> Self {
let coef = (day_of_year / 365.25) * 2.0 * PI;
Self {
cos1: coef.cos(),
sin1: coef.sin(),
cos2: (2.0 * coef).cos(),
sin2: (2.0 * coef).sin(),
}
}
}
#[derive(Clone)]
struct GridEntry {
latitude: f64,
longitude: f64,
undulation: f64,
hs: f64,
pressure: SeasonalModel,
temperature: SeasonalModel,
qv: SeasonalModel,
dt: SeasonalModel,
ah: SeasonalModel,
aw: SeasonalModel,
lambda: SeasonalModel,
tm: SeasonalModel,
gn_h: SeasonalModel,
ge_h: SeasonalModel,
gn_w: SeasonalModel,
ge_w: SeasonalModel,
}
#[derive(Clone, Copy)]
struct CornerEval {
ah: f64,
aw: f64,
temperature: f64,
pressure: f64,
water_vapor: f64,
tm: f64,
lambda: f64,
gn_h: f64,
ge_h: f64,
gn_w: f64,
ge_w: f64,
}
impl GridEntry {
fn evaluate(&self, h: Harmonics, height: f64) -> CornerEval {
let corrected_height = height - self.undulation - self.hs;
let t0 = self.temperature.evaluate(h);
let qv = self.qv.evaluate(h) * 0.001;
let tv = t0 * (1.0 + VIRTUAL_TEMPERATURE_COEFF * qv);
let factor = -corrected_height * G0_STANDARD_GRAVITY * DRY_AIR_MOLAR_MASS
/ (UNIVERSAL_GAS_CONSTANT * tv);
let dtdh = self.dt.evaluate(h) * 0.001;
let temperature = t0 + dtdh * corrected_height;
let p0 = self.pressure.evaluate(h) * 0.01;
let pressure = p0 * factor.exp();
let e0 = qv * p0 / (0.622 + 0.378 * qv);
let lambda = self.lambda.evaluate(h);
let water_vapor = e0 * (factor * (1.0 + lambda)).exp();
CornerEval {
ah: self.ah.evaluate(h),
aw: self.aw.evaluate(h),
temperature,
pressure,
water_vapor,
tm: self.tm.evaluate(h),
lambda,
gn_h: self.gn_h.evaluate(h),
ge_h: self.ge_h.evaluate(h),
gn_w: self.gn_w.evaluate(h),
ge_w: self.ge_w.evaluate(h),
}
}
}
struct Gpt3Grid {
lat_keys: Vec<i64>,
lon_keys: Vec<i64>,
lon0: f64,
entries: Vec<Vec<GridEntry>>,
}
impl Gpt3Grid {
fn south_index(&self, latitude: f64) -> usize {
let lat_key = (latitude.to_degrees() * DEG_TO_MAS).round() as i64;
let count = self.lat_keys.partition_point(|&k| k <= lat_key);
count.saturating_sub(1).min(self.lat_keys.len() - 2)
}
fn west_index(&self, longitude: f64) -> usize {
let lon_key = (longitude.to_degrees() * DEG_TO_MAS).round() as i64;
let count = self.lon_keys.partition_point(|&k| k <= lon_key);
count.saturating_sub(1).min(self.entries[0].len() - 2)
}
fn normalize_longitude(&self, longitude: f64) -> f64 {
let two_pi = 2.0 * PI;
longitude - two_pi * ((longitude - self.lon0) / two_pi).floor()
}
fn evaluate(&self, latitude: f64, longitude: f64, day_of_year: f64, height: f64) -> Gpt3Result {
let h = Harmonics::from_day_of_year(day_of_year);
let norm_lon = self.normalize_longitude(longitude);
let si = self.south_index(latitude);
let wi = self.west_index(norm_lon);
let sw_entry = &self.entries[si][wi];
let se_entry = &self.entries[si][wi + 1];
let nw_entry = &self.entries[si + 1][wi];
let ne_entry = &self.entries[si + 1][wi + 1];
let sw = sw_entry.evaluate(h, height);
let se = se_entry.evaluate(h, height);
let nw = nw_entry.evaluate(h, height);
let ne = ne_entry.evaluate(h, height);
let t = (norm_lon - sw_entry.longitude) / (se_entry.longitude - sw_entry.longitude);
let u = (latitude - sw_entry.latitude) / (nw_entry.latitude - sw_entry.latitude);
let bilinear = |f_sw: f64, f_se: f64, f_nw: f64, f_ne: f64| {
(1.0 - t) * (1.0 - u) * f_sw
+ (1.0 - t) * u * f_nw
+ t * (1.0 - u) * f_se
+ t * u * f_ne
};
Gpt3Result {
ah: bilinear(sw.ah, se.ah, nw.ah, ne.ah) * 0.001,
aw: bilinear(sw.aw, se.aw, nw.aw, ne.aw) * 0.001,
pressure: bilinear(sw.pressure, se.pressure, nw.pressure, ne.pressure)
* HECTO_PASCAL_TO_PASCAL,
temperature: bilinear(
sw.temperature,
se.temperature,
nw.temperature,
ne.temperature,
),
water_vapor_pressure: bilinear(
sw.water_vapor,
se.water_vapor,
nw.water_vapor,
ne.water_vapor,
) * HECTO_PASCAL_TO_PASCAL,
tm: bilinear(sw.tm, se.tm, nw.tm, ne.tm),
lambda: bilinear(sw.lambda, se.lambda, nw.lambda, ne.lambda),
gnh: bilinear(sw.gn_h, se.gn_h, nw.gn_h, ne.gn_h) * 0.00001,
geh: bilinear(sw.ge_h, se.ge_h, nw.ge_h, ne.ge_h) * 0.00001,
gnw: bilinear(sw.gn_w, se.gn_w, nw.gn_w, ne.gn_w) * 0.00001,
gew: bilinear(sw.ge_w, se.ge_w, nw.ge_w, ne.ge_w) * 0.00001,
}
}
}
struct ColumnLayout {
lat: usize,
lon: usize,
undu: usize,
hs: usize,
pressure: usize,
temperature: usize,
qv: usize,
dt: usize,
ah: usize,
aw: usize,
lambda: usize,
tm: usize,
gn_h: usize,
ge_h: usize,
gn_w: usize,
ge_w: usize,
}
fn parse_f64(s: &str) -> f64 {
s.parse::<f64>()
.expect("gpt3 grid: unparsable number in embedded asset")
}
fn read_model(fields: &[&str], first: usize) -> SeasonalModel {
SeasonalModel {
a0: parse_f64(fields[first]),
a1: parse_f64(fields[first + 1]),
b1: parse_f64(fields[first + 2]),
a2: parse_f64(fields[first + 3]),
b2: parse_f64(fields[first + 4]),
}
}
fn parse_header(header: &str) -> ColumnLayout {
let stripped = header.trim().strip_prefix('%').unwrap_or(header);
let mut lat = None;
let mut lon = None;
let mut undu = None;
let mut hs = None;
let mut pressure = None;
let mut temperature = None;
let mut qv = None;
let mut dt = None;
let mut ah = None;
let mut aw = None;
let mut lambda = None;
let mut tm = None;
let mut gn_h = None;
let mut ge_h = None;
let mut gn_w = None;
let mut ge_w = None;
for (i, token) in stripped.split_whitespace().enumerate() {
match token {
"lat" => lat = Some(i),
"lon" => lon = Some(i),
"undu" => undu = Some(i),
"Hs" => hs = Some(i),
"p:a0" => pressure = Some(i),
"T:a0" => temperature = Some(i),
"Q:a0" => qv = Some(i),
"dT:a0" => dt = Some(i),
"a_h:a0" => ah = Some(i),
"a_w:a0" => aw = Some(i),
"lambda:a0" => lambda = Some(i),
"Tm:a0" => tm = Some(i),
"Gn_h:a0" => gn_h = Some(i),
"Ge_h:a0" => ge_h = Some(i),
"Gn_w:a0" => gn_w = Some(i),
"Ge_w:a0" => ge_w = Some(i),
_ => {}
}
}
ColumnLayout {
lat: lat.expect(MISSING_COLUMN),
lon: lon.expect(MISSING_COLUMN),
undu: undu.expect(MISSING_COLUMN),
hs: hs.expect(MISSING_COLUMN),
pressure: pressure.expect(MISSING_COLUMN),
temperature: temperature.expect(MISSING_COLUMN),
qv: qv.expect(MISSING_COLUMN),
dt: dt.expect(MISSING_COLUMN),
ah: ah.expect(MISSING_COLUMN),
aw: aw.expect(MISSING_COLUMN),
lambda: lambda.expect(MISSING_COLUMN),
tm: tm.expect(MISSING_COLUMN),
gn_h: gn_h.expect(MISSING_COLUMN),
ge_h: ge_h.expect(MISSING_COLUMN),
gn_w: gn_w.expect(MISSING_COLUMN),
ge_w: ge_w.expect(MISSING_COLUMN),
}
}
fn parse_grid(text: &str) -> Gpt3Grid {
let mut lines = text.lines();
let header = lines.next().expect("gpt3 grid: empty embedded asset");
let cols = parse_header(header);
let mut lat_set: BTreeSet<i64> = BTreeSet::new();
let mut lon_set: BTreeSet<i64> = BTreeSet::new();
let mut raw: Vec<(i64, i64, GridEntry)> = Vec::new();
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
let f: Vec<&str> = line.split_whitespace().collect();
let lat_deg = parse_f64(f[cols.lat]);
let lon_deg = parse_f64(f[cols.lon]);
let lat_key = (lat_deg * DEG_TO_MAS).round() as i64;
let lon_key = (lon_deg * DEG_TO_MAS).round() as i64;
let entry = GridEntry {
latitude: lat_deg.to_radians(),
longitude: lon_deg.to_radians(),
undulation: parse_f64(f[cols.undu]),
hs: parse_f64(f[cols.hs]),
pressure: read_model(&f, cols.pressure),
temperature: read_model(&f, cols.temperature),
qv: read_model(&f, cols.qv),
dt: read_model(&f, cols.dt),
ah: read_model(&f, cols.ah),
aw: read_model(&f, cols.aw),
lambda: read_model(&f, cols.lambda),
tm: read_model(&f, cols.tm),
gn_h: read_model(&f, cols.gn_h),
ge_h: read_model(&f, cols.ge_h),
gn_w: read_model(&f, cols.gn_w),
ge_w: read_model(&f, cols.ge_w),
};
lat_set.insert(lat_key);
lon_set.insert(lon_key);
raw.push((lat_key, lon_key, entry));
}
let lat_keys: Vec<i64> = lat_set.into_iter().collect();
let lon_keys: Vec<i64> = lon_set.into_iter().collect();
let n_lat = lat_keys.len();
let n_real = lon_keys.len();
let n_lon = n_real + 1;
let mut entries: Vec<Vec<Option<GridEntry>>> = vec![vec![None; n_lon]; n_lat];
for (lat_key, lon_key, entry) in raw {
let i = lat_keys.partition_point(|&k| k < lat_key);
let j = lon_keys.partition_point(|&k| k < lon_key);
entries[i][j] = Some(entry);
}
for row in &mut entries {
let mut wrapped = row[0]
.clone()
.expect("gpt3 grid: missing westmost column while wrapping");
wrapped.longitude += 2.0 * PI;
row[n_real] = Some(wrapped);
}
let entries: Vec<Vec<GridEntry>> = entries
.into_iter()
.map(|row| {
row.into_iter()
.map(|e| e.expect("gpt3 grid: incomplete grid, a node is missing"))
.collect()
})
.collect();
let lon0 = entries[0][0].longitude;
Gpt3Grid {
lat_keys,
lon_keys,
lon0,
entries,
}
}
fn grid() -> &'static Gpt3Grid {
static GRID: OnceLock<Gpt3Grid> = OnceLock::new();
GRID.get_or_init(|| parse_grid(GPT3_GRID_TEXT))
}
#[cfg(test)]
mod tests {
use super::*;
const DOY_CASE1: f64 = 18_576_000.0 / 86_400.0;
const DOY_CASE2: f64 = 7_948_800.0 / 86_400.0;
const TOL: f64 = 1.0e-9;
fn close(actual: f64, expected: f64) -> bool {
(actual - expected).abs() < TOL
}
#[test]
fn day_of_year_constants_are_the_expected_whole_days() {
assert!(close(DOY_CASE1, 215.0));
assert!(close(DOY_CASE2, 92.0));
}
#[test]
fn matches_orekit_matlab_ref_case1() {
let r = gpt3(
48.20_f64.to_radians(),
16.37_f64.to_radians(),
DOY_CASE1,
156.0,
);
assert!(close(r.ah, 0.001258154), "ah = {}", r.ah);
assert!(close(r.aw, 0.000568212), "aw = {}", r.aw);
assert!(
close(r.temperature, 273.15 + 22.060239452),
"temperature = {}",
r.temperature
);
assert!(
close(r.pressure / HECTO_PASCAL_TO_PASCAL, 1002.577297099),
"pressure hPa = {}",
r.pressure / HECTO_PASCAL_TO_PASCAL
);
assert!(
close(
r.water_vapor_pressure / HECTO_PASCAL_TO_PASCAL,
16.676583415
),
"water vapor hPa = {}",
r.water_vapor_pressure / HECTO_PASCAL_TO_PASCAL
);
assert!(close(r.tm, 281.064766962), "tm = {}", r.tm);
assert!(close(r.lambda, 2.697732327), "lambda = {}", r.lambda);
assert!(close(r.gnh, -0.000177344), "gnh = {}", r.gnh);
assert!(close(r.geh, -0.000048308), "geh = {}", r.geh);
assert!(close(r.gnw, -0.000085318), "gnw = {}", r.gnw);
assert!(close(r.gew, -0.000035866), "gew = {}", r.gew);
}
#[test]
fn matches_orekit_matlab_ref_case2() {
let r = gpt3(
77.5_f64.to_radians(),
137.5_f64.to_radians(),
DOY_CASE2,
0.0,
);
assert!(close(r.ah, 0.001171577), "ah = {}", r.ah);
assert!(close(r.aw, 0.000574057), "aw = {}", r.aw);
assert!(
close(r.temperature, 273.15 - 18.589601195),
"temperature = {}",
r.temperature
);
assert!(
close(r.pressure / HECTO_PASCAL_TO_PASCAL, 1019.900868185),
"pressure hPa = {}",
r.pressure / HECTO_PASCAL_TO_PASCAL
);
assert!(
close(r.water_vapor_pressure / HECTO_PASCAL_TO_PASCAL, 1.251369020),
"water vapor hPa = {}",
r.water_vapor_pressure / HECTO_PASCAL_TO_PASCAL
);
assert!(close(r.tm, 250.211896532), "tm = {}", r.tm);
assert!(close(r.lambda, 1.813332081), "lambda = {}", r.lambda);
assert!(close(r.gnh, 0.000004818), "gnh = {}", r.gnh);
assert!(close(r.geh, 0.000154168), "geh = {}", r.geh);
assert!(close(r.gnw, 0.000001018), "gnw = {}", r.gnw);
assert!(close(r.gew, 0.000003392), "gew = {}", r.gew);
}
fn fields(r: &Gpt3Result) -> [f64; 11] {
[
r.ah,
r.aw,
r.pressure,
r.temperature,
r.water_vapor_pressure,
r.tm,
r.lambda,
r.gnh,
r.geh,
r.gnw,
r.gew,
]
}
fn assert_close(got: &Gpt3Result, want: &Gpt3Result, rel: f64, what: &str) {
for (k, (g, w)) in fields(got).into_iter().zip(fields(want)).enumerate() {
assert!(
(g - w).abs() <= rel * w.abs().max(1e-12),
"{what}: field {k} is {g}, expected {w}",
);
}
}
#[test]
fn north_of_the_last_grid_row_extrapolates_from_the_last_full_cell() {
let lon = 16.37_f64.to_radians();
let at = |lat_deg: f64| gpt3(lat_deg.to_radians(), lon, DOY_CASE1, 156.0);
let (south, north, beyond) = (at(82.5), at(87.5), at(89.0));
for (k, ((s, n), b)) in fields(&south)
.into_iter()
.zip(fields(&north))
.zip(fields(&beyond))
.enumerate()
{
let want = s + 1.3 * (n - s);
assert!(
(b - want).abs() <= 1e-12 * want.abs().max(1e-12),
"field {k}: {b} at 89°, linear continuation says {want}",
);
}
}
#[test]
fn a_longitude_outside_the_grid_span_folds_back_by_a_full_turn() {
let lat = 48.2_f64.to_radians();
let at = |lon_deg: f64| gpt3(lat, lon_deg.to_radians(), DOY_CASE1, 156.0);
assert_close(&at(200.0), &at(-160.0), 1e-12, "200° vs −160°");
assert_close(&at(-200.0), &at(160.0), 1e-12, "−200° vs 160°");
}
#[test]
fn the_seam_between_the_last_column_and_the_wrap_is_the_mean_of_its_two_nodes() {
let lat = 48.2_f64.to_radians();
let at = |lon_deg: f64| gpt3(lat, lon_deg.to_radians(), DOY_CASE1, 156.0);
let (east, west, seam) = (at(177.5), at(-177.5), at(180.0));
for (k, ((e, w), s)) in fields(&east)
.into_iter()
.zip(fields(&west))
.zip(fields(&seam))
.enumerate()
{
let want = f64::midpoint(e, w);
assert!(
(s - want).abs() <= 1e-12 * want.abs().max(1e-12),
"field {k}: {s} at the seam, the mean of its nodes is {want}",
);
}
}
#[test]
fn feeds_the_vmf3_mapping_function() {
let latitude = 48.20_f64.to_radians();
let longitude = 16.37_f64.to_radians();
let r = gpt3(latitude, longitude, DOY_CASE1, 156.0);
let (mh, mw) = crate::vmf3_mapping(
r.ah,
r.aw,
latitude,
longitude,
156.0,
DOY_CASE1,
20.0_f64.to_radians(),
);
assert!(
mh.is_finite() && mw.is_finite() && mh > 1.0 && mw > 1.0,
"mapping factors = ({mh}, {mw})"
);
}
}