1use crate::columnar::{PropertyKind, PropertyTypes};
4use anyhow::{Context, Result};
5use arrow::array::{Array, BinaryViewArray, Float32Array, Float64Array, Int64Array, LargeBinaryArray, ListArray, StringArray, TimestampSecondArray, TimestampMillisecondArray, TimestampMicrosecondArray, TimestampNanosecondArray};
6use arrow::datatypes::DataType;
7use geojson::{Feature, Geometry, Value as GeomValue};
8use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
9use parquet::file::metadata::KeyValue;
10use std::collections::HashMap;
11use std::fs::File;
12use std::path::Path;
13use std::sync::Arc;
14use stt_core::types::{BoundingBox, TimeRange};
15
16pub type SharedProperties = Arc<serde_json::Map<String, serde_json::Value>>;
18
19#[derive(Debug, Clone)]
21pub struct ParsedFeature {
22 pub geojson: Feature,
23 pub shared_properties: Option<SharedProperties>,
25 pub timestamp: u64,
26 pub end_timestamp: Option<u64>,
28 pub vertex_timestamps: Option<Vec<u64>>,
37 pub vertex_values: Option<Vec<f32>>,
42 pub vertex_value_matrix: Option<Vec<f32>>,
49 pub lon: f64,
50 pub lat: f64,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum InputStrictness {
65 Warn,
66 Strict,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Deserialize)]
77pub enum TimeFormat {
78 #[serde(rename = "iso8601")]
80 Iso8601,
81 #[serde(rename = "unix-sec")]
83 UnixSec,
84 #[serde(rename = "unix-ms")]
86 UnixMs,
87}
88
89impl TimeFormat {
90 pub fn as_str(&self) -> &'static str {
92 match self {
93 TimeFormat::Iso8601 => "iso8601",
94 TimeFormat::UnixSec => "unix-sec",
95 TimeFormat::UnixMs => "unix-ms",
96 }
97 }
98}
99
100pub fn load_features(
101 path: &Path,
102 time_field: &str,
103 end_time_field: Option<&str>,
104 time_format: TimeFormat,
105 time_strictness: InputStrictness,
106 geometry_strictness: InputStrictness,
107) -> Result<Vec<ParsedFeature>> {
108 let mut features = Vec::new();
109 let mut row_count = 0usize;
110 stream_features(path, time_field, end_time_field, time_format, time_strictness, geometry_strictness, |batch| {
111 row_count += batch.len();
112 features.extend(batch);
113 if row_count.is_multiple_of(100_000) {
114 tracing::info!("Loaded {} features...", row_count);
115 }
116 Ok(())
117 })?;
118 tracing::info!("Loaded {} total features", features.len());
119 Ok(features)
120}
121
122pub fn stream_features<F>(
133 path: &Path,
134 time_field: &str,
135 end_time_field: Option<&str>,
136 time_format: TimeFormat,
137 time_strictness: InputStrictness,
138 geometry_strictness: InputStrictness,
139 mut on_batch: F,
140) -> Result<()>
141where
142 F: FnMut(Vec<ParsedFeature>) -> Result<()>,
143{
144 let file = File::open(path).context("Failed to open GeoParquet file")?;
145 let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
146 let schema = builder.schema().clone();
147
148 let geo_meta = parse_geo_metadata(
151 builder.metadata().file_metadata().key_value_metadata(),
152 &schema,
153 );
154 let geom_col_name = find_geometry_column(&schema, geo_meta.as_ref())?;
155 if let Some(meta) = geo_meta.as_ref() {
156 validate_geo_column(meta, &geom_col_name, geometry_strictness)?;
157 }
158 let time_col_idx = schema
159 .fields()
160 .iter()
161 .position(|f| f.name() == time_field)
162 .ok_or_else(|| anyhow::anyhow!("Time field '{}' not found", time_field))?;
163 if time_format == TimeFormat::Iso8601
167 && matches!(schema.field(time_col_idx).data_type(), DataType::Int64)
168 {
169 tracing::warn!(
170 "--time-format iso8601 but time column '{}' is Int64; integer \
171 values are interpreted as unix-ms (pass --time-format unix-ms \
172 or unix-sec to make this explicit)",
173 time_field
174 );
175 }
176 let end_time_col_idx = end_time_field
177 .and_then(|field| schema.fields().iter().position(|f| f.name() == field));
178
179 let vertex_times_col_idx = schema.fields().iter().position(|f| f.name() == "vertex_timestamps");
184
185 let vertex_values_col_idx = schema.fields().iter().position(|f| f.name() == "vertex_values");
189
190 let vertex_value_matrix_col_idx =
194 schema.fields().iter().position(|f| f.name() == "vertex_value_matrix");
195
196 let reader = builder.build()?;
197
198 let property_cols: Vec<usize> = schema
200 .fields()
201 .iter()
202 .enumerate()
203 .filter_map(|(idx, field)| {
204 if is_property_column(field.name(), &geom_col_name, time_field, end_time_field) {
205 Some(idx)
206 } else {
207 None
208 }
209 })
210 .collect();
211
212 let mut row_count = 0usize;
213 let mut seen_props = vec![false; property_cols.len()];
217 let mut time_parse_failures = 0usize;
222 for batch_result in reader {
223 let batch = batch_result.context("Failed to read Parquet batch")?;
224 let parsed = parse_batch(
225 &batch,
226 &schema,
227 &geom_col_name,
228 time_col_idx,
229 end_time_col_idx,
230 vertex_times_col_idx,
231 vertex_values_col_idx,
232 vertex_value_matrix_col_idx,
233 &property_cols,
234 &mut seen_props,
235 time_format,
236 time_strictness,
237 geometry_strictness,
238 row_count,
239 &mut time_parse_failures,
240 )?;
241 row_count += batch.num_rows();
242 on_batch(parsed)?;
243 }
244 if row_count > 0 && time_parse_failures == row_count {
245 anyhow::bail!(
246 "all {row_count} rows in time column '{time_field}' were null or unparseable — \
247 refusing to write an archive whose every feature is coerced to epoch 0 \
248 (1970-01-01). Check --time-field/--time-format: zone-less ISO 8601 strings \
249 are read as UTC; integer columns are unix-ms unless --time-format unix-sec"
250 );
251 }
252 warn_dropped_property_columns(&schema, &property_cols, &seen_props, row_count);
253 Ok(())
254}
255
256fn warn_dropped_property_columns(
262 schema: &arrow::datatypes::Schema,
263 property_cols: &[usize],
264 seen: &[bool],
265 total_rows: usize,
266) {
267 if total_rows == 0 {
268 return;
269 }
270 let dropped: Vec<String> = property_cols
273 .iter()
274 .zip(seen)
275 .filter(|&(_, &s)| !s)
276 .map(|(&idx, _)| {
277 let field = schema.field(idx);
278 format!("{} ({})", field.name(), field.data_type())
279 })
280 .collect();
281 if !dropped.is_empty() {
282 tracing::warn!(
283 "{} source column(s) carried no value in any of {total_rows} rows and were \
284 dropped from tiles (unmappable Arrow column type — e.g. decimal/struct/binary — \
285 or entirely NULL): {}",
286 dropped.len(),
287 dropped.join(", ")
288 );
289 }
290}
291
292#[allow(clippy::too_many_arguments)]
296fn parse_batch(
297 batch: &arrow::record_batch::RecordBatch,
298 schema: &arrow::datatypes::Schema,
299 geom_col_name: &str,
300 time_col_idx: usize,
301 end_time_col_idx: Option<usize>,
302 vertex_times_col_idx: Option<usize>,
303 vertex_values_col_idx: Option<usize>,
304 vertex_value_matrix_col_idx: Option<usize>,
305 property_cols: &[usize],
306 seen_props: &mut [bool],
307 time_format: TimeFormat,
308 time_strictness: InputStrictness,
309 geometry_strictness: InputStrictness,
310 row_offset: usize,
311 time_parse_failures: &mut usize,
312) -> Result<Vec<ParsedFeature>> {
313 let geometries = extract_geometries_from_batch(batch, geom_col_name)?;
314 let (timestamps, batch_time_failures) =
315 extract_timestamps_from_batch(batch, time_col_idx, time_format, time_strictness, row_offset)?;
316 *time_parse_failures += batch_time_failures;
317 let end_timestamps = end_time_col_idx
318 .map(|idx| extract_timestamps_from_batch(batch, idx, time_format, time_strictness, row_offset))
319 .transpose()?
320 .map(|(ts, _)| ts);
321 let vertex_times = vertex_times_col_idx
322 .map(|idx| extract_vertex_timestamps_from_batch(batch, idx, row_offset))
323 .transpose()?;
324 let vertex_values = vertex_values_col_idx
325 .map(|idx| extract_vertex_values_from_batch(batch, idx))
326 .transpose()?;
327 let vertex_value_matrices = vertex_value_matrix_col_idx
330 .map(|idx| extract_vertex_values_from_batch(batch, idx))
331 .transpose()?;
332
333 let mut features = Vec::with_capacity(batch.num_rows());
334 let mut geometry_failures = 0usize;
335 for i in 0..batch.num_rows() {
336 let slot = geometries
337 .get(i)
338 .ok_or_else(|| anyhow::anyhow!("Missing geometry at row {}", row_offset + i))?;
339 let Some((geometry, lon, lat)) = slot.clone() else {
344 geometry_failures += 1;
345 if geometry_strictness == InputStrictness::Strict {
346 anyhow::bail!(
347 "row {}: null or unparseable geometry (rerun without \
348 --strict-geometry to skip such rows)",
349 row_offset + i
350 );
351 }
352 continue;
353 };
354 let timestamp = timestamps
355 .get(i)
356 .copied()
357 .ok_or_else(|| anyhow::anyhow!("Missing timestamp at row {}", row_offset + i))?;
358 let end_timestamp = end_timestamps.as_ref().and_then(|ts| ts.get(i).copied());
359 let row_vertex_times = vertex_times
360 .as_ref()
361 .and_then(|v| v.get(i).cloned().flatten());
362 let row_vertex_values = vertex_values
363 .as_ref()
364 .and_then(|v| v.get(i).cloned().flatten());
365 let row_vertex_value_matrix = vertex_value_matrices
366 .as_ref()
367 .and_then(|v| v.get(i).cloned().flatten());
368
369 let mut properties = serde_json::Map::new();
373 for (pc, &col_idx) in property_cols.iter().enumerate() {
374 if let Some(value) = extract_property_value(batch, col_idx, i) {
375 seen_props[pc] = true;
376 let name = schema.field(col_idx).name();
377 properties.insert(name.clone(), value);
378 }
379 }
380 let shared_properties = if properties.is_empty() {
381 None
382 } else {
383 Some(Arc::new(properties))
384 };
385
386 let feature = Feature {
387 bbox: None,
388 geometry: Some(geometry),
389 id: None,
390 properties: None,
391 foreign_members: None,
392 };
393 features.push(ParsedFeature {
394 geojson: feature,
395 shared_properties,
396 timestamp,
397 end_timestamp,
398 vertex_timestamps: row_vertex_times,
399 vertex_values: row_vertex_values,
400 vertex_value_matrix: row_vertex_value_matrix,
401 lon,
402 lat,
403 });
404 }
405 warn_geometry_failures(geometry_failures, batch.num_rows());
406 Ok(features)
407}
408
409pub fn is_coordinate_column_name(name: &str) -> bool {
419 matches!(name, "lon" | "lat" | "longitude" | "latitude" | "x" | "y")
420}
421
422pub const VERTEX_METADATA_COLUMNS: [&str; 3] =
427 ["vertex_timestamps", "vertex_values", "vertex_value_matrix"];
428
429pub fn is_vertex_metadata_column(name: &str) -> bool {
431 VERTEX_METADATA_COLUMNS.contains(&name)
432}
433
434fn is_property_column(
439 name: &str,
440 geom_col_name: &str,
441 time_field: &str,
442 end_time_field: Option<&str>,
443) -> bool {
444 !(name == geom_col_name
445 || name == time_field
446 || end_time_field.map(|f| name == f).unwrap_or(false)
447 || is_vertex_metadata_column(name)
448 || is_coordinate_column_name(name))
449}
450
451fn property_kind_for(dt: &DataType) -> Option<PropertyKind> {
456 match dt {
457 DataType::Float64 | DataType::Float32 | DataType::Int64 | DataType::Int32 => {
458 Some(PropertyKind::Numeric)
459 }
460 DataType::Utf8 | DataType::Boolean => Some(PropertyKind::Categorical),
461 _ => None,
462 }
463}
464
465pub fn property_kinds(
477 path: &Path,
478 time_field: &str,
479 end_time_field: Option<&str>,
480) -> Result<PropertyTypes> {
481 let file = File::open(path).context("Failed to open GeoParquet file")?;
482 let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
483 let schema = builder.schema().clone();
484 let geo_meta = parse_geo_metadata(
485 builder.metadata().file_metadata().key_value_metadata(),
486 &schema,
487 );
488 let geom_col_name = find_geometry_column(&schema, geo_meta.as_ref())?;
489
490 let mut kinds = PropertyTypes::new();
491 for field in schema.fields() {
492 if !is_property_column(field.name(), &geom_col_name, time_field, end_time_field) {
493 continue;
494 }
495 if let Some(kind) = property_kind_for(field.data_type()) {
496 kinds.insert(field.name().clone(), kind);
497 }
498 }
499 Ok(kinds)
500}
501
502fn extract_vertex_timestamps_from_batch(
503 batch: &arrow::record_batch::RecordBatch,
504 col_idx: usize,
505 row_offset: usize,
506) -> Result<Vec<Option<Vec<u64>>>> {
507 let column = batch.column(col_idx);
508 let list = column
509 .as_any()
510 .downcast_ref::<ListArray>()
511 .ok_or_else(|| anyhow::anyhow!("vertex_timestamps column is not a List array"))?;
512
513 let mut out: Vec<Option<Vec<u64>>> = Vec::with_capacity(batch.num_rows());
514 for row in 0..batch.num_rows() {
515 if !list.is_valid(row) {
516 out.push(None);
517 continue;
518 }
519 let values = list.value(row);
520 let row_no = row_offset + row;
527 let scale = |value: i64, unit: TimestampUnit| -> Result<u64> {
531 Ok(normalize_timestamp_to_ms(row_no, value, unit)?)
532 };
533 let row_times: Vec<u64> = if let Some(ts) =
534 values.as_any().downcast_ref::<TimestampSecondArray>()
535 {
536 (0..ts.len())
537 .map(|i| if ts.is_valid(i) { scale(ts.value(i), TimestampUnit::Second) } else { Ok(0) })
538 .collect::<Result<Vec<u64>>>()?
539 } else if let Some(ts) = values.as_any().downcast_ref::<TimestampMillisecondArray>() {
540 (0..ts.len())
541 .map(|i| if ts.is_valid(i) { scale(ts.value(i), TimestampUnit::Millisecond) } else { Ok(0) })
542 .collect::<Result<Vec<u64>>>()?
543 } else if let Some(ts) = values.as_any().downcast_ref::<TimestampMicrosecondArray>() {
544 (0..ts.len())
545 .map(|i| if ts.is_valid(i) { scale(ts.value(i), TimestampUnit::Microsecond) } else { Ok(0) })
546 .collect::<Result<Vec<u64>>>()?
547 } else if let Some(ts) = values.as_any().downcast_ref::<TimestampNanosecondArray>() {
548 (0..ts.len())
549 .map(|i| if ts.is_valid(i) { scale(ts.value(i), TimestampUnit::Nanosecond) } else { Ok(0) })
550 .collect::<Result<Vec<u64>>>()?
551 } else if let Some(ints) = values.as_any().downcast_ref::<Int64Array>() {
552 (0..ints.len())
553 .map(|i| if ints.is_valid(i) { scale(ints.value(i), TimestampUnit::Millisecond) } else { Ok(0) })
554 .collect::<Result<Vec<u64>>>()?
555 } else {
556 anyhow::bail!(
557 "vertex_timestamps child must be a Timestamp (second/millisecond/microsecond/\
558 nanosecond) or Int64 (raw ms) array; got {:?}",
559 values.data_type()
560 );
561 };
562 out.push(Some(row_times));
563 }
564 Ok(out)
565}
566
567fn extract_vertex_values_from_batch(
572 batch: &arrow::record_batch::RecordBatch,
573 col_idx: usize,
574) -> Result<Vec<Option<Vec<f32>>>> {
575 let column = batch.column(col_idx);
576 let list = column
577 .as_any()
578 .downcast_ref::<ListArray>()
579 .ok_or_else(|| anyhow::anyhow!("vertex_values column is not a List array"))?;
580
581 let mut out: Vec<Option<Vec<f32>>> = Vec::with_capacity(batch.num_rows());
582 for row in 0..batch.num_rows() {
583 if !list.is_valid(row) {
584 out.push(None);
585 continue;
586 }
587 let values = list.value(row);
588 let row_vals: Vec<f32> = if let Some(f32s) =
589 values.as_any().downcast_ref::<Float32Array>()
590 {
591 (0..f32s.len())
592 .map(|i| if f32s.is_valid(i) { f32s.value(i) } else { f32::NAN })
593 .collect()
594 } else if let Some(f64s) = values.as_any().downcast_ref::<Float64Array>() {
595 (0..f64s.len())
596 .map(|i| if f64s.is_valid(i) { f64s.value(i) as f32 } else { f32::NAN })
597 .collect()
598 } else {
599 anyhow::bail!(
600 "vertex_values child must be Float32 or Float64; got {:?}",
601 values.data_type()
602 );
603 };
604 out.push(Some(row_vals));
605 }
606 Ok(out)
607}
608
609pub fn calculate_bounds(features: &[ParsedFeature]) -> Result<(BoundingBox, TimeRange)> {
611 if features.is_empty() {
612 return Ok((
613 BoundingBox::new(-180.0, -90.0, 180.0, 90.0),
614 TimeRange::new(0, 0),
615 ));
616 }
617
618 let mut min_time = u64::MAX;
621 let mut max_time = u64::MIN;
622 for f in features {
623 min_time = min_time.min(f.timestamp);
624 max_time = max_time.max(f.end_timestamp.unwrap_or(f.timestamp));
625 }
626
627 let mut min_lon = f64::MAX;
635 let mut max_lon = f64::MIN;
636 let mut min_lat = f64::MAX;
637 let mut max_lat = f64::MIN;
638 let mut counted = 0usize;
639 let mut skipped_null_island = 0usize;
640 for f in features {
641 if f.lon == 0.0 && f.lat == 0.0 {
642 skipped_null_island += 1;
643 continue;
644 }
645 min_lon = min_lon.min(f.lon);
646 max_lon = max_lon.max(f.lon);
647 min_lat = min_lat.min(f.lat);
648 max_lat = max_lat.max(f.lat);
649 counted += 1;
650 }
651
652 if counted == 0 {
653 for f in features {
657 min_lon = min_lon.min(f.lon);
658 max_lon = max_lon.max(f.lon);
659 min_lat = min_lat.min(f.lat);
660 max_lat = max_lat.max(f.lat);
661 }
662 } else if skipped_null_island > 0 {
663 tracing::warn!(
664 "excluded {} null-island (0,0) feature(s) from archive bounds \
665 (likely coerced bad/missing geometry)",
666 skipped_null_island
667 );
668 }
669
670 Ok((
671 BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
672 TimeRange::new(min_time, max_time),
673 ))
674}
675
676#[derive(Debug, Default, serde::Deserialize)]
684struct GeoFileMeta {
685 #[serde(default)]
686 primary_column: Option<String>,
687 #[serde(default)]
688 columns: HashMap<String, GeoColumnMeta>,
689}
690
691#[derive(Debug, Default, serde::Deserialize)]
692struct GeoColumnMeta {
693 #[serde(default)]
694 encoding: Option<String>,
695 #[serde(default)]
698 crs: Option<serde_json::Value>,
699}
700
701fn parse_geo_metadata(
706 kv: Option<&Vec<KeyValue>>,
707 schema: &arrow::datatypes::Schema,
708) -> Option<GeoFileMeta> {
709 let raw = kv
710 .and_then(|entries| {
711 entries
712 .iter()
713 .find(|e| e.key == "geo")
714 .and_then(|e| e.value.clone())
715 })
716 .or_else(|| schema.metadata().get("geo").cloned())?;
717 match serde_json::from_str::<GeoFileMeta>(&raw) {
718 Ok(meta) => Some(meta),
719 Err(e) => {
720 tracing::warn!(
721 "ignoring malformed GeoParquet 'geo' footer metadata ({e}); \
722 falling back to geometry-column name heuristics"
723 );
724 None
725 }
726 }
727}
728
729fn crs_is_lonlat_wgs84(crs: &serde_json::Value) -> std::result::Result<(), String> {
733 let auth_code_ok = |auth: &str, code: &str| -> bool {
734 (auth.eq_ignore_ascii_case("OGC") && code.eq_ignore_ascii_case("CRS84"))
735 || (auth.eq_ignore_ascii_case("EPSG") && code == "4326")
736 };
737 match crs {
738 serde_json::Value::Null => Ok(()),
739 serde_json::Value::String(s) => {
740 let norm = s.trim();
744 let bare_ok = matches!(
745 norm.to_ascii_uppercase().as_str(),
746 "CRS84" | "4326" | "WGS 84" | "WGS84" | "WGS_1984" | "WGS84(DD)"
747 );
748 let ok = bare_ok
749 || match norm.rsplit_once(':') {
750 Some((head, code)) => {
751 let auth = head.rsplit(':').find(|p| !p.is_empty()).unwrap_or(head);
752 auth_code_ok(auth, code)
753 || (norm.to_ascii_lowercase().contains("ogc")
754 && code.eq_ignore_ascii_case("CRS84"))
755 }
756 None => false,
757 };
758 if ok { Ok(()) } else { Err(format!("'{norm}'")) }
759 }
760 serde_json::Value::Object(obj) => {
761 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
763 if let Some(id) = obj.get("id").and_then(|v| v.as_object()) {
764 let auth = id.get("authority").and_then(|v| v.as_str()).unwrap_or("");
765 let code = match id.get("code") {
766 Some(serde_json::Value::String(s)) => s.clone(),
767 Some(serde_json::Value::Number(n)) => n.to_string(),
768 _ => String::new(),
769 };
770 if auth_code_ok(auth, &code) {
771 return Ok(());
772 }
773 return Err(if name.is_empty() {
774 format!("{auth}:{code}")
775 } else {
776 format!("{auth}:{code} ({name})")
777 });
778 }
779 if matches!(name, "WGS 84 (CRS84)" | "WGS 84") {
781 Ok(())
782 } else if name.is_empty() {
783 Err("an unrecognized PROJJSON CRS without an authority id".to_string())
784 } else {
785 Err(format!("'{name}'"))
786 }
787 }
788 other => Err(format!("{other}")),
789 }
790}
791
792fn validate_geo_column(
806 meta: &GeoFileMeta,
807 geom_col_name: &str,
808 geometry_strictness: InputStrictness,
809) -> Result<()> {
810 let Some(col) = meta.columns.get(geom_col_name) else {
811 return Ok(());
812 };
813 if let Some(crs) = &col.crs {
814 if let Err(found) = crs_is_lonlat_wgs84(crs) {
815 if geometry_strictness == InputStrictness::Strict {
819 anyhow::bail!(
820 "GeoParquet geometry column '{geom_col_name}' declares CRS {found}, \
821 but stt-build requires lon/lat degrees (OGC:CRS84 / EPSG:4326). \
822 Reproject the input before export (e.g. geopandas: \
823 gdf.to_crs(4326).to_parquet(...))."
824 );
825 }
826 tracing::warn!(
827 "GeoParquet geometry column '{geom_col_name}' declares CRS {found}, but \
828 stt-build assumes WGS 84 lon/lat degrees (OGC:CRS84 / EPSG:4326) and \
829 tiles coordinates AS-IS — output tiles will be WRONG if the data is \
830 actually in another CRS. Reproject to EPSG:4326 first (e.g. geopandas: \
831 gdf.to_crs(4326).to_parquet(...)), or pass --strict-geometry to make \
832 this a hard error.",
833 );
834 }
835 }
836 if let Some(encoding) = col.encoding.as_deref() {
837 let unsupported = matches!(
838 encoding.to_ascii_lowercase().as_str(),
839 "linestring" | "polygon" | "multipoint" | "multilinestring" | "multipolygon"
840 );
841 if unsupported {
842 anyhow::bail!(
843 "GeoParquet geometry column '{geom_col_name}' uses the native \
844 geoarrow '{encoding}' encoding, which this reader cannot ingest. \
845 Re-export with WKB geometry encoding (e.g. geopandas: \
846 gdf.to_parquet(..., geometry_encoding='WKB'))."
847 );
848 }
849 }
850 Ok(())
851}
852
853fn find_geometry_column(
857 schema: &arrow::datatypes::Schema,
858 geo_meta: Option<&GeoFileMeta>,
859) -> Result<String> {
860 if let Some(primary) = geo_meta.and_then(|m| m.primary_column.as_deref()) {
861 if schema.field_with_name(primary).is_ok() {
862 return Ok(primary.to_string());
863 }
864 tracing::warn!(
865 "GeoParquet metadata names primary_column '{primary}' but the \
866 schema has no such column; falling back to name heuristics"
867 );
868 }
869
870 let common_names = ["geometry", "geom", "wkb_geometry", "the_geom", "shape"];
872
873 for name in common_names {
874 if schema.field_with_name(name).is_ok() {
875 return Ok(name.to_string());
876 }
877 }
878
879 for field in schema.fields() {
881 if matches!(
882 field.data_type(),
883 DataType::Binary | DataType::LargeBinary | DataType::BinaryView
884 ) {
885 return Ok(field.name().clone());
886 }
887 }
888
889 for field in schema.fields() {
891 if matches!(field.data_type(), DataType::Struct(_)) {
892 return Ok(field.name().clone());
893 }
894 }
895
896 let has_lon = schema.field_with_name("lon").is_ok()
898 || schema.field_with_name("longitude").is_ok()
899 || schema.field_with_name("x").is_ok();
900 let has_lat = schema.field_with_name("lat").is_ok()
901 || schema.field_with_name("latitude").is_ok()
902 || schema.field_with_name("y").is_ok();
903
904 if has_lon && has_lat {
905 return Ok("__lon_lat__".to_string());
906 }
907
908 anyhow::bail!("Could not find geometry column in Parquet schema. Expected columns: {:?}", common_names)
909}
910
911fn extract_geometries_from_batch(
916 batch: &arrow::record_batch::RecordBatch,
917 geom_col_name: &str,
918) -> Result<Vec<Option<(Geometry, f64, f64)>>> {
919 fn points_from_xy(
922 x_arr: &Float64Array,
923 y_arr: &Float64Array,
924 num_rows: usize,
925 ) -> Vec<Option<(Geometry, f64, f64)>> {
926 (0..num_rows)
927 .map(|i| {
928 if x_arr.is_valid(i) && y_arr.is_valid(i) {
929 let x = x_arr.value(i);
930 let y = y_arr.value(i);
931 Some((Geometry::new(GeomValue::Point(vec![x, y])), x, y))
932 } else {
933 None
934 }
935 })
936 .collect()
937 }
938
939 fn points_from_wkb<'a>(
941 rows: impl Iterator<Item = Option<&'a [u8]>>,
942 ) -> Vec<Option<(Geometry, f64, f64)>> {
943 rows.map(|wkb| wkb.and_then(parse_wkb_geometry)).collect()
944 }
945
946 if geom_col_name == "__lon_lat__" {
948 let lon_col = batch.column_by_name("lon")
949 .or_else(|| batch.column_by_name("longitude"))
950 .or_else(|| batch.column_by_name("x"));
951 let lat_col = batch.column_by_name("lat")
952 .or_else(|| batch.column_by_name("latitude"))
953 .or_else(|| batch.column_by_name("y"));
954
955 if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
956 if let (Some(lon_arr), Some(lat_arr)) = (
957 lon.as_any().downcast_ref::<Float64Array>(),
958 lat.as_any().downcast_ref::<Float64Array>(),
959 ) {
960 return Ok(points_from_xy(lon_arr, lat_arr, batch.num_rows()));
961 }
962 }
963 anyhow::bail!("Expected lon/lat columns but could not read them");
964 }
965
966 let geom_col = batch.column_by_name(geom_col_name)
967 .ok_or_else(|| anyhow::anyhow!("Geometry column '{}' not found", geom_col_name))?;
968
969 if let Some(struct_array) = geom_col.as_any().downcast_ref::<arrow::array::StructArray>() {
971 let x_col = struct_array.column_by_name("x")
972 .or_else(|| struct_array.column_by_name("longitude"))
973 .or_else(|| struct_array.column_by_name("lon"));
974 let y_col = struct_array.column_by_name("y")
975 .or_else(|| struct_array.column_by_name("latitude"))
976 .or_else(|| struct_array.column_by_name("lat"));
977
978 if let (Some(x), Some(y)) = (x_col, y_col) {
979 if let (Some(x_arr), Some(y_arr)) = (
980 x.as_any().downcast_ref::<Float64Array>(),
981 y.as_any().downcast_ref::<Float64Array>(),
982 ) {
983 return Ok(points_from_xy(x_arr, y_arr, batch.num_rows()));
984 }
985 }
986 }
987
988 if let Some(arr) = geom_col.as_any().downcast_ref::<arrow::array::BinaryArray>() {
990 return Ok(points_from_wkb(
991 (0..batch.num_rows()).map(|i| arr.is_valid(i).then(|| arr.value(i))),
992 ));
993 }
994 if let Some(arr) = geom_col.as_any().downcast_ref::<LargeBinaryArray>() {
995 return Ok(points_from_wkb(
996 (0..batch.num_rows()).map(|i| arr.is_valid(i).then(|| arr.value(i))),
997 ));
998 }
999 if let Some(arr) = geom_col.as_any().downcast_ref::<BinaryViewArray>() {
1000 return Ok(points_from_wkb(
1001 (0..batch.num_rows()).map(|i| arr.is_valid(i).then(|| arr.value(i))),
1002 ));
1003 }
1004
1005 let lon_col = batch.column_by_name("lon")
1007 .or_else(|| batch.column_by_name("longitude"))
1008 .or_else(|| batch.column_by_name("x"));
1009 let lat_col = batch.column_by_name("lat")
1010 .or_else(|| batch.column_by_name("latitude"))
1011 .or_else(|| batch.column_by_name("y"));
1012
1013 if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
1014 if let (Some(lon_arr), Some(lat_arr)) = (
1015 lon.as_any().downcast_ref::<Float64Array>(),
1016 lat.as_any().downcast_ref::<Float64Array>(),
1017 ) {
1018 return Ok(points_from_xy(lon_arr, lat_arr, batch.num_rows()));
1019 }
1020 }
1021
1022 anyhow::bail!(
1023 "Could not extract geometries from column '{}' (Arrow type {:?}). \
1024 Supported encodings: WKB (Binary/LargeBinary/BinaryView), separated \
1025 x/y point structs, or top-level lon/lat columns",
1026 geom_col_name,
1027 geom_col.data_type()
1028 )
1029}
1030
1031pub use stt_core::timestamp::{
1037 normalize_timestamp_to_ms, reject_negative_timestamp, scale_timestamp_to_ms, TimestampUnit,
1038};
1039
1040fn extract_timestamps_from_batch(
1045 batch: &arrow::record_batch::RecordBatch,
1046 col_idx: usize,
1047 time_format: TimeFormat,
1048 strictness: InputStrictness,
1049 row_offset: usize,
1050) -> Result<(Vec<u64>, usize)> {
1051 let column = batch.column(col_idx);
1052 let mut timestamps = Vec::with_capacity(batch.num_rows());
1053
1054 let mut parse_failures: usize = 0;
1058 let record_failure =
1059 |row: usize, parse_failures: &mut usize, reason: &str| -> Result<u64> {
1060 *parse_failures += 1;
1061 if strictness == InputStrictness::Strict {
1062 anyhow::bail!(
1063 "row {row}: {reason} (rerun without --strict-times to coerce to epoch 0)"
1064 );
1065 }
1066 Ok(0)
1067 };
1068
1069 macro_rules! push_timestamp_column {
1074 ($arr:expr, $unit:expr) => {{
1075 for i in 0..batch.num_rows() {
1076 if $arr.is_valid(i) {
1077 timestamps.push(normalize_timestamp_to_ms(row_offset + i, $arr.value(i), $unit)?);
1078 } else {
1079 timestamps.push(record_failure(row_offset + i, &mut parse_failures, "null timestamp")?);
1080 }
1081 }
1082 warn_timestamp_failures(parse_failures, batch.num_rows());
1083 return Ok((timestamps, parse_failures));
1084 }};
1085 }
1086
1087 if let Some(ts_array) = column.as_any().downcast_ref::<TimestampSecondArray>() {
1088 push_timestamp_column!(ts_array, TimestampUnit::Second);
1089 }
1090 if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMillisecondArray>() {
1091 push_timestamp_column!(ts_array, TimestampUnit::Millisecond);
1092 }
1093 if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMicrosecondArray>() {
1094 push_timestamp_column!(ts_array, TimestampUnit::Microsecond);
1095 }
1096 if let Some(ts_array) = column.as_any().downcast_ref::<TimestampNanosecondArray>() {
1097 push_timestamp_column!(ts_array, TimestampUnit::Nanosecond);
1098 }
1099
1100 if let Some(int_array) = column.as_any().downcast_ref::<Int64Array>() {
1105 let unit = match time_format {
1106 TimeFormat::UnixSec => TimestampUnit::Second,
1107 TimeFormat::UnixMs | TimeFormat::Iso8601 => TimestampUnit::Millisecond,
1108 };
1109 for i in 0..batch.num_rows() {
1110 if int_array.is_valid(i) {
1111 timestamps.push(normalize_timestamp_to_ms(row_offset + i, int_array.value(i), unit)?);
1112 } else {
1113 timestamps.push(record_failure(row_offset + i, &mut parse_failures, "null timestamp")?);
1114 }
1115 }
1116 warn_timestamp_failures(parse_failures, batch.num_rows());
1117 return Ok((timestamps, parse_failures));
1118 }
1119
1120 if let Some(str_array) = column.as_any().downcast_ref::<StringArray>() {
1122 for i in 0..batch.num_rows() {
1123 if str_array.is_valid(i) {
1124 let s = str_array.value(i);
1125 match parse_iso8601(s) {
1126 Ok(ms) => {
1127 reject_negative_timestamp(row_offset + i, ms)?;
1128 timestamps.push(ms as u64);
1129 }
1130 Err(_) => {
1131 let reason = format!("unparseable ISO8601 timestamp {s:?}");
1132 timestamps.push(record_failure(row_offset + i, &mut parse_failures, &reason)?);
1133 }
1134 }
1135 } else {
1136 timestamps.push(record_failure(row_offset + i, &mut parse_failures, "null timestamp")?);
1137 }
1138 }
1139 warn_timestamp_failures(parse_failures, batch.num_rows());
1140 return Ok((timestamps, parse_failures));
1141 }
1142
1143 anyhow::bail!(
1144 "unsupported timestamp column type {:?}: expected a Timestamp \
1145 (second/millisecond/microsecond/nanosecond), an Int64 (unix seconds or \
1146 milliseconds per --time-format), or an ISO 8601 String column",
1147 column.data_type()
1148 )
1149}
1150
1151fn warn_timestamp_failures(failures: usize, total: usize) {
1154 if failures > 0 {
1155 tracing::warn!(
1156 "{} of {} rows had null or unparseable timestamps; \
1157 these were coerced to Unix epoch 0 (1970-01-01)",
1158 failures,
1159 total
1160 );
1161 }
1162}
1163
1164fn warn_geometry_failures(failures: usize, total: usize) {
1167 if failures > 0 {
1168 tracing::warn!(
1169 "{} of {} rows had null or unparseable geometries and were \
1170 skipped (pass --strict-geometry to fail the build instead)",
1171 failures,
1172 total
1173 );
1174 }
1175}
1176
1177fn extract_property_value(
1179 batch: &arrow::record_batch::RecordBatch,
1180 col_idx: usize,
1181 row_idx: usize,
1182) -> Option<serde_json::Value> {
1183 let column = batch.column(col_idx);
1184
1185 if !column.is_valid(row_idx) {
1186 return None;
1187 }
1188
1189 if let Some(arr) = column.as_any().downcast_ref::<Float64Array>() {
1190 return Some(serde_json::json!(arr.value(row_idx)));
1191 }
1192 if let Some(arr) = column.as_any().downcast_ref::<Int64Array>() {
1193 return Some(serde_json::json!(arr.value(row_idx)));
1194 }
1195 if let Some(arr) = column.as_any().downcast_ref::<StringArray>() {
1196 return Some(serde_json::json!(arr.value(row_idx)));
1197 }
1198 if let Some(arr) = column.as_any().downcast_ref::<arrow::array::BooleanArray>() {
1199 return Some(serde_json::json!(arr.value(row_idx)));
1200 }
1201 if let Some(arr) = column.as_any().downcast_ref::<arrow::array::Float32Array>() {
1202 return Some(serde_json::json!(arr.value(row_idx) as f64));
1203 }
1204 if let Some(arr) = column.as_any().downcast_ref::<arrow::array::Int32Array>() {
1205 return Some(serde_json::json!(arr.value(row_idx) as i64));
1206 }
1207
1208 None
1209}
1210
1211pub(crate) fn parse_iso8601(s: &str) -> Result<i64> {
1215 use chrono::{DateTime, NaiveDateTime};
1216
1217 if let Ok(dt) = s.parse::<DateTime<chrono::Utc>>() {
1219 return Ok(dt.timestamp_millis());
1220 }
1221
1222 for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] {
1228 if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
1229 return Ok(dt.and_utc().timestamp_millis());
1230 }
1231 }
1232
1233 if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
1235 let dt = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
1236 return Ok(dt.timestamp_millis());
1237 }
1238
1239 anyhow::bail!("Failed to parse timestamp: {}", s)
1240}
1241
1242pub(crate) fn parse_wkb_geometry(wkb: &[u8]) -> Option<(Geometry, f64, f64)> {
1252 use geo::algorithm::centroid::Centroid;
1253 use geozero::ToGeo;
1254
1255 let geo_geom = geozero::wkb::Ewkb(wkb.to_vec()).to_geo().ok()?;
1257 let centroid = geo_geom.centroid()?;
1258 Some((
1259 Geometry::new(GeomValue::from(&geo_geom)),
1260 centroid.x(),
1261 centroid.y(),
1262 ))
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::parse_iso8601;
1268
1269 const SEP_28_2024_NOON_MS: i64 = 1_727_524_800_000; #[test]
1272 fn parses_zoned_timestamps() {
1273 assert_eq!(parse_iso8601("2024-09-28T12:00:00Z").unwrap(), SEP_28_2024_NOON_MS);
1274 assert_eq!(parse_iso8601("2024-09-28T14:00:00+02:00").unwrap(), SEP_28_2024_NOON_MS);
1275 }
1276
1277 #[test]
1278 fn parses_naive_timestamps_as_utc() {
1279 assert_eq!(parse_iso8601("2024-09-28T12:00:00").unwrap(), SEP_28_2024_NOON_MS);
1282 assert_eq!(parse_iso8601("2024-09-28 12:00:00").unwrap(), SEP_28_2024_NOON_MS);
1283 }
1284
1285 #[test]
1286 fn parses_naive_fractional_seconds() {
1287 assert_eq!(parse_iso8601("2024-09-28T12:00:00.250").unwrap(), SEP_28_2024_NOON_MS + 250);
1288 assert_eq!(parse_iso8601("2024-09-28 12:00:00.250").unwrap(), SEP_28_2024_NOON_MS + 250);
1289 }
1290
1291 #[test]
1292 fn parses_date_only_as_utc_midnight() {
1293 assert_eq!(parse_iso8601("2024-09-28").unwrap(), SEP_28_2024_NOON_MS - 12 * 3_600_000);
1294 }
1295
1296 #[test]
1297 fn rejects_garbage() {
1298 assert!(parse_iso8601("not a time").is_err());
1299 assert!(parse_iso8601("2024-13-40T99:00:00").is_err());
1300 assert!(parse_iso8601("").is_err());
1301 }
1302}