1use serde::{Deserialize, Serialize};
48use serde_json::Value;
49
50use crate::coordinate::coordinate;
51use crate::{Client, Fetched, NetError, Source, Transport};
52
53pub const ENDPOINT: &str = "https://api.open-meteo.com/v1/elevation";
55
56pub const MAX_PLACES: usize = 100;
58
59pub const TTL_S: u64 = 365 * 86_400;
62
63pub const HEIGHT_RANGE_M: std::ops::RangeInclusive<f64> = -1_000.0..=9_000.0;
68
69pub 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#[non_exhaustive]
78#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
79pub struct Place {
80 pub latitude_deg: f64,
82 pub longitude_deg: f64,
84}
85
86impl Place {
87 #[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#[non_exhaustive]
99#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
100pub struct Elevation {
101 pub place: Place,
103 pub height_msl_m: f64,
105}
106
107#[non_exhaustive]
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct ElevationRequest {
111 pub places: Vec<Place>,
113 pub endpoint: Option<String>,
116}
117
118impl ElevationRequest {
119 #[must_use]
121 pub fn new(places: Vec<Place>) -> Self {
122 Self {
123 places,
124 endpoint: None,
125 }
126 }
127
128 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 #[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
181pub 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
235pub 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#[non_exhaustive]
262#[derive(Debug, thiserror::Error)]
263pub enum ElevationError {
264 #[error("the elevation request's {what} is out of range: {value}")]
266 Request {
267 what: &'static str,
269 value: String,
271 },
272 #[error(transparent)]
274 Net(#[from] NetError),
275 #[error("the Open-Meteo elevation answer is not JSON: {0}")]
277 Json(String),
278 #[error("Open-Meteo refused the elevation request: {reason}")]
283 Server {
284 reason: String,
286 },
287 #[error("the Open-Meteo elevation answer has no usable {field}")]
289 Missing {
290 field: String,
292 },
293 #[error("the Open-Meteo elevation answer holds {found} heights, not the {expected} asked for")]
295 Count {
296 expected: usize,
298 found: usize,
300 },
301 #[error("the Open-Meteo elevation answer's height {index} is out of range: {height_m} m")]
303 OutOfRange {
304 index: usize,
306 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 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 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 #[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 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 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}