1use anyhow::{Context, Result};
6use arrow::array::{Array, Float64Array, Int64Array, StringArray, TimestampSecondArray, TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray};
7use arrow::datatypes::DataType;
8use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
9use std::path::{Path, PathBuf};
10use stt_core::timestamp::{normalize_timestamp_to_ms, TimestampUnit};
11use stt_core::types::{BoundingBox, TimeRange};
12
13#[derive(Debug, Clone)]
15pub enum DataSource {
16 GeoParquet {
17 path: PathBuf,
18 time_field: String,
19 time_format: String,
20 },
21}
22
23impl DataSource {
24 pub fn display_name(&self) -> String {
25 match self {
26 DataSource::GeoParquet { path, .. } => {
27 path.file_name()
28 .map(|n| n.to_string_lossy().to_string())
29 .unwrap_or_else(|| "unknown".to_string())
30 }
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct AnalyzableFeature {
38 pub lon: f64,
40 pub lat: f64,
42 pub timestamp: u64,
44 pub geometry_type: GeometryType,
46 pub vertex_count: usize,
48 pub estimated_size: usize,
50 pub property_count: usize,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum GeometryType {
57 Point,
58 LineString,
59 Polygon,
60 MultiPoint,
61 MultiLineString,
62 MultiPolygon,
63 Unknown,
64}
65
66impl std::fmt::Display for GeometryType {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 GeometryType::Point => write!(f, "Point"),
70 GeometryType::LineString => write!(f, "LineString"),
71 GeometryType::Polygon => write!(f, "Polygon"),
72 GeometryType::MultiPoint => write!(f, "MultiPoint"),
73 GeometryType::MultiLineString => write!(f, "MultiLineString"),
74 GeometryType::MultiPolygon => write!(f, "MultiPolygon"),
75 GeometryType::Unknown => write!(f, "Unknown"),
76 }
77 }
78}
79
80const MAX_SAMPLE_FEATURES: usize = 5000;
84
85#[derive(Debug, Clone, PartialEq)]
87pub enum PropValue {
88 Number(f64),
90 Text(String),
92}
93
94#[derive(Debug, Clone)]
99pub struct SampledFeature {
100 pub geometry: geo_types::Geometry<f64>,
102 pub timestamp_ms: u64,
104 pub properties: Vec<(String, PropValue)>,
107}
108
109#[derive(Debug)]
111pub struct LoadedData {
112 pub features: Vec<AnalyzableFeature>,
113 pub bounds: BoundingBox,
114 pub time_range: TimeRange,
115 pub sample: Vec<SampledFeature>,
119}
120
121pub fn load_data(source: &DataSource) -> Result<LoadedData> {
123 match source {
124 DataSource::GeoParquet { path, time_field, time_format } => {
125 load_geoparquet(path, time_field, time_format)
126 }
127 }
128}
129
130fn load_geoparquet(path: &Path, time_field: &str, time_format: &str) -> Result<LoadedData> {
132 use indicatif::{ProgressBar, ProgressStyle};
133
134 let pb = ProgressBar::new_spinner();
135 pb.set_style(
136 ProgressStyle::default_spinner()
137 .template("{spinner:.green} {msg}")
138 .unwrap(),
139 );
140 pb.set_message("Loading GeoParquet file...");
141
142 let file = std::fs::File::open(path).context("Failed to open GeoParquet file")?;
143 let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
144 let schema = builder.schema().clone();
145
146 let geom_col_name = find_geometry_column(&schema)?;
148 let time_col_idx = schema.fields().iter().position(|f| f.name() == time_field)
149 .ok_or_else(|| anyhow::anyhow!("Time field '{}' not found", time_field))?;
150
151 let total_rows = builder.metadata().file_metadata().num_rows().max(0) as usize;
154 let sample_stride = ((total_rows + MAX_SAMPLE_FEATURES - 1) / MAX_SAMPLE_FEATURES).max(1);
155 let mut sample: Vec<SampledFeature> = Vec::new();
156 let mut row_index: usize = 0;
157
158 let reader = builder.build()?;
159
160 let mut features = Vec::new();
161 let mut min_lon = f64::MAX;
162 let mut max_lon = f64::MIN;
163 let mut min_lat = f64::MAX;
164 let mut max_lat = f64::MIN;
165 let mut min_time = u64::MAX;
166 let mut max_time = u64::MIN;
167
168 for batch_result in reader {
169 let batch = batch_result.context("Failed to read Parquet batch")?;
170
171 let geometries = extract_geometries_from_batch(&batch, &geom_col_name)?;
172 let timestamps = extract_timestamps_from_batch(&batch, time_col_idx, time_format)?;
173
174 let property_count = schema.fields().len() - 2; for i in 0..batch.num_rows() {
178 let (geom_type, vertex_count, lon, lat) = geometries.get(i)
179 .cloned()
180 .unwrap_or((GeometryType::Unknown, 0, 0.0, 0.0));
181 let timestamp = timestamps.get(i).copied().unwrap_or(0);
182
183 min_lon = min_lon.min(lon);
185 max_lon = max_lon.max(lon);
186 min_lat = min_lat.min(lat);
187 max_lat = max_lat.max(lat);
188 min_time = min_time.min(timestamp);
189 max_time = max_time.max(timestamp);
190
191 let estimated_size = 100 + (vertex_count * 16) + (property_count * 20);
193
194 features.push(AnalyzableFeature {
195 lon,
196 lat,
197 timestamp,
198 geometry_type: geom_type,
199 vertex_count,
200 estimated_size,
201 property_count,
202 });
203
204 if row_index % sample_stride == 0 && sample.len() < MAX_SAMPLE_FEATURES {
208 if let Some(geometry) = sample_geometry_at(&batch, &geom_col_name, i) {
209 sample.push(SampledFeature {
210 geometry,
211 timestamp_ms: timestamp,
212 properties: sample_properties_at(&batch, i, &geom_col_name, time_field),
213 });
214 }
215 }
216 row_index += 1;
217 }
218
219 if features.len() % 100_000 == 0 {
220 pb.set_message(format!("Loaded {} features...", features.len()));
221 }
222 }
223
224 pb.finish_with_message(format!("Loaded {} features", features.len()));
225
226 Ok(LoadedData {
227 features,
228 bounds: BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
229 time_range: TimeRange::new(min_time, max_time),
230 sample,
231 })
232}
233
234fn find_geometry_column(schema: &arrow::datatypes::Schema) -> Result<String> {
240 let common_names = ["geometry", "geom", "wkb_geometry", "the_geom", "shape"];
241
242 for name in common_names {
243 if schema.field_with_name(name).is_ok() {
244 return Ok(name.to_string());
245 }
246 }
247
248 for field in schema.fields() {
250 if matches!(field.data_type(), DataType::Binary | DataType::LargeBinary) {
251 return Ok(field.name().clone());
252 }
253 }
254
255 for field in schema.fields() {
257 if matches!(field.data_type(), DataType::Struct(_)) {
258 return Ok(field.name().clone());
259 }
260 }
261
262 let has_lon = schema.field_with_name("lon").is_ok()
264 || schema.field_with_name("longitude").is_ok()
265 || schema.field_with_name("x").is_ok();
266 let has_lat = schema.field_with_name("lat").is_ok()
267 || schema.field_with_name("latitude").is_ok()
268 || schema.field_with_name("y").is_ok();
269
270 if has_lon && has_lat {
271 return Ok("__lon_lat__".to_string());
272 }
273
274 anyhow::bail!("Could not find geometry column in Parquet schema")
275}
276
277fn extract_geometries_from_batch(
279 batch: &arrow::record_batch::RecordBatch,
280 geom_col_name: &str,
281) -> Result<Vec<(GeometryType, usize, f64, f64)>> {
282 let mut results = Vec::with_capacity(batch.num_rows());
283
284 if geom_col_name == "__lon_lat__" {
286 let lon_col = batch.column_by_name("lon")
287 .or_else(|| batch.column_by_name("longitude"))
288 .or_else(|| batch.column_by_name("x"));
289 let lat_col = batch.column_by_name("lat")
290 .or_else(|| batch.column_by_name("latitude"))
291 .or_else(|| batch.column_by_name("y"));
292
293 if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
294 if let (Some(lon_arr), Some(lat_arr)) = (
295 lon.as_any().downcast_ref::<Float64Array>(),
296 lat.as_any().downcast_ref::<Float64Array>(),
297 ) {
298 for i in 0..batch.num_rows() {
299 if lon_arr.is_valid(i) && lat_arr.is_valid(i) {
300 results.push((GeometryType::Point, 1, lon_arr.value(i), lat_arr.value(i)));
301 } else {
302 results.push((GeometryType::Unknown, 0, 0.0, 0.0));
303 }
304 }
305 return Ok(results);
306 }
307 }
308 anyhow::bail!("Expected lon/lat columns but could not read them");
309 }
310
311 let geom_col = batch.column_by_name(geom_col_name)
312 .ok_or_else(|| anyhow::anyhow!("Geometry column '{}' not found", geom_col_name))?;
313
314 if let Some(struct_array) = geom_col.as_any().downcast_ref::<arrow::array::StructArray>() {
316 let x_col = struct_array.column_by_name("x")
317 .or_else(|| struct_array.column_by_name("longitude"))
318 .or_else(|| struct_array.column_by_name("lon"));
319 let y_col = struct_array.column_by_name("y")
320 .or_else(|| struct_array.column_by_name("latitude"))
321 .or_else(|| struct_array.column_by_name("lat"));
322
323 if let (Some(x), Some(y)) = (x_col, y_col) {
324 if let (Some(x_arr), Some(y_arr)) = (
325 x.as_any().downcast_ref::<Float64Array>(),
326 y.as_any().downcast_ref::<Float64Array>(),
327 ) {
328 for i in 0..batch.num_rows() {
329 if x_arr.is_valid(i) && y_arr.is_valid(i) {
330 results.push((GeometryType::Point, 1, x_arr.value(i), y_arr.value(i)));
331 } else {
332 results.push((GeometryType::Unknown, 0, 0.0, 0.0));
333 }
334 }
335 return Ok(results);
336 }
337 }
338 }
339
340 if let Some(binary_array) = geom_col.as_any().downcast_ref::<arrow::array::BinaryArray>() {
342 for i in 0..batch.num_rows() {
343 if binary_array.is_valid(i) {
344 let wkb = binary_array.value(i);
345 if let Some((geom_type, vertex_count, lon, lat)) = parse_wkb_info(wkb) {
346 results.push((geom_type, vertex_count, lon, lat));
347 } else {
348 results.push((GeometryType::Unknown, 0, 0.0, 0.0));
349 }
350 } else {
351 results.push((GeometryType::Unknown, 0, 0.0, 0.0));
352 }
353 }
354 return Ok(results);
355 }
356
357 let lon_col = batch.column_by_name("lon")
359 .or_else(|| batch.column_by_name("longitude"))
360 .or_else(|| batch.column_by_name("x"));
361 let lat_col = batch.column_by_name("lat")
362 .or_else(|| batch.column_by_name("latitude"))
363 .or_else(|| batch.column_by_name("y"));
364
365 if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
366 if let (Some(lon_arr), Some(lat_arr)) = (
367 lon.as_any().downcast_ref::<Float64Array>(),
368 lat.as_any().downcast_ref::<Float64Array>(),
369 ) {
370 for i in 0..batch.num_rows() {
371 if lon_arr.is_valid(i) && lat_arr.is_valid(i) {
372 results.push((GeometryType::Point, 1, lon_arr.value(i), lat_arr.value(i)));
373 } else {
374 results.push((GeometryType::Unknown, 0, 0.0, 0.0));
375 }
376 }
377 return Ok(results);
378 }
379 }
380
381 anyhow::bail!("Could not extract geometries from column '{}'", geom_col_name)
382}
383
384const LONLAT_COLUMN_NAMES: &[&str] = &["lon", "longitude", "lat", "latitude", "x", "y"];
392
393fn sample_geometry_at(
397 batch: &arrow::record_batch::RecordBatch,
398 geom_col_name: &str,
399 row: usize,
400) -> Option<geo_types::Geometry<f64>> {
401 if geom_col_name != "__lon_lat__" {
402 if let Some(col) = batch.column_by_name(geom_col_name) {
403 if let Some(binary) = col.as_any().downcast_ref::<arrow::array::BinaryArray>() {
404 if !binary.is_valid(row) {
405 return None;
406 }
407 return parse_wkb_geometry(binary.value(row));
408 }
409 if let Some(struct_array) = col.as_any().downcast_ref::<arrow::array::StructArray>() {
410 if let Some(point) = struct_point_at(struct_array, row) {
411 return Some(point);
412 }
413 }
416 }
417 }
418 lonlat_point_at(batch, row)
419}
420
421fn struct_point_at(
423 struct_array: &arrow::array::StructArray,
424 row: usize,
425) -> Option<geo_types::Geometry<f64>> {
426 let x = struct_array
427 .column_by_name("x")
428 .or_else(|| struct_array.column_by_name("longitude"))
429 .or_else(|| struct_array.column_by_name("lon"))?;
430 let y = struct_array
431 .column_by_name("y")
432 .or_else(|| struct_array.column_by_name("latitude"))
433 .or_else(|| struct_array.column_by_name("lat"))?;
434 let x_arr = x.as_any().downcast_ref::<Float64Array>()?;
435 let y_arr = y.as_any().downcast_ref::<Float64Array>()?;
436 (x_arr.is_valid(row) && y_arr.is_valid(row)).then(|| {
437 geo_types::Geometry::Point(geo_types::Point::new(x_arr.value(row), y_arr.value(row)))
438 })
439}
440
441fn lonlat_point_at(
443 batch: &arrow::record_batch::RecordBatch,
444 row: usize,
445) -> Option<geo_types::Geometry<f64>> {
446 let lon = batch
447 .column_by_name("lon")
448 .or_else(|| batch.column_by_name("longitude"))
449 .or_else(|| batch.column_by_name("x"))?;
450 let lat = batch
451 .column_by_name("lat")
452 .or_else(|| batch.column_by_name("latitude"))
453 .or_else(|| batch.column_by_name("y"))?;
454 let lon_arr = lon.as_any().downcast_ref::<Float64Array>()?;
455 let lat_arr = lat.as_any().downcast_ref::<Float64Array>()?;
456 (lon_arr.is_valid(row) && lat_arr.is_valid(row)).then(|| {
457 geo_types::Geometry::Point(geo_types::Point::new(lon_arr.value(row), lat_arr.value(row)))
458 })
459}
460
461fn sample_properties_at(
465 batch: &arrow::record_batch::RecordBatch,
466 row: usize,
467 geom_col_name: &str,
468 time_field: &str,
469) -> Vec<(String, PropValue)> {
470 let schema = batch.schema();
471 let mut properties = Vec::new();
472 for (idx, field) in schema.fields().iter().enumerate() {
473 let name = field.name();
474 if name == geom_col_name || name == time_field {
475 continue;
476 }
477 if geom_col_name == "__lon_lat__" && LONLAT_COLUMN_NAMES.contains(&name.as_str()) {
478 continue;
479 }
480 if let Some(value) = prop_value_at(batch.column(idx).as_ref(), row) {
481 properties.push((name.clone(), value));
482 }
483 }
484 properties
485}
486
487fn prop_value_at(col: &dyn Array, row: usize) -> Option<PropValue> {
490 use arrow::array::{
491 Float32Array, Int16Array, Int32Array, Int8Array, LargeStringArray, UInt16Array,
492 UInt32Array, UInt64Array, UInt8Array,
493 };
494
495 if col.is_null(row) {
496 return None;
497 }
498 macro_rules! num {
499 ($t:ty) => {
500 col.as_any()
501 .downcast_ref::<$t>()
502 .map(|a| PropValue::Number(a.value(row) as f64))
503 };
504 }
505 match col.data_type() {
506 DataType::Float64 => num!(Float64Array),
507 DataType::Float32 => num!(Float32Array),
508 DataType::Int64 => num!(Int64Array),
509 DataType::Int32 => num!(Int32Array),
510 DataType::Int16 => num!(Int16Array),
511 DataType::Int8 => num!(Int8Array),
512 DataType::UInt64 => num!(UInt64Array),
513 DataType::UInt32 => num!(UInt32Array),
514 DataType::UInt16 => num!(UInt16Array),
515 DataType::UInt8 => num!(UInt8Array),
516 DataType::Utf8 => col
517 .as_any()
518 .downcast_ref::<StringArray>()
519 .map(|a| PropValue::Text(a.value(row).to_string())),
520 DataType::LargeUtf8 => col
521 .as_any()
522 .downcast_ref::<LargeStringArray>()
523 .map(|a| PropValue::Text(a.value(row).to_string())),
524 DataType::Dictionary(_, values)
527 if matches!(values.as_ref(), DataType::Utf8 | DataType::LargeUtf8) =>
528 {
529 let one = col.slice(row, 1);
530 let casted = arrow::compute::cast(one.as_ref(), &DataType::Utf8).ok()?;
531 let strings = casted.as_any().downcast_ref::<StringArray>()?;
532 strings
533 .is_valid(0)
534 .then(|| PropValue::Text(strings.value(0).to_string()))
535 }
536 _ => None,
537 }
538}
539
540fn extract_timestamps_from_batch(
542 batch: &arrow::record_batch::RecordBatch,
543 col_idx: usize,
544 time_format: &str,
545) -> Result<Vec<u64>> {
546 let column = batch.column(col_idx);
547 let mut timestamps = Vec::with_capacity(batch.num_rows());
548
549 macro_rules! push_ts_column {
556 ($arr:expr, $unit:expr) => {{
557 for i in 0..batch.num_rows() {
558 if $arr.is_valid(i) {
559 timestamps.push(normalize_timestamp_to_ms(i, $arr.value(i), $unit)?);
560 } else {
561 timestamps.push(0);
562 }
563 }
564 return Ok(timestamps);
565 }};
566 }
567
568 if let Some(ts_array) = column.as_any().downcast_ref::<TimestampSecondArray>() {
569 push_ts_column!(ts_array, TimestampUnit::Second);
570 }
571 if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMillisecondArray>() {
572 push_ts_column!(ts_array, TimestampUnit::Millisecond);
573 }
574 if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMicrosecondArray>() {
575 push_ts_column!(ts_array, TimestampUnit::Microsecond);
576 }
577 if let Some(ts_array) = column.as_any().downcast_ref::<TimestampNanosecondArray>() {
578 push_ts_column!(ts_array, TimestampUnit::Nanosecond);
579 }
580
581 if let Some(int_array) = column.as_any().downcast_ref::<Int64Array>() {
583 let unit = match time_format {
584 "unix-sec" => TimestampUnit::Second,
585 _ => TimestampUnit::Millisecond,
586 };
587 for i in 0..batch.num_rows() {
588 if int_array.is_valid(i) {
589 timestamps.push(normalize_timestamp_to_ms(i, int_array.value(i), unit)?);
590 } else {
591 timestamps.push(0);
592 }
593 }
594 return Ok(timestamps);
595 }
596
597 if let Some(str_array) = column.as_any().downcast_ref::<StringArray>() {
599 for i in 0..batch.num_rows() {
600 if str_array.is_valid(i) {
601 let s = str_array.value(i);
602 let ts = parse_iso8601(s).unwrap_or(0);
603 timestamps.push(ts);
604 } else {
605 timestamps.push(0);
606 }
607 }
608 return Ok(timestamps);
609 }
610
611 anyhow::bail!("Unsupported timestamp column type")
612}
613
614fn parse_iso8601(s: &str) -> Result<u64> {
616 use chrono::{DateTime, NaiveDateTime};
617
618 if let Ok(dt) = s.parse::<DateTime<chrono::Utc>>() {
619 return Ok(dt.timestamp_millis() as u64);
620 }
621
622 if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
623 return Ok(dt.and_utc().timestamp_millis() as u64);
624 }
625
626 if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
627 let dt = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
628 return Ok(dt.timestamp_millis() as u64);
629 }
630
631 anyhow::bail!("Failed to parse timestamp: {}", s)
632}
633
634pub fn parse_wkb_geometry(bytes: &[u8]) -> Option<geo_types::Geometry<f64>> {
644 use geozero::ToGeo;
645 geozero::wkb::Ewkb(bytes.to_vec()).to_geo().ok()
647}
648
649fn parse_wkb_info(wkb: &[u8]) -> Option<(GeometryType, usize, f64, f64)> {
651 let geom = parse_wkb_geometry(wkb)?;
652 let (lon, lat) = geometry_centroid(&geom)?;
653 Some((classify_geometry(&geom), count_vertices(&geom), lon, lat))
654}
655
656fn classify_geometry(geom: &geo_types::Geometry<f64>) -> GeometryType {
658 use geo_types::Geometry as G;
659 match geom {
660 G::Point(_) => GeometryType::Point,
661 G::Line(_) | G::LineString(_) => GeometryType::LineString,
662 G::Polygon(_) | G::Rect(_) | G::Triangle(_) => GeometryType::Polygon,
663 G::MultiPoint(_) => GeometryType::MultiPoint,
664 G::MultiLineString(_) => GeometryType::MultiLineString,
665 G::MultiPolygon(_) => GeometryType::MultiPolygon,
666 G::GeometryCollection(_) => GeometryType::Unknown,
667 }
668}
669
670fn polygon_vertex_count(polygon: &geo_types::Polygon<f64>) -> usize {
671 polygon.exterior().0.len()
672 + polygon.interiors().iter().map(|ring| ring.0.len()).sum::<usize>()
673}
674
675fn count_vertices(geom: &geo_types::Geometry<f64>) -> usize {
678 use geo_types::Geometry as G;
679 match geom {
680 G::Point(_) => 1,
681 G::Line(_) => 2,
682 G::LineString(ls) => ls.0.len(),
683 G::Polygon(polygon) => polygon_vertex_count(polygon),
684 G::MultiPoint(mp) => mp.0.len(),
685 G::MultiLineString(mls) => mls.0.iter().map(|ls| ls.0.len()).sum(),
686 G::MultiPolygon(mp) => mp.0.iter().map(polygon_vertex_count).sum(),
687 G::GeometryCollection(gc) => gc.0.iter().map(count_vertices).sum(),
688 G::Rect(_) => 4,
689 G::Triangle(_) => 3,
690 }
691}
692
693fn geometry_centroid(geom: &geo_types::Geometry<f64>) -> Option<(f64, f64)> {
696 use geo::algorithm::bounding_rect::BoundingRect;
697 use geo::algorithm::centroid::Centroid;
698
699 if let Some(c) = geom.centroid() {
700 return Some((c.x(), c.y()));
701 }
702 geom.bounding_rect().map(|rect| {
703 let c = rect.center();
704 (c.x, c.y)
705 })
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use geo_types::{Geometry, LineString, MultiPolygon, Point, Polygon};
712 use geozero::{CoordDimensions, ToWkb};
713
714 fn wkb(geom: &Geometry<f64>) -> Vec<u8> {
716 geom.to_wkb(CoordDimensions::xy()).expect("encode WKB fixture")
717 }
718
719 fn closed_ring(coords: &[(f64, f64)]) -> LineString<f64> {
720 LineString::from(coords.to_vec())
721 }
722
723 #[test]
724 fn parses_point() {
725 let bytes = wkb(&Geometry::Point(Point::new(1.5, -2.5)));
726 let (geom_type, vertices, lon, lat) = parse_wkb_info(&bytes).unwrap();
727 assert_eq!(geom_type, GeometryType::Point);
728 assert_eq!(vertices, 1);
729 assert_eq!((lon, lat), (1.5, -2.5));
730 }
731
732 #[test]
733 fn parses_linestring() {
734 let line = LineString::from(vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)]);
737 let bytes = wkb(&Geometry::LineString(line));
738 let (geom_type, vertices, lon, lat) = parse_wkb_info(&bytes).unwrap();
739 assert_eq!(geom_type, GeometryType::LineString);
740 assert_eq!(vertices, 3);
741 assert!((lon - 1.0).abs() < 1e-9);
742 assert!(lat.abs() < 1e-9);
743 }
744
745 #[test]
746 fn polygon_vertex_count_includes_interior_rings() {
747 let exterior = closed_ring(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0)]);
748 let interior = closed_ring(&[(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)]);
749 let bytes = wkb(&Geometry::Polygon(Polygon::new(exterior, vec![interior])));
750 let (geom_type, vertices, _, _) = parse_wkb_info(&bytes).unwrap();
751 assert_eq!(geom_type, GeometryType::Polygon);
752 assert_eq!(vertices, 10);
754 }
755
756 #[test]
757 fn multipolygon_counts_all_parts() {
758 let tri_a = Polygon::new(
759 closed_ring(&[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (0.0, 0.0)]),
760 vec![],
761 );
762 let tri_b = Polygon::new(
763 closed_ring(&[(10.0, 10.0), (11.0, 10.0), (10.0, 11.0), (10.0, 10.0)]),
764 vec![],
765 );
766 let bytes = wkb(&Geometry::MultiPolygon(MultiPolygon(vec![tri_a, tri_b])));
767 let (geom_type, vertices, _, _) = parse_wkb_info(&bytes).unwrap();
768 assert_eq!(geom_type, GeometryType::MultiPolygon);
769 assert_eq!(vertices, 8);
771 }
772
773 #[test]
774 fn unit_square_centroid() {
775 let square = Polygon::new(
776 closed_ring(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]),
777 vec![],
778 );
779 let bytes = wkb(&Geometry::Polygon(square));
780 let (_, _, lon, lat) = parse_wkb_info(&bytes).unwrap();
781 assert!((lon - 0.5).abs() < 1e-9);
782 assert!((lat - 0.5).abs() < 1e-9);
783 }
784
785 #[test]
786 fn malformed_bytes_return_none() {
787 assert!(parse_wkb_geometry(&[]).is_none());
788 assert!(parse_wkb_geometry(&[0x01, 0x02, 0x03]).is_none());
789 assert!(parse_wkb_geometry(&[0x01, 0x01, 0x00, 0x00, 0x00, 0x00]).is_none());
791 assert!(parse_wkb_info(&[0xff; 16]).is_none());
792 }
793
794 #[test]
795 fn load_geoparquet_retains_deterministic_sample() {
796 use arrow::array::{BinaryArray, Float64Array, Int64Array, StringArray};
797 use arrow::datatypes::{DataType, Field, Schema};
798 use parquet::arrow::ArrowWriter;
799 use std::sync::Arc;
800
801 let n = 120usize;
802 let wkbs: Vec<Vec<u8>> = (0..n)
803 .map(|i| wkb(&Geometry::Point(Point::new(-73.0 + i as f64 * 0.01, 45.0))))
804 .collect();
805 let schema = Arc::new(Schema::new(vec![
806 Field::new("geometry", DataType::Binary, false),
807 Field::new("timestamp", DataType::Int64, false),
808 Field::new("value", DataType::Float64, false),
809 Field::new("name", DataType::Utf8, false),
810 ]));
811 let batch = arrow::record_batch::RecordBatch::try_new(
812 schema.clone(),
813 vec![
814 Arc::new(BinaryArray::from_iter_values(wkbs.iter().map(|v| v.as_slice()))),
815 Arc::new(Int64Array::from(
816 (0..n as i64).map(|i| 1_600_000_000_000 + i * 1_000).collect::<Vec<_>>(),
817 )),
818 Arc::new(Float64Array::from(
819 (0..n).map(|i| i as f64 * 0.5).collect::<Vec<_>>(),
820 )),
821 Arc::new(StringArray::from(
822 (0..n).map(|i| format!("cat-{}", i % 3)).collect::<Vec<_>>(),
823 )),
824 ],
825 )
826 .unwrap();
827
828 let dir = tempfile::tempdir().unwrap();
829 let path = dir.path().join("sample.parquet");
830 let file = std::fs::File::create(&path).unwrap();
831 let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
832 writer.write(&batch).unwrap();
833 writer.close().unwrap();
834
835 let data = load_data(&DataSource::GeoParquet {
836 path,
837 time_field: "timestamp".to_string(),
838 time_format: "unix-ms".to_string(),
839 })
840 .unwrap();
841
842 assert_eq!(data.features.len(), n);
843 assert_eq!(data.sample.len(), n);
845 let s = &data.sample[3];
846 assert!(matches!(s.geometry, Geometry::Point(_)));
847 assert_eq!(s.timestamp_ms, 1_600_000_000_000 + 3 * 1_000);
848 assert_eq!(
849 s.properties,
850 vec![
851 ("value".to_string(), PropValue::Number(1.5)),
852 ("name".to_string(), PropValue::Text("cat-0".to_string())),
853 ]
854 );
855
856 let measured =
858 crate::measure::measure_sample(&data.sample, &crate::measure::MeasureSettings::default())
859 .unwrap()
860 .expect("sample is large enough to measure");
861 assert_eq!(measured.features, n);
862 assert_eq!(measured.geometry_kind, "point");
863 }
864
865 #[test]
866 fn prop_value_at_widens_numerics_and_copies_strings() {
867 use arrow::array::{DictionaryArray, Float64Array, Int32Array, StringArray};
868 use arrow::datatypes::Int32Type;
869
870 let floats = Float64Array::from(vec![Some(1.5), None]);
871 assert_eq!(prop_value_at(&floats, 0), Some(PropValue::Number(1.5)));
872 assert_eq!(prop_value_at(&floats, 1), None);
873
874 let ints = Int32Array::from(vec![7]);
875 assert_eq!(prop_value_at(&ints, 0), Some(PropValue::Number(7.0)));
876
877 let strings = StringArray::from(vec!["storm"]);
878 assert_eq!(
879 prop_value_at(&strings, 0),
880 Some(PropValue::Text("storm".to_string()))
881 );
882
883 let dict: DictionaryArray<Int32Type> = vec!["a", "b", "a"].into_iter().collect();
884 assert_eq!(prop_value_at(&dict, 2), Some(PropValue::Text("a".to_string())));
885 }
886}
887