Skip to main content

stt_build/
input.rs

1//! GeoParquet input parsing and feature loading
2
3use 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
16/// Shared properties map to avoid cloning per-segment
17pub type SharedProperties = Arc<serde_json::Map<String, serde_json::Value>>;
18
19/// Parsed feature with geometry and temporal information
20#[derive(Debug, Clone)]
21pub struct ParsedFeature {
22    pub geojson: Feature,
23    /// Shared properties reference (avoids cloning during clipping)
24    pub shared_properties: Option<SharedProperties>,
25    pub timestamp: u64,
26    /// End timestamp for features with time ranges (if provided)
27    pub end_timestamp: Option<u64>,
28    /// Optional per-vertex absolute Unix-ms timestamps for LineString
29    /// geometries. When present, the line-layer builder uses these directly
30    /// instead of interpolating uniformly by distance — lets the producer
31    /// pass real per-segment timing (e.g. OSRM `annotations=duration`)
32    /// through to the GPU.
33    ///
34    /// MUST be the same length as the geometry's coord count when set;
35    /// length-mismatched values are dropped at the reader (logged once).
36    pub vertex_timestamps: Option<Vec<u64>>,
37    /// Optional per-vertex scalar values (producer-defined; e.g. sea-surface
38    /// temperature for the ocean-drifter dataset). Same length contract as
39    /// `vertex_timestamps`; flows through clipping and into the tile's
40    /// `vertex_value` column so renderers can color the line by it.
41    pub vertex_values: Option<Vec<f32>>,
42    /// Optional per-vertex × per-bucket value matrix, flattened **vertex-major**
43    /// (`matrix[v * num_buckets + b]`). Length is `coord_count * num_buckets`.
44    /// Carried through clipping (each bucket channel resampled like
45    /// `vertex_values`) into the tile's `vertex_value_matrix` column so a
46    /// static-geometry overview can animate per-bucket without re-emitting
47    /// geometry. Mutually exclusive with `vertex_values` in practice.
48    pub vertex_value_matrix: Option<Vec<f32>>,
49    pub lon: f64,
50    pub lat: f64,
51}
52
53/// Load all features from a GeoParquet file into memory.
54///
55/// This is the simple in-memory approach that works well for datasets
56/// that fit in RAM (up to several million features on a typical machine).
57/// Strictness for input parsing, applied independently to timestamps
58/// (`--strict-times`) and geometries (`--strict-geometry`). For timestamps,
59/// `Warn` keeps the legacy "coerce bad timestamps to epoch 0 and log"
60/// behaviour; for geometries, `Warn` skips the row entirely (a feature with
61/// no parseable position cannot be tiled). `Strict` aborts the build on the
62/// first parse failure with a row-counted error.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum InputStrictness {
65    Warn,
66    Strict,
67}
68
69/// Wire format of the `--time-field` column. Only consulted for integer
70/// (Int64) time columns — Arrow Timestamp columns are self-describing and
71/// String columns are always parsed as ISO 8601 regardless of this flag.
72///
73/// The `serde` rename spellings match the clap `ValueEnum` value names exactly,
74/// so a JSON config file (e.g. `stt-serve --config`) accepts the same
75/// `"iso8601"`/`"unix-sec"`/`"unix-ms"` strings the CLI does.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Deserialize)]
77pub enum TimeFormat {
78    /// ISO 8601 strings (e.g. `2024-06-21T12:00:00Z`).
79    #[serde(rename = "iso8601")]
80    Iso8601,
81    /// Integer seconds since the Unix epoch.
82    #[serde(rename = "unix-sec")]
83    UnixSec,
84    /// Integer milliseconds since the Unix epoch.
85    #[serde(rename = "unix-ms")]
86    UnixMs,
87}
88
89impl TimeFormat {
90    /// Canonical CLI spelling — matches the clap `ValueEnum` value names.
91    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
122/// Stream a GeoParquet input one record batch at a time, invoking
123/// `on_batch` with the materialised `ParsedFeature`s for that batch.
124///
125/// Peak memory is bounded by one Parquet batch (typically 1–8k rows) rather
126/// than the entire input. This is the entry point for the streaming build
127/// pipeline; downstream tilers must not retain references into prior
128/// batches across calls.
129///
130/// `on_batch` may consume or discard the batch; the function returns when
131/// the input is exhausted or when `on_batch` returns an error.
132pub 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    // GeoParquet `geo` footer metadata (absent for plain Parquet inputs with
149    // lon/lat columns — those keep working through the name heuristics).
150    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    // An Int64 time column can't hold ISO 8601 strings; the values fall back
164    // to the unix-ms interpretation. Surface the mismatch instead of letting
165    // the documented default silently mean unix-ms.
166    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    // Optional per-vertex timestamps column (List<Timestamp> or List<Int64>).
180    // Producers that have real per-segment timing (e.g. nyc-rideshare with
181    // OSRM annotations) populate this; absence falls back to legacy
182    // uniform-by-distance interpolation in columnar.rs.
183    let vertex_times_col_idx = schema.fields().iter().position(|f| f.name() == "vertex_timestamps");
184
185    // Optional per-vertex scalar column (List<Float32> or List<Float64>),
186    // e.g. sea-surface temperature for the ocean-drifter dataset. Aligned with
187    // the geometry vertices like `vertex_timestamps`.
188    let vertex_values_col_idx = schema.fields().iter().position(|f| f.name() == "vertex_values");
189
190    // Optional per-vertex × per-bucket value matrix (List<Float32>), flattened
191    // vertex-major. Present for static-geometry overviews (flow corridors);
192    // animated by selecting the active bucket column at render time.
193    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    // Property column indices computed once.
199    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    // Aligned with `property_cols`: flips true the first time the column
214    // yields a value, so columns that never do can be reported at EOF (the
215    // silent-drop accounting the PostGIS/DuckDB readers already have).
216    let mut seen_props = vec![false; property_cols.len()];
217    // Whole-input accounting for the main time column. Scattered failures are
218    // per-row dirt (warn + coerce to epoch 0); EVERY row failing means the
219    // column/format is wrong, and the all-1970 archive it would produce is
220    // silent garbage — fail the build instead, even in Warn mode.
221    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
256/// Warn once about property columns present in the source but carrying no
257/// value in any row — the silent-drop cases (unmappable Arrow column type, or
258/// entirely NULL) made visible so a missing tile column isn't a mystery.
259/// Mirrors the PostGIS/DuckDB readers' `warn_dropped_columns` so all three
260/// input adaptors degrade equally loudly.
261fn 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    // Report `name (arrow_type)` so an operator can tell an unmappable-type
271    // drop (e.g. `foo (Decimal128(38, 9))`) from an all-NULL column at a glance.
272    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/// Materialise one record batch into a `Vec<ParsedFeature>` without holding
293/// any other batches in memory. Pulled out of `stream_features` so the
294/// streaming loop and the eager `load_features` share one definition.
295#[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    // Matrix rows are flat List<Float32> just like vertex_values — reuse the
328    // same extractor; the bucket reshape happens at the renderer.
329    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        // A row whose geometry is null or unparseable has no position to tile
340        // at. Strict mode fails the build; Warn mode skips the row (it must
341        // NOT fall through as a (0,0) point — that tiled garbage at Null
342        // Island and dragged it into every zoom level).
343        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        // Build properties from non-meta columns. We only materialise this
370        // map when the row actually has property values; an empty map is
371        // dropped so empty rows pay no allocation.
372        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
409/// Extract optional per-row vertex-timestamp lists from a `vertex_timestamps`
410/// column. Tolerates both `List<Timestamp(Millisecond)>` and `List<Int64>`
411/// children (ms in either case). Null rows return `None` for that slot;
412/// non-list columns return an error.
413/// Column names every input adaptor treats as geometry-component coordinates
414/// rather than user properties — excluded from the property set so a tile never
415/// carries coordinates twice. Case-SENSITIVE lowercase. Shared by the GeoParquet
416/// file reader and the PostGIS/DuckDB readers so all adaptors exclude the
417/// identical set (one source of truth — see also [`is_vertex_metadata_column`]).
418pub fn is_coordinate_column_name(name: &str) -> bool {
419    matches!(name, "lon" | "lat" | "longitude" | "latitude" | "x" | "y")
420}
421
422/// The per-vertex array column names recognised as geometry metadata (LineString
423/// trajectory timing / per-vertex values / animated-overview matrix). These
424/// populate the `ParsedFeature.vertex_*` fields, not the property set. Shared
425/// across all input adaptors so a new reader can't silently disagree.
426pub const VERTEX_METADATA_COLUMNS: [&str; 3] =
427    ["vertex_timestamps", "vertex_values", "vertex_value_matrix"];
428
429/// Whether `name` is one of [`VERTEX_METADATA_COLUMNS`].
430pub fn is_vertex_metadata_column(name: &str) -> bool {
431    VERTEX_METADATA_COLUMNS.contains(&name)
432}
433
434/// Whether a source column is a user property (vs geometry / time / vertex
435/// metadata / coordinate component). The single predicate behind BOTH the
436/// row-reading property selection in [`stream_features`] and the schema-level
437/// [`property_kinds`] map, so the two can't disagree.
438fn 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
451/// Schema-level mirror of [`extract_property_value`]'s downcasts: the tile
452/// kind an Arrow property column of this type will produce. `None` =
453/// unmappable (decimal/struct/binary/…) — such columns yield no values and
454/// are reported by the EOF drop accounting.
455fn 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
465/// Derive the authoritative property-type map from a GeoParquet input's
466/// schema — the source of truth `TileConfig::property_types` wants so a
467/// column that happens to be all-null within one tile still gets its column
468/// there (per-tile value sniffing otherwise drops it and the layer schema
469/// drifts across tiles; see `ColumnarOptions::property_types`).
470///
471/// Uses the same geometry-column detection and property-column selection as
472/// [`stream_features`], and the same type mapping as its per-row value
473/// extraction. Note the schema is authoritative by design: a Utf8 column
474/// whose values all happen to look numeric stays Categorical (cast it in the
475/// source if you want a numeric column).
476pub 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        // The child can be a Timestamp array of ANY precision (Second/
521        // Millisecond/Microsecond/Nanosecond — all normalized to ms via the
522        // shared `normalize_timestamp_to_ms`, so this path, the scalar
523        // `--time-field` path, and the DuckDB reader agree) or a plain
524        // Int64Array (raw integer ms). A null element pushes `0` to preserve
525        // per-vertex alignment.
526        let row_no = row_offset + row;
527        // `normalize_timestamp_to_ms` now lives in stt-core and returns a
528        // `stt_core::Error`; map it into anyhow here so the `.collect::<Result<…>>()`
529        // (anyhow) call sites below infer the right error type.
530        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
567/// Extract optional per-row vertex-scalar lists from a `vertex_values` column.
568/// Tolerates `List<Float32>` and `List<Float64>` children. Null rows return
569/// `None` for that slot; null entries within a list become `NaN` so the
570/// per-vertex alignment is preserved. Non-list columns return an error.
571fn 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
609/// Calculate spatial and temporal bounds from features
610pub 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    // Time range spans every row — a row whose geometry failed to parse may
619    // still carry a valid timestamp.
620    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    // Spatial bounds exclude the null-island sentinel. The reader now skips
628    // rows with unparseable/null geometry entirely, but features built
629    // programmatically (tests, generators) can still carry the (0.0, 0.0)
630    // sentinel; including them would let a single bad row widen the archive
631    // bbox to the whole globe (and make the showcase open zoomed all the way
632    // out). Real data at precisely (0,0) to full f64 precision is effectively
633    // never legitimate.
634    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        // Degenerate: every feature sits at the sentinel (e.g. a dataset that
654        // genuinely straddles null island, or one that is entirely malformed).
655        // Fall back to the raw extent rather than return an inside-out bbox.
656        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// =============================================================================
677// Helper Functions
678// =============================================================================
679
680/// Parsed subset of the GeoParquet `geo` footer key-value metadata. Only the
681/// fields the reader acts on (geometry-column selection, CRS gate, encoding
682/// gate) are kept; everything else is ignored.
683#[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    /// PROJJSON object, an `"AUTH:CODE"` string, or absent/`null`
696    /// (= OGC:CRS84 per the GeoParquet spec).
697    #[serde(default)]
698    crs: Option<serde_json::Value>,
699}
700
701/// Read the GeoParquet `geo` entry from the Parquet footer key-value
702/// metadata (falling back to the Arrow schema metadata map). Returns `None`
703/// for plain Parquet inputs without the entry; malformed JSON is logged and
704/// treated as absent so non-GeoParquet producers can't brick a build.
705fn 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
729/// Check that a GeoParquet column CRS is lon/lat WGS 84 (OGC:CRS84 /
730/// EPSG:4326). Returns a human-readable name of the offending CRS otherwise.
731/// Absent / `null` means CRS84 per the GeoParquet spec.
732fn 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            // "EPSG:4326" / "OGC:CRS84" / urn:ogc:def:crs:OGC:1.3:CRS84 forms,
741            // plus the bare aliases producers commonly write ("CRS84", "4326",
742            // "WGS 84", "WGS84"). Match leniently — these are all WGS 84 lon/lat.
743            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            // PROJJSON: prefer the authority id, fall back to the name.
762            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            // No authority id — accept only the canonical CRS84 names.
780            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
792/// Enforce the GeoParquet column constraints the rest of the reader assumes:
793/// lon/lat WGS 84 coordinates, and an encoding the extractors can actually
794/// ingest (WKB or native point — the native linestring/polygon/multi*
795/// layouts have no extraction path here).
796///
797/// The CRS gate is advisory under `--strict-geometry == Warn`: a non-WGS84
798/// declaration is a *correctness hazard*, not an ingestion blocker — the whole
799/// pipeline assumes lon/lat degrees and will tile the raw coordinates as-is, so
800/// non-WGS84 input yields silently-wrong tiles. We warn loudly by default (so a
801/// mis-declared/already-lon-lat file still builds) and hard-fail under
802/// `--strict-geometry`. The encoding gate stays a hard error in both modes: an
803/// unsupported native encoding has no extraction path, so there is nothing to
804/// tile at all.
805fn 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            // Coordinates are consumed verbatim downstream; a non-WGS84 CRS
816            // means every tiled position is wrong. Fail in strict mode, warn
817            // (once, at load) otherwise.
818            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
853/// Find the geometry column in a Parquet schema. The GeoParquet
854/// `primary_column` declaration wins when present and resolvable; the name /
855/// type heuristics below cover plain Parquet inputs without `geo` metadata.
856fn 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    // Common geometry column names
871    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    // Look for binary columns that might contain WKB
880    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    // Look for struct columns (GeoArrow native encoding)
890    for field in schema.fields() {
891        if matches!(field.data_type(), DataType::Struct(_)) {
892            return Ok(field.name().clone());
893        }
894    }
895
896    // Check for separate lon/lat columns
897    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
911/// Extract geometries from a batch. A `None` slot means the row's geometry
912/// is null or unparseable — the caller decides whether to skip or bail
913/// (`--strict-geometry`). Such rows must never be materialised as (0,0)
914/// points: that tiles garbage at Null Island.
915fn extract_geometries_from_batch(
916    batch: &arrow::record_batch::RecordBatch,
917    geom_col_name: &str,
918) -> Result<Vec<Option<(Geometry, f64, f64)>>> {
919    // Point rows from a pair of x/y Float64 arrays (top-level lon/lat
920    // columns or the legs of a separated GeoArrow point struct).
921    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    // WKB rows from any binary-flavoured array (Binary/LargeBinary/BinaryView).
940    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    // Handle separate lon/lat columns
947    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    // Try GeoArrow struct
970    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    // Try WKB binary column — all three binary layouts carry the same bytes.
989    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    // Fallback: separate lon/lat columns
1006    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
1031// Timestamp-unit normalization now lives in `stt_core::timestamp` so every
1032// input adaptor (this GeoParquet reader's scalar + per-vertex paths, the DuckDB
1033// reader, and the stt-optimize analysis loader) shares ONE implementation. The
1034// re-exports below keep this module's historical call sites and public surface
1035// (`input::normalize_timestamp_to_ms`, `input::TimestampUnit`, etc.) unchanged.
1036pub use stt_core::timestamp::{
1037    normalize_timestamp_to_ms, reject_negative_timestamp, scale_timestamp_to_ms, TimestampUnit,
1038};
1039
1040/// Extract timestamps from a column. `row_offset` is the batch's absolute
1041/// row position in the file, used for error context. Returns the parsed
1042/// values plus the number of rows that failed (null or unparseable, coerced
1043/// to epoch 0 in Warn mode) so the caller can account across the whole input.
1044fn 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    // Count rows whose timestamp could not be parsed (null or invalid).
1055    // In Warn mode they are coerced to Unix epoch 0 and we log; in Strict
1056    // mode we fail the build on the first bad row with row context.
1057    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    // Arrow Timestamp columns are self-describing: scale any precision to ms
1070    // through the shared `normalize_timestamp_to_ms` (agrees with the per-vertex
1071    // path and the DuckDB reader). A null pushes epoch 0 (Warn) or fails
1072    // (Strict) via `record_failure`.
1073    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    // Try as i64 array (unix timestamp). The integer is interpreted per
1101    // `--time-format`: unix-sec ⇒ Second (×1000, overflow-checked), unix-ms /
1102    // iso8601 ⇒ Millisecond passthrough (Int64 + iso8601 warned once at schema
1103    // time in stream_features).
1104    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    // Try as string array (ISO8601)
1121    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
1151/// Emit a warning summary if any rows had unparseable/null timestamps that
1152/// were silently coerced to Unix epoch 0.
1153fn 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
1164/// Emit a warning summary if any rows had null/unparseable geometries and
1165/// were skipped (pass --strict-geometry to fail the build instead).
1166fn 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
1177/// Extract a property value from a column
1178fn 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
1211/// Parse ISO 8601 timestamp to Unix milliseconds. Returns the signed value
1212/// so the caller can reject pre-1970 (negative) instants explicitly rather
1213/// than letting them wrap through `as u64`.
1214pub(crate) fn parse_iso8601(s: &str) -> Result<i64> {
1215    use chrono::{DateTime, NaiveDateTime};
1216
1217    // Try parsing as DateTime with timezone
1218    if let Ok(dt) = s.parse::<DateTime<chrono::Utc>>() {
1219        return Ok(dt.timestamp_millis());
1220    }
1221
1222    // Zone-less (naive) datetimes are interpreted as UTC: both the T- and
1223    // space-separated forms, with optional fractional seconds (`%.f` also
1224    // matches no fraction). Real-world CSV/Parquet exports (e.g. NOAA Marine
1225    // Cadastre AIS BaseDateTime) are commonly `2024-09-28T12:00:00` with no
1226    // zone suffix, which the zoned parse above rejects.
1227    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    // Try parsing as date only
1234    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
1242// =============================================================================
1243// WKB Parsing
1244// =============================================================================
1245
1246/// Parse a WKB/EWKB blob into a GeoJSON geometry and its centroid `(lon, lat)`.
1247///
1248/// Delegates to `geozero`, which correctly handles 2D, 3D (WKB Z/M), and EWKB
1249/// (SRID-prefixed) inputs. The previous hand-rolled parser assumed a fixed 2D
1250/// 16-byte coordinate stride and silently misread any geometry carrying Z/M.
1251pub(crate) fn parse_wkb_geometry(wkb: &[u8]) -> Option<(Geometry, f64, f64)> {
1252    use geo::algorithm::centroid::Centroid;
1253    use geozero::ToGeo;
1254
1255    // `Ewkb` parses both plain ISO WKB and SRID-prefixed EWKB.
1256    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; // 2024-09-28T12:00:00Z
1270
1271    #[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        // The T-separated zone-less form (NOAA AIS BaseDateTime and most CSV
1280        // exports) and the space-separated form must agree, both = UTC.
1281        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}