1use quick_xml::events::Event;
11use quick_xml::Reader;
12
13use crate::{q_hash, NQuin};
14
15pub const SPATIAL_CONTEXT: u64 = q_hash("urn:qualia:context:spatial");
17pub const T_CONTEXT: u64 = q_hash("urn:qualia:context:temporal");
18
19pub const P_HAS_GEOMETRY: u64 = q_hash("http://www.opengis.net/ont/geosparql#hasGeometry");
21pub const P_AS_WKT: u64 = q_hash("http://www.opengis.net/ont/geosparql#asWKT");
22
23pub const P_GENERATED_AT: u64 = q_hash("http://www.w3.org/ns/prov#generatedAtTime");
25pub const P_STARTED_AT: u64 = q_hash("http://www.w3.org/ns/prov#startedAtTime");
26pub const P_ENDED_AT: u64 = q_hash("http://www.w3.org/ns/prov#endedAtTime");
27
28const P_TITLE: u64 = q_hash("http://purl.org/dc/terms/title");
30
31#[derive(Debug)]
33pub enum KmlError {
34 Xml(String),
35 InvalidGeometry(String),
36}
37
38impl std::fmt::Display for KmlError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 KmlError::Xml(s) => write!(f, "KML XML error: {s}"),
42 KmlError::InvalidGeometry(s) => write!(f, "KML geometry error: {s}"),
43 }
44 }
45}
46
47impl std::error::Error for KmlError {}
48
49pub fn import_kml(
59 bytes: &[u8],
60) -> Result<(Vec<NQuin>, std::collections::HashMap<u64, String>), KmlError> {
61 let mut reader = Reader::from_reader(bytes);
62 reader.config_mut().trim_text(true);
63
64 let mut quins: Vec<NQuin> = Vec::new();
65 let mut lexicon: std::collections::HashMap<u64, String> = std::collections::HashMap::new();
66 let mut buf = Vec::new();
67
68 let mut in_placemark = false;
70 let mut in_point = false;
71 let mut in_timestamp = false;
72 let mut in_timespan = false;
73 let mut in_name = false;
74 let mut in_coordinates = false;
75 let mut in_when = false;
76 let mut in_begin = false;
77 let mut in_end = false;
78
79 let mut in_polygon = false;
81 let mut in_outer_boundary = false;
82 let mut in_linear_ring = false;
83 let mut in_poly_coordinates = false;
84 let mut polygon_coordinates_text = String::new();
85
86 let mut in_network_link = false;
88 let mut in_link = false;
89 let mut in_href = false;
90 let mut href_text = String::new();
91 let mut network_link_name = String::new();
92 let mut network_link_idx: u64 = 0;
93
94 let mut placemark_subject: u64 = 0;
95 let mut placemark_idx: u64 = 0;
96 let mut coordinates_text = String::new();
97 let mut name_text = String::new();
98 let mut when_text = String::new();
99 let mut begin_text = String::new();
100 let mut end_text = String::new();
101
102 loop {
103 match reader.read_event_into(&mut buf) {
104 Ok(Event::Start(e)) => {
105 let local = e.local_name();
106 match local.as_ref() {
107 b"Placemark" => {
108 placemark_idx += 1;
109 placemark_subject =
110 fnv_hash(format!("kml:placemark:{placemark_idx}").as_bytes());
111 in_placemark = true;
112 in_polygon = false;
113 coordinates_text.clear();
114 polygon_coordinates_text.clear();
115 name_text.clear();
116 when_text.clear();
117 begin_text.clear();
118 end_text.clear();
119 }
120 b"Point" if in_placemark => in_point = true,
121 b"TimeStamp" if in_placemark => in_timestamp = true,
122 b"TimeSpan" if in_placemark => in_timespan = true,
123 b"name" if in_placemark => in_name = true,
124 b"name" if in_network_link => in_name = true,
125 b"coordinates" if in_point => in_coordinates = true,
126 b"when" if in_timestamp => in_when = true,
127 b"begin" if in_timespan => in_begin = true,
128 b"end" if in_timespan => in_end = true,
129 b"Polygon" if in_placemark => in_polygon = true,
131 b"outerBoundaryIs" if in_polygon => in_outer_boundary = true,
132 b"LinearRing" if in_outer_boundary => in_linear_ring = true,
133 b"coordinates" if in_linear_ring => in_poly_coordinates = true,
134 b"NetworkLink" => {
136 network_link_idx += 1;
137 in_network_link = true;
138 href_text.clear();
139 network_link_name.clear();
140 }
141 b"Link" if in_network_link => in_link = true,
142 b"href" if in_link => in_href = true,
143 _ => {}
144 }
145 }
146 Ok(Event::End(e)) => {
147 let local = e.local_name();
148 match local.as_ref() {
149 b"Placemark" if in_placemark => {
150 flush_placemark(
151 placemark_subject,
152 &coordinates_text,
153 &name_text,
154 &when_text,
155 &begin_text,
156 &end_text,
157 &mut quins,
158 &mut lexicon,
159 )?;
160 if in_polygon && !polygon_coordinates_text.is_empty() {
161 flush_polygon(
162 placemark_subject,
163 &polygon_coordinates_text,
164 &name_text,
165 &mut quins,
166 &mut lexicon,
167 )?;
168 }
169 in_placemark = false;
170 in_point = false;
171 in_timestamp = false;
172 in_timespan = false;
173 in_polygon = false;
174 in_outer_boundary = false;
175 in_linear_ring = false;
176 }
177 b"Point" => in_point = false,
178 b"TimeStamp" => in_timestamp = false,
179 b"TimeSpan" => in_timespan = false,
180 b"name" => {
181 in_name = false;
182 if in_network_link {
183 network_link_name = name_text.clone();
184 }
185 }
186 b"coordinates" if !in_poly_coordinates => in_coordinates = false,
187 b"when" => in_when = false,
188 b"begin" => in_begin = false,
189 b"end" => in_end = false,
190 b"Polygon" => in_polygon = false,
192 b"outerBoundaryIs" => in_outer_boundary = false,
193 b"LinearRing" => in_linear_ring = false,
194 b"coordinates" if in_poly_coordinates => in_poly_coordinates = false,
195 b"NetworkLink" if in_network_link => {
197 flush_network_link(
198 network_link_idx,
199 &href_text,
200 &network_link_name,
201 &mut quins,
202 &mut lexicon,
203 );
204 in_network_link = false;
205 in_link = false;
206 }
207 b"Link" => in_link = false,
208 b"href" => in_href = false,
209 _ => {}
210 }
211 }
212 Ok(Event::Text(e)) => {
213 let decoded = e.decode().map_err(|e| KmlError::Xml(e.to_string()))?;
216 let text = quick_xml::escape::unescape(&decoded)
217 .map_err(|e| KmlError::Xml(e.to_string()))?
218 .into_owned();
219 if in_poly_coordinates {
220 polygon_coordinates_text = text;
221 } else if in_coordinates {
222 coordinates_text = text;
223 } else if in_name {
224 name_text = text;
225 } else if in_when {
226 when_text = text;
227 } else if in_begin {
228 begin_text = text;
229 } else if in_end {
230 end_text = text;
231 } else if in_href {
232 href_text = text;
233 }
234 }
235 Ok(Event::Eof) => break,
236 Err(e) => return Err(KmlError::Xml(e.to_string())),
237 _ => {}
238 }
239 buf.clear();
240 }
241
242 Ok((quins, lexicon))
243}
244
245fn flush_placemark(
247 subject: u64,
248 coordinates: &str,
249 name: &str,
250 when: &str,
251 begin: &str,
252 end: &str,
253 quins: &mut Vec<NQuin>,
254 lexicon: &mut std::collections::HashMap<u64, String>,
255) -> Result<(), KmlError> {
256 if coordinates.is_empty() {
257 return Ok(());
258 }
259
260 let (lon, lat) = parse_first_coordinate(coordinates)?;
262 let wkt = format!("POINT({lon} {lat})");
263 let wkt_hash = fnv_hash(wkt.as_bytes());
264 lexicon.insert(wkt_hash, wkt);
265
266 let geohash = encode_geohash_64(lon, lat);
268
269 quins.push(make_quin(subject, P_HAS_GEOMETRY, geohash, SPATIAL_CONTEXT));
270 quins.push(make_quin(subject, P_AS_WKT, wkt_hash, SPATIAL_CONTEXT));
271
272 if !name.is_empty() {
274 let name_hash = fnv_hash(name.as_bytes());
275 lexicon.insert(name_hash, name.to_owned());
276 quins.push(make_quin(subject, P_TITLE, name_hash, SPATIAL_CONTEXT));
277 }
278
279 if !when.is_empty() {
281 let ts = parse_iso8601_ms(when).unwrap_or(0);
282 quins.push(make_temporal_quin(subject, P_GENERATED_AT, ts));
283 }
284 if !begin.is_empty() {
285 let ts = parse_iso8601_ms(begin).unwrap_or(0);
286 quins.push(make_temporal_quin(subject, P_STARTED_AT, ts));
287 }
288 if !end.is_empty() {
289 let ts = parse_iso8601_ms(end).unwrap_or(0);
290 quins.push(make_temporal_quin(subject, P_ENDED_AT, ts));
291 }
292
293 Ok(())
294}
295
296const P_SEE_ALSO: u64 = q_hash("http://www.w3.org/2000/01/rdf-schema#seeAlso");
298const P_BOUNDING_BOX: u64 = q_hash("urn:qualia:spatial:boundingBox");
299const P_BB_MIN_LON: u64 = q_hash("urn:qualia:spatial:minLon");
300const P_BB_MAX_LON: u64 = q_hash("urn:qualia:spatial:maxLon");
301const P_BB_MIN_LAT: u64 = q_hash("urn:qualia:spatial:minLat");
302const P_BB_MAX_LAT: u64 = q_hash("urn:qualia:spatial:maxLat");
303
304fn flush_polygon(
311 subject: u64,
312 coordinates_text: &str,
313 name: &str,
314 quins: &mut Vec<NQuin>,
315 lexicon: &mut std::collections::HashMap<u64, String>,
316) -> Result<(), KmlError> {
317 let points = parse_coordinate_list(coordinates_text)?;
318 if points.is_empty() {
319 return Ok(());
320 }
321
322 let mut min_lon = f64::MAX;
324 let mut max_lon = f64::MIN;
325 let mut min_lat = f64::MAX;
326 let mut max_lat = f64::MIN;
327 let mut sum_lon = 0.0f64;
328 let mut sum_lat = 0.0f64;
329 for &(lon, lat) in &points {
330 min_lon = min_lon.min(lon);
331 max_lon = max_lon.max(lon);
332 min_lat = min_lat.min(lat);
333 max_lat = max_lat.max(lat);
334 sum_lon += lon;
335 sum_lat += lat;
336 }
337 let n = points.len() as f64;
338 let centroid_lon = sum_lon / n;
339 let centroid_lat = sum_lat / n;
340
341 let ring: Vec<String> = points
344 .iter()
345 .map(|(lon, lat)| format!("{lon} {lat}"))
346 .collect();
347 let wkt = format!("POLYGON(({})", ring.join(", "));
348 let wkt = wkt + ")";
349 let wkt_hash = fnv_hash(wkt.as_bytes());
350 lexicon.insert(wkt_hash, wkt);
351
352 let geohash = encode_geohash_64(centroid_lon, centroid_lat);
353 quins.push(make_quin(subject, P_HAS_GEOMETRY, geohash, SPATIAL_CONTEXT));
354 quins.push(make_quin(subject, P_AS_WKT, wkt_hash, SPATIAL_CONTEXT));
355
356 let bb_subject = fnv_hash(format!("kml:bbox:{subject}").as_bytes());
358 quins.push(make_quin(
359 subject,
360 P_BOUNDING_BOX,
361 bb_subject,
362 SPATIAL_CONTEXT,
363 ));
364 quins.push(make_quin(
365 bb_subject,
366 P_BB_MIN_LON,
367 min_lon.to_bits(),
368 SPATIAL_CONTEXT,
369 ));
370 quins.push(make_quin(
371 bb_subject,
372 P_BB_MAX_LON,
373 max_lon.to_bits(),
374 SPATIAL_CONTEXT,
375 ));
376 quins.push(make_quin(
377 bb_subject,
378 P_BB_MIN_LAT,
379 min_lat.to_bits(),
380 SPATIAL_CONTEXT,
381 ));
382 quins.push(make_quin(
383 bb_subject,
384 P_BB_MAX_LAT,
385 max_lat.to_bits(),
386 SPATIAL_CONTEXT,
387 ));
388
389 if !name.is_empty() {
390 let name_hash = fnv_hash(name.as_bytes());
391 lexicon.insert(name_hash, name.to_owned());
392 quins.push(make_quin(subject, P_TITLE, name_hash, SPATIAL_CONTEXT));
393 }
394
395 Ok(())
396}
397
398fn parse_coordinate_list(s: &str) -> Result<Vec<(f64, f64)>, KmlError> {
403 let mut out = Vec::new();
404 for token in s.split_whitespace() {
405 let (lon, lat) = parse_first_coordinate(token)?;
407 out.push((lon, lat));
408 }
409 Ok(out)
410}
411
412fn flush_network_link(
417 idx: u64,
418 href: &str,
419 name: &str,
420 quins: &mut Vec<NQuin>,
421 lexicon: &mut std::collections::HashMap<u64, String>,
422) {
423 if href.is_empty() {
424 return;
425 }
426 let link_subject = fnv_hash(format!("kml:networklink:{idx}").as_bytes());
427 let href_hash = fnv_hash(href.as_bytes());
428 lexicon.insert(href_hash, href.to_owned());
429
430 quins.push(make_quin(
431 link_subject,
432 P_SEE_ALSO,
433 href_hash,
434 SPATIAL_CONTEXT,
435 ));
436
437 if !name.is_empty() {
438 let name_hash = fnv_hash(name.as_bytes());
439 lexicon.insert(name_hash, name.to_owned());
440 quins.push(make_quin(link_subject, P_TITLE, name_hash, SPATIAL_CONTEXT));
441 }
442}
443
444pub fn export_kml(quins: &[NQuin], lexicon: &std::collections::HashMap<u64, String>) -> String {
449 use std::collections::BTreeMap;
450
451 let mut by_subject: BTreeMap<u64, PlacemarkData> = BTreeMap::new();
453
454 for q in quins {
455 if q.context != SPATIAL_CONTEXT && q.context != T_CONTEXT {
456 continue;
457 }
458 let entry = by_subject.entry(q.subject).or_default();
459 match q.predicate {
460 P_AS_WKT => {
461 if let Some(wkt) = lexicon.get(&q.object) {
462 entry.wkt = Some(wkt.clone());
463 }
464 }
465 P_TITLE => {
466 if let Some(title) = lexicon.get(&q.object) {
467 entry.name = Some(title.clone());
468 }
469 }
470 P_GENERATED_AT => entry.when_ms = Some(q.object),
471 P_STARTED_AT => entry.begin_ms = Some(q.object),
472 P_ENDED_AT => entry.end_ms = Some(q.object),
473 _ => {}
474 }
475 }
476
477 let mut out = String::from(
478 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
479 <kml xmlns=\"http://www.opengis.net/kml/2.2\">\n<Document>\n",
480 );
481
482 for (_subj, pm) in &by_subject {
483 out.push_str("<Placemark>\n");
484 if let Some(name) = &pm.name {
485 out.push_str(&format!(" <name>{}</name>\n", xml_escape(name)));
486 }
487 if let Some(wkt) = &pm.wkt {
488 if let Some((lon, lat)) = wkt_to_lonlat(wkt) {
489 out.push_str(&format!(
490 " <Point><coordinates>{lon},{lat},0</coordinates></Point>\n"
491 ));
492 }
493 }
494 match (pm.when_ms, pm.begin_ms, pm.end_ms) {
495 (Some(ms), _, _) => {
496 let ts = ms_to_iso8601(ms);
497 out.push_str(&format!(" <TimeStamp><when>{ts}</when></TimeStamp>\n"));
498 }
499 (_, Some(b), Some(e)) => {
500 out.push_str(&format!(
501 " <TimeSpan><begin>{}</begin><end>{}</end></TimeSpan>\n",
502 ms_to_iso8601(b),
503 ms_to_iso8601(e)
504 ));
505 }
506 _ => {}
507 }
508 out.push_str("</Placemark>\n");
509 }
510
511 out.push_str("</Document>\n</kml>");
512 out
513}
514
515#[derive(Default)]
518struct PlacemarkData {
519 wkt: Option<String>,
520 name: Option<String>,
521 when_ms: Option<u64>,
522 begin_ms: Option<u64>,
523 end_ms: Option<u64>,
524}
525
526#[inline]
527fn make_quin(subject: u64, predicate: u64, object: u64, context: u64) -> NQuin {
528 NQuin {
529 subject,
530 predicate,
531 object,
532 context,
533 metadata: 0,
534 parity: 0,
535 }
536}
537
538#[inline]
539fn make_temporal_quin(subject: u64, predicate: u64, timestamp_ms: u64) -> NQuin {
540 NQuin {
541 subject,
542 predicate,
543 object: timestamp_ms,
544 context: T_CONTEXT,
545 metadata: 0,
546 parity: 0,
547 }
548}
549
550#[inline]
554fn fnv_hash(bytes: &[u8]) -> u64 {
555 let mut h: u64 = 0xcbf29ce484222325;
556 for &b in bytes {
557 h ^= b as u64;
558 h = h.wrapping_mul(0x100000001b3);
559 }
560 h & 0x0FFF_FFFF_FFFF_FFFF
561}
562
563pub fn encode_geohash_64(lon: f64, lat: f64) -> u64 {
566 let lon_u = ((lon + 180.0) / 360.0 * u32::MAX as f64) as u64;
567 let lat_u = ((lat + 90.0) / 180.0 * u32::MAX as f64) as u64;
568 let mut result: u64 = 0;
570 for i in 0..32u64 {
571 result |= ((lon_u >> i) & 1) << (i * 2);
572 result |= ((lat_u >> i) & 1) << (i * 2 + 1);
573 }
574 result
575}
576
577fn parse_first_coordinate(s: &str) -> Result<(f64, f64), KmlError> {
579 let first = s.split_whitespace().next().unwrap_or(s);
580 let mut parts = first.split(',');
581 let lon: f64 = parts
582 .next()
583 .and_then(|v| v.trim().parse().ok())
584 .ok_or_else(|| KmlError::InvalidGeometry(format!("bad longitude in '{s}'")))?;
585 let lat: f64 = parts
586 .next()
587 .and_then(|v| v.trim().parse().ok())
588 .ok_or_else(|| KmlError::InvalidGeometry(format!("bad latitude in '{s}'")))?;
589 Ok((lon, lat))
590}
591
592fn wkt_to_lonlat(wkt: &str) -> Option<(f64, f64)> {
594 let inner = wkt.trim_start_matches("POINT(").trim_end_matches(')');
595 let mut parts = inner.split_whitespace();
596 let lon: f64 = parts.next()?.parse().ok()?;
597 let lat: f64 = parts.next()?.parse().ok()?;
598 Some((lon, lat))
599}
600
601fn parse_iso8601_ms(s: &str) -> Option<u64> {
604 let s = s.trim().trim_end_matches('Z');
605 if s.len() >= 19 {
607 let (date, time) = s.split_at(10);
608 let time = time.trim_start_matches('T');
609 let (y, m, d) = parse_date(date)?;
610 let (hh, mm, ss) = parse_time(time)?;
611 let days = days_since_epoch(y, m, d)?;
612 let secs = days as u64 * 86400 + hh as u64 * 3600 + mm as u64 * 60 + ss as u64;
613 return Some(secs * 1000);
614 }
615 if s.len() == 10 {
617 let (y, m, d) = parse_date(s)?;
618 let days = days_since_epoch(y, m, d)?;
619 return Some(days as u64 * 86400 * 1000);
620 }
621 None
622}
623
624fn parse_date(s: &str) -> Option<(i32, u8, u8)> {
625 let mut parts = s.split('-');
626 let y: i32 = parts.next()?.parse().ok()?;
627 let m: u8 = parts.next()?.parse().ok()?;
628 let d: u8 = parts.next()?.parse().ok()?;
629 Some((y, m, d))
630}
631
632fn parse_time(s: &str) -> Option<(u8, u8, u8)> {
633 let mut parts = s.split(':');
634 let hh: u8 = parts.next()?.parse().ok()?;
635 let mm: u8 = parts.next()?.parse().ok()?;
636 let ss: u8 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
637 Some((hh, mm, ss))
638}
639
640fn days_since_epoch(y: i32, m: u8, d: u8) -> Option<i64> {
642 if m < 1 || m > 12 || d < 1 || d > 31 {
643 return None;
644 }
645 let y = y as i64 - if m <= 2 { 1 } else { 0 };
647 let era = y.div_euclid(400);
648 let yoe = y.rem_euclid(400);
649 let doy = (153 * (m as i64 + if m > 2 { -3 } else { 9 }) + 2) / 5 + d as i64 - 1;
650 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
651 Some(era * 146097 + doe - 719468)
652}
653
654fn ms_to_iso8601(ms: u64) -> String {
656 let secs = ms / 1000;
657 let s = secs % 60;
658 let m = (secs / 60) % 60;
659 let h = (secs / 3600) % 24;
660 let days = secs / 86400;
661 let (y, mo, d) = civil_from_days(days as i64);
662 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
663}
664
665fn civil_from_days(z: i64) -> (i32, u32, u32) {
667 let z = z + 719468;
668 let era = z.div_euclid(146097);
669 let doe = z.rem_euclid(146097) as u64;
670 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
671 let y = yoe as i64 + era * 400;
672 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
673 let mp = (5 * doy + 2) / 153;
674 let d = doy - (153 * mp + 2) / 5 + 1;
675 let m = if mp < 10 { mp + 3 } else { mp - 9 };
676 let y = if m <= 2 { y + 1 } else { y };
677 (y as i32, m as u32, d as u32)
678}
679
680fn xml_escape(s: &str) -> String {
681 s.replace('&', "&")
682 .replace('<', "<")
683 .replace('>', ">")
684 .replace('"', """)
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn roundtrip_simple_point() {
693 let kml = br#"<?xml version="1.0"?>
694<kml xmlns="http://www.opengis.net/kml/2.2">
695<Document>
696 <Placemark>
697 <name>Test Point</name>
698 <Point><coordinates>-122.0839,37.4219,0</coordinates></Point>
699 <TimeStamp><when>2024-01-15T12:00:00Z</when></TimeStamp>
700 </Placemark>
701</Document>
702</kml>"#;
703 let (quins, lex) = import_kml(kml).unwrap();
704 assert!(!quins.is_empty(), "should produce quins");
705
706 let spatial: Vec<_> = quins
707 .iter()
708 .filter(|q| q.context == SPATIAL_CONTEXT)
709 .collect();
710 let temporal: Vec<_> = quins.iter().filter(|q| q.context == T_CONTEXT).collect();
711 assert!(!spatial.is_empty(), "spatial quins expected");
712 assert!(!temporal.is_empty(), "temporal quins expected");
713
714 let exported = export_kml(&quins, &lex);
715 assert!(
716 exported.contains("POINT") || exported.contains("-122"),
717 "WKT or coords in export"
718 );
719 }
720
721 #[test]
722 fn timespan_produces_start_end_quins() {
723 let kml = br#"<?xml version="1.0"?>
724<kml xmlns="http://www.opengis.net/kml/2.2">
725<Document>
726 <Placemark>
727 <Point><coordinates>10.0,20.0,0</coordinates></Point>
728 <TimeSpan>
729 <begin>2020-06-01</begin>
730 <end>2020-12-31</end>
731 </TimeSpan>
732 </Placemark>
733</Document>
734</kml>"#;
735 let (quins, _) = import_kml(kml).unwrap();
736 let has_start = quins.iter().any(|q| q.predicate == P_STARTED_AT);
737 let has_end = quins.iter().any(|q| q.predicate == P_ENDED_AT);
738 assert!(has_start, "expected prov:startedAtTime quin");
739 assert!(has_end, "expected prov:endedAtTime quin");
740 }
741
742 #[test]
743 fn geohash_64_is_deterministic() {
744 let h1 = encode_geohash_64(-122.0, 37.4);
745 let h2 = encode_geohash_64(-122.0, 37.4);
746 assert_eq!(h1, h2);
747 }
748}