Skip to main content

hpr_net/
elevation.rs

1//! A launch site's elevation (height above sea level) from Open-Meteo's elevation API.
2//!
3//! [Open-Meteo's elevation API](https://open-meteo.com/en/docs/elevation-api)
4//! (`api.open-meteo.com/v1/elevation`) answers the height at up to [`MAX_PLACES`] places in one
5//! request, as `{"elevation":[1400.0]}`, one number per place in the order asked. Its data is the
6//! Copernicus DEM GLO-90 (2021 release), a digital elevation model on a grid 3 arc-seconds apart
7//! in latitude, about 93 m; in longitude its spacing narrows from 93 m at the equator to 60 m at
8//! 50°, then widens in steps (the [product handbook][handbook], issue 5.0, Table 3, p. 15). It is
9//! a *surface* model: its heights include buildings and vegetation, so over a tree line or
10//! buildings they sit above the bare ground. Its heights are above the EGM2008 geoid (mean sea
11//! level), not the WGS 84 ellipsoid (§1.2.1, p. 13): a site's ellipsoidal height is `h = H + N`,
12//! with `N` the geoid undulation there, which hpr has no model for ([geodesy notes][geodesy]).
13//! The ocean has no tiles and reads 0 m.
14//!
15//! An [`ElevationRequest`] names the places; [`parse`] reads the answer, refusing one with the
16//! wrong number of heights, a height that is not a number, or a height outside
17//! [`HEIGHT_RANGE_M`]. [`fetch`] asks a [`Client`] for the URL, so the answer comes from the cache
18//! when it can, and offline from the cache only; an answer that doesn't parse is never cached.
19//! The cache key is the whole request: the same places, in the same order. The URL writes each
20//! coordinate to 5 decimals (about 1 m), so a place given to 8 decimals or fewer and rebuilt from
21//! radians finds its cached answer. The ground doesn't move, so a copy stays fresh for [`TTL_S`],
22//! a year. Show [`ATTRIBUTION`] (it is on every [`Fetched`]) wherever the height is shown.
23//!
24//! **How far to trust it:** a height is the answer's number, unchanged (`tests/elevation.rs`).
25//! The recorded heights are whole meters; Open-Meteo doesn't document its rounding. The handbook
26//! states the DEM's absolute vertical accuracy as under 4 m (90% linear error), a global mean
27//! outside Antarctica and Greenland (Table 1, p. 10); in 184 of the 16,363 geotiles there, each
28//! about a degree across (1.1%; the table's 0.9% is of all tiles), it is over 10 m (Table 12,
29//! p. 31). Nothing here measures it. The [guide page][guide]
30//! says more.
31//!
32//! ```
33//! use hpr_net::elevation::{self, Place};
34//!
35//! // An answer recorded for Spaceport America's launch area, 32.99° N, 106.97° W.
36//! let body = include_bytes!("../tests/fixtures/replay/open-meteo-elevation.json");
37//! let spaceport = Place::new(32.99, -106.97);
38//! let heights = elevation::parse(body, &[spaceport])?;
39//! assert_eq!((heights[0].place, heights[0].height_msl_m), (spaceport, 1400.0));
40//! # Ok::<(), Box<dyn std::error::Error>>(())
41//! ```
42//!
43//! [handbook]: https://dataspace.copernicus.eu/sites/default/files/media/files/2024-06/geo1988-copernicusdem-spe-002_producthandbook_i5.0.pdf
44//! [geodesy]: https://nrdptel.github.io/hpr-sim/physics/geodesy.html
45//! [guide]: https://nrdptel.github.io/hpr-sim/elevation.html
46
47use serde::{Deserialize, Serialize};
48use serde_json::Value;
49
50use crate::coordinate::coordinate;
51use crate::{Client, Fetched, NetError, Source, Transport};
52
53/// Open-Meteo's elevation endpoint.
54pub const ENDPOINT: &str = "https://api.open-meteo.com/v1/elevation";
55
56/// The most places Open-Meteo answers in one request.
57pub const MAX_PLACES: usize = 100;
58
59/// How long a cached answer counts as fresh, s: a year. The model behind it changes with a new
60/// DEM release, years apart.
61pub const TTL_S: u64 = 365 * 86_400;
62
63/// The heights [`parse`] accepts, m above mean sea level. The lowest land, by the Dead Sea, lies a
64/// little over 400 m below sea level, and the highest, Everest's summit, 8,849 m above it; the
65/// margins leave room for the DEM's own errors. A height outside them is a broken answer, such as
66/// a 16-bit no-data value.
67pub const HEIGHT_RANGE_M: std::ops::RangeInclusive<f64> = -1_000.0..=9_000.0;
68
69/// The credit Open-Meteo's license (CC BY 4.0) asks for: itself, and the Copernicus programme
70/// whose DEM it serves, in the DEM license's words.
71pub const ATTRIBUTION: &str = "Elevation data by Open-Meteo.com (CC BY 4.0), from the Copernicus \
72                               DEM GLO-90: © DLR e.V. 2010-2014 and © Airbus Defence and Space \
73                               GmbH 2014-2018 provided under COPERNICUS by the European Union and \
74                               ESA; all rights reserved";
75
76/// A place on the ground.
77#[non_exhaustive]
78#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
79pub struct Place {
80    /// Latitude, degrees north, in `[-90, 90]`.
81    pub latitude_deg: f64,
82    /// Longitude, degrees east, in `[-180, 180]`.
83    pub longitude_deg: f64,
84}
85
86impl Place {
87    /// A place from its latitude and longitude, degrees.
88    #[must_use]
89    pub fn new(latitude_deg: f64, longitude_deg: f64) -> Self {
90        Self {
91            latitude_deg,
92            longitude_deg,
93        }
94    }
95}
96
97/// A place's height, as the elevation API answers it.
98#[non_exhaustive]
99#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
100pub struct Elevation {
101    /// The place, as asked for.
102    pub place: Place,
103    /// The DEM's height there, m above mean sea level (the EGM2008 geoid), not the ellipsoid.
104    pub height_msl_m: f64,
105}
106
107/// What to ask Open-Meteo's elevation API for: one place or up to [`MAX_PLACES`].
108#[non_exhaustive]
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct ElevationRequest {
111    /// The places, in the order their heights come back.
112    pub places: Vec<Place>,
113    /// Another server's endpoint in place of [`ENDPOINT`], for a self-hosted Open-Meteo; `None`
114    /// for Open-Meteo's own. It must hold no `?` or `#`.
115    pub endpoint: Option<String>,
116}
117
118impl ElevationRequest {
119    /// A request to Open-Meteo's own server for `places`.
120    #[must_use]
121    pub fn new(places: Vec<Place>) -> Self {
122        Self {
123            places,
124            endpoint: None,
125        }
126    }
127
128    /// The URL that asks for every place's height: the latitudes, then the longitudes, each
129    /// comma-separated in the places' order and written to 5 decimals, trailing zeros dropped:
130    /// 1e-5° is at most 1.1 m on the ground, far inside the DEM's 90 m cells.
131    ///
132    /// # Errors
133    /// [`ElevationError::Request`] when there are no places or more than [`MAX_PLACES`], a place
134    /// is outside the range its doc gives (the value names the place's index), or the endpoint is
135    /// empty or holds a `?` or `#`.
136    pub fn url(&self) -> Result<String, ElevationError> {
137        let refuse = |what: &'static str, value: String| ElevationError::Request { what, value };
138        if !(1..=MAX_PLACES).contains(&self.places.len()) {
139            return Err(refuse("number of places", self.places.len().to_string()));
140        }
141        for (i, place) in self.places.iter().enumerate() {
142            if !(-90.0..=90.0).contains(&place.latitude_deg) {
143                let value = format!("{} (place {i})", place.latitude_deg);
144                return Err(refuse("latitude (deg)", value));
145            }
146            if !(-180.0..=180.0).contains(&place.longitude_deg) {
147                let value = format!("{} (place {i})", place.longitude_deg);
148                return Err(refuse("longitude (deg)", value));
149            }
150        }
151        let endpoint = match &self.endpoint {
152            Some(endpoint) if endpoint.is_empty() || endpoint.contains(['?', '#']) => {
153                return Err(refuse("endpoint", endpoint.clone()));
154            }
155            Some(endpoint) => endpoint.as_str(),
156            None => ENDPOINT,
157        };
158        let join = |value: fn(&Place) -> f64| {
159            let values: Vec<String> = self.places.iter().map(|p| coordinate(value(p))).collect();
160            values.join(",")
161        };
162        Ok(format!(
163            "{endpoint}?latitude={}&longitude={}",
164            join(|p| p.latitude_deg),
165            join(|p| p.longitude_deg),
166        ))
167    }
168
169    /// The [`Source`] a [`Client`] caches this request's answer under: Open-Meteo's elevation,
170    /// its [`ATTRIBUTION`], and [`TTL_S`].
171    #[must_use]
172    pub fn source(&self) -> Source {
173        Source {
174            name: "Open-Meteo elevation".to_owned(),
175            attribution: ATTRIBUTION.to_owned(),
176            ttl_s: TTL_S,
177        }
178    }
179}
180
181/// Reads an answer of the elevation API for `places`: each place's height, m above mean sea level
182/// (the EGM2008 geoid), in the order asked.
183///
184/// # Errors
185/// [`ElevationError::Json`] when the body is not JSON; [`ElevationError::Server`] for Open-Meteo's
186/// error answer; [`ElevationError::Missing`] when `elevation` is not an array of numbers;
187/// [`ElevationError::Count`] when it holds another number of heights than places;
188/// [`ElevationError::OutOfRange`] for a height outside [`HEIGHT_RANGE_M`].
189pub fn parse(body: &[u8], places: &[Place]) -> Result<Vec<Elevation>, ElevationError> {
190    let json: Value =
191        serde_json::from_slice(body).map_err(|e| ElevationError::Json(e.to_string()))?;
192    if json.get("error").and_then(Value::as_bool) == Some(true) {
193        let reason = json
194            .get("reason")
195            .and_then(Value::as_str)
196            .unwrap_or("no reason given");
197        return Err(ElevationError::Server {
198            reason: reason.to_owned(),
199        });
200    }
201    let missing = |field: String| ElevationError::Missing { field };
202    let heights = json
203        .get("elevation")
204        .and_then(Value::as_array)
205        .ok_or_else(|| missing("elevation".to_owned()))?;
206    if heights.len() != places.len() {
207        return Err(ElevationError::Count {
208            expected: places.len(),
209            found: heights.len(),
210        });
211    }
212    heights
213        .iter()
214        .zip(places)
215        .enumerate()
216        .map(|(index, (height, &place))| {
217            let height_msl_m = height
218                .as_f64()
219                .ok_or_else(|| missing(format!("elevation[{index}]")))?;
220            if HEIGHT_RANGE_M.contains(&height_msl_m) {
221                Ok(Elevation {
222                    place,
223                    height_msl_m,
224                })
225            } else {
226                Err(ElevationError::OutOfRange {
227                    index,
228                    height_m: height_msl_m,
229                })
230            }
231        })
232        .collect()
233}
234
235/// Fetches `request` through `client` and reads each place's height above mean sea level.
236///
237/// The answer comes from the client's cache while fresh (see [`ElevationRequest::source`]);
238/// offline, from the cache only. The [`Fetched`] says which, carries [`ATTRIBUTION`] and holds the
239/// body. Only an answer that [`parse`] reads is cached ([`Client::fetch_checked`]): one that
240/// doesn't never takes a good copy's place, and online a stale good copy is returned instead, with
241/// the reason.
242///
243/// # Errors
244/// [`ElevationError::Request`] for a bad request; [`ElevationError::Net`] when the fetch fails,
245/// or with [`NetError::Refused`] naming what [`parse`] refused when the only answer there is
246/// doesn't parse.
247pub fn fetch<T: Transport>(
248    client: &Client<T>,
249    request: &ElevationRequest,
250    now_s: u64,
251) -> Result<(Vec<Elevation>, Fetched), ElevationError> {
252    let url = request.url()?;
253    let places = &request.places;
254    let check = |body: &[u8]| parse(body, places).map(drop).map_err(|e| e.to_string());
255    let fetched = client.fetch_checked(&request.source(), &url, now_s, check)?;
256    let heights = parse(&fetched.body, places)?;
257    Ok((heights, fetched))
258}
259
260/// Why an elevation request or answer was refused.
261#[non_exhaustive]
262#[derive(Debug, thiserror::Error)]
263pub enum ElevationError {
264    /// A request field is outside its range.
265    #[error("the elevation request's {what} is out of range: {value}")]
266    Request {
267        /// The field.
268        what: &'static str,
269        /// Its value; for a coordinate, followed by its place's index from 0, as `90.5 (place 1)`.
270        value: String,
271    },
272    /// The fetch failed.
273    #[error(transparent)]
274    Net(#[from] NetError),
275    /// The body is not JSON.
276    #[error("the Open-Meteo elevation answer is not JSON: {0}")]
277    Json(String),
278    /// The body is Open-Meteo's error answer. Open-Meteo sends it with HTTP status 400, which
279    /// `Http` reports as [`NetError::Transport`] naming the status, without the body; this error
280    /// comes only from a transport that hands the body on, or a self-hosted server that answers
281    /// it with status 200.
282    #[error("Open-Meteo refused the elevation request: {reason}")]
283    Server {
284        /// Its reason.
285        reason: String,
286    },
287    /// A field is absent, or not of the expected type.
288    #[error("the Open-Meteo elevation answer has no usable {field}")]
289    Missing {
290        /// The field.
291        field: String,
292    },
293    /// The answer holds another number of heights than places asked for.
294    #[error("the Open-Meteo elevation answer holds {found} heights, not the {expected} asked for")]
295    Count {
296        /// The places asked for.
297        expected: usize,
298        /// The heights in the answer.
299        found: usize,
300    },
301    /// A height is outside [`HEIGHT_RANGE_M`].
302    #[error("the Open-Meteo elevation answer's height {index} is out of range: {height_m} m")]
303    OutOfRange {
304        /// Its place's index in the request.
305        index: usize,
306        /// The height, m.
307        height_m: f64,
308    },
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn places(places: &[(f64, f64)]) -> Vec<Place> {
316        places
317            .iter()
318            .map(|&(lat, lon)| Place::new(lat, lon))
319            .collect()
320    }
321
322    fn request(list: &[(f64, f64)]) -> ElevationRequest {
323        ElevationRequest::new(places(list))
324    }
325
326    /// The field a request refusal names.
327    fn refused(request: &ElevationRequest) -> (&'static str, String) {
328        match request.url() {
329            Err(ElevationError::Request { what, value }) => (what, value),
330            other => panic!("not a request refusal: {other:?}"),
331        }
332    }
333
334    /// Only the heights of an answer.
335    fn heights(body: &[u8], list: &[(f64, f64)]) -> Vec<f64> {
336        let read = parse(body, &places(list)).unwrap();
337        assert_eq!(
338            read.iter().map(|e| e.place).collect::<Vec<_>>(),
339            places(list)
340        );
341        read.iter().map(|e| e.height_msl_m).collect()
342    }
343
344    #[test]
345    fn the_url_lists_latitudes_then_longitudes_in_order() {
346        let one = request(&[(32.99, -106.97)]);
347        assert_eq!(
348            one.url().unwrap(),
349            "https://api.open-meteo.com/v1/elevation?latitude=32.99&longitude=-106.97"
350        );
351        let three = request(&[(32.99, -106.97), (31.5, 35.5), (0.0, -30.0)]);
352        assert_eq!(
353            three.url().unwrap(),
354            "https://api.open-meteo.com/v1/elevation?latitude=32.99,31.5,0&longitude=-106.97,35.5,-30"
355        );
356        let mut own = one.clone();
357        own.endpoint = Some("http://localhost:8080/v1/elevation".to_owned());
358        assert_eq!(
359            own.url().unwrap(),
360            "http://localhost:8080/v1/elevation?latitude=32.99&longitude=-106.97"
361        );
362    }
363
364    /// A place rebuilt from radians has other last digits (−106.91° comes back as
365    /// −106.91000000000001); the URL, and so the cache key, is the same.
366    #[test]
367    fn the_url_survives_a_round_trip_through_radians() {
368        let mut changed = 0;
369        for hundredths in -18_000..=18_000 {
370            let deg = f64::from(hundredths) / 100.0;
371            let again = deg.to_radians().to_degrees();
372            changed += usize::from(again.to_bits() != deg.to_bits());
373            let lat = deg / 2.0;
374            let back = (lat.to_radians().to_degrees(), again);
375            assert_eq!(
376                request(&[back]).url().unwrap(),
377                request(&[(lat, deg)]).url().unwrap(),
378                "{deg}"
379            );
380        }
381        // The case is real: thousands of two-decimal longitudes change on the way.
382        assert!(changed > 1_000, "{changed}");
383        assert_eq!(coordinate(-106.910_000_000_000_01), "-106.91");
384    }
385
386    #[test]
387    fn a_request_out_of_range_names_its_field() {
388        assert_eq!(refused(&request(&[])), ("number of places", "0".to_owned()));
389        let full = vec![(0.0, 0.0); MAX_PLACES];
390        assert!(request(&full).url().is_ok());
391        let over = vec![(0.0, 0.0); MAX_PLACES + 1];
392        assert_eq!(
393            refused(&request(&over)),
394            ("number of places", "101".to_owned())
395        );
396        assert_eq!(
397            refused(&request(&[(0.0, 0.0), (90.5, 0.0)])),
398            ("latitude (deg)", "90.5 (place 1)".to_owned())
399        );
400        assert_eq!(
401            refused(&request(&[(f64::NAN, 0.0)])),
402            ("latitude (deg)", "NaN (place 0)".to_owned())
403        );
404        assert_eq!(
405            refused(&request(&[(-90.0, -180.5)])),
406            ("longitude (deg)", "-180.5 (place 0)".to_owned())
407        );
408        assert_eq!(
409            refused(&request(&[(0.0, f64::NAN)])),
410            ("longitude (deg)", "NaN (place 0)".to_owned())
411        );
412        assert!(request(&[(-90.0, 180.0), (90.0, -180.0)]).url().is_ok());
413        for endpoint in ["", "https://x.test/v1/elevation?a=1", "https://x.test/#e"] {
414            let mut r = request(&[(0.0, 0.0)]);
415            r.endpoint = Some(endpoint.to_owned());
416            assert_eq!(refused(&r), ("endpoint", endpoint.to_owned()));
417        }
418    }
419
420    #[test]
421    fn the_source_carries_both_credits_and_a_year() {
422        let source = request(&[(0.0, 0.0)]).source();
423        assert_eq!(source.ttl_s, 31_536_000);
424        assert!(
425            source
426                .attribution
427                .starts_with("Elevation data by Open-Meteo.com (CC BY 4.0)")
428        );
429        assert!(source.attribution.contains("Copernicus DEM GLO-90"));
430        assert!(source.attribution.ends_with("all rights reserved"));
431        assert!(!source.attribution.contains("  "), "{}", source.attribution);
432    }
433
434    #[test]
435    fn parse_reads_every_height_in_order() {
436        let three = [(32.99, -106.97), (31.5, 35.5), (0.0, -30.0)];
437        assert_eq!(
438            heights(br#"{"elevation":[1400.0, -427.0, 0.0]}"#, &three),
439            [1400.0, -427.0, 0.0]
440        );
441        // Another field beside `elevation` is ignored, as Open-Meteo may add some.
442        assert_eq!(
443            heights(
444                br#"{"elevation":[12.5],"generationtime_ms":0.1}"#,
445                &[(1.0, 2.0)]
446            ),
447            [12.5]
448        );
449        let edges = heights(br#"{"elevation":[-1000, 9000]}"#, &[(0.0, 0.0), (1.0, 1.0)]);
450        assert_eq!(edges, [-1_000.0, 9_000.0]);
451    }
452
453    #[test]
454    fn parse_refuses_a_broken_answer() {
455        let at = |n: usize| vec![Place::new(0.0, 0.0); n];
456        let error = |body: &[u8], n: usize| parse(body, &at(n)).unwrap_err();
457        assert!(matches!(error(b"<html>", 1), ElevationError::Json(_)));
458        let server =
459            r#"{"error":true,"reason":"Latitude must be in range of -90 to 90°. Given: 95.0."}"#;
460        match error(server.as_bytes(), 1) {
461            ElevationError::Server { reason } => {
462                assert_eq!(
463                    reason,
464                    "Latitude must be in range of -90 to 90°. Given: 95.0."
465                );
466            }
467            other => panic!("{other:?}"),
468        }
469        match error(br#"{"error":true}"#, 1) {
470            ElevationError::Server { reason } => assert_eq!(reason, "no reason given"),
471            other => panic!("{other:?}"),
472        }
473        for (body, n, field) in [
474            (&br#"{}"#[..], 1, "elevation"),
475            (br#"{"elevation":1400.0}"#, 1, "elevation"),
476            (br#"{"elevation":[1400.0, null]}"#, 2, "elevation[1]"),
477            (br#"{"elevation":["1400"]}"#, 1, "elevation[0]"),
478        ] {
479            match error(body, n) {
480                ElevationError::Missing { field: f } => assert_eq!(f, field),
481                other => panic!("{other:?}"),
482            }
483        }
484        let count = error(br#"{"elevation":[1.0, 2.0]}"#, 3);
485        assert!(matches!(
486            count,
487            ElevationError::Count {
488                expected: 3,
489                found: 2
490            }
491        ));
492        assert_eq!(
493            count.to_string(),
494            "the Open-Meteo elevation answer holds 2 heights, not the 3 asked for"
495        );
496        assert!(matches!(
497            error(br#"{"elevation":[]}"#, 1),
498            ElevationError::Count {
499                expected: 1,
500                found: 0
501            }
502        ));
503        for (body, n, index, height) in [
504            (&br#"{"elevation":[0.0, -32768]}"#[..], 2, 1, -32_768.0),
505            (br#"{"elevation":[-32767]}"#, 1, 0, -32_767.0),
506            (br#"{"elevation":[-1000.5]}"#, 1, 0, -1_000.5),
507            (br#"{"elevation":[9000.5]}"#, 1, 0, 9_000.5),
508        ] {
509            match error(body, n) {
510                ElevationError::OutOfRange { index: i, height_m } => {
511                    assert_eq!((i, height_m), (index, height));
512                }
513                other => panic!("{other:?}"),
514            }
515        }
516    }
517}