Skip to main content

stt_optimize/
loader.rs

1//! Data loading for GeoParquet files
2//!
3//! Provides unified data loading from different sources for analysis.
4
5use anyhow::{Context, Result};
6use arrow::array::{Array, Float64Array, Int64Array, StringArray, TimestampSecondArray, TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray};
7use arrow::datatypes::DataType;
8use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
9use std::path::{Path, PathBuf};
10use stt_core::timestamp::{normalize_timestamp_to_ms, TimestampUnit};
11use stt_core::types::{BoundingBox, TimeRange};
12
13/// Data source specification
14#[derive(Debug, Clone)]
15pub enum DataSource {
16    GeoParquet {
17        path: PathBuf,
18        time_field: String,
19        time_format: String,
20    },
21}
22
23impl DataSource {
24    pub fn display_name(&self) -> String {
25        match self {
26            DataSource::GeoParquet { path, .. } => {
27                path.file_name()
28                    .map(|n| n.to_string_lossy().to_string())
29                    .unwrap_or_else(|| "unknown".to_string())
30            }
31        }
32    }
33}
34
35/// A loaded feature for analysis
36#[derive(Debug, Clone)]
37pub struct AnalyzableFeature {
38    /// Longitude (centroid for complex geometries)
39    pub lon: f64,
40    /// Latitude (centroid for complex geometries)
41    pub lat: f64,
42    /// Timestamp in milliseconds
43    pub timestamp: u64,
44    /// Geometry type
45    pub geometry_type: GeometryType,
46    /// Number of vertices in the geometry
47    pub vertex_count: usize,
48    /// Estimated serialized size in bytes
49    pub estimated_size: usize,
50    /// Number of properties
51    pub property_count: usize,
52}
53
54/// Geometry type classification
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum GeometryType {
57    Point,
58    LineString,
59    Polygon,
60    MultiPoint,
61    MultiLineString,
62    MultiPolygon,
63    Unknown,
64}
65
66impl std::fmt::Display for GeometryType {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            GeometryType::Point => write!(f, "Point"),
70            GeometryType::LineString => write!(f, "LineString"),
71            GeometryType::Polygon => write!(f, "Polygon"),
72            GeometryType::MultiPoint => write!(f, "MultiPoint"),
73            GeometryType::MultiLineString => write!(f, "MultiLineString"),
74            GeometryType::MultiPolygon => write!(f, "MultiPolygon"),
75            GeometryType::Unknown => write!(f, "Unknown"),
76        }
77    }
78}
79
80/// Sample cap for [`LoadedData::sample`]. Enough features for the measured
81/// sample encoding (`crate::measure`) to be representative while keeping the
82/// retained geometries/properties memory-bounded.
83const MAX_SAMPLE_FEATURES: usize = 5000;
84
85/// A sampled property value retained for sample-encode measurement.
86#[derive(Debug, Clone, PartialEq)]
87pub enum PropValue {
88    /// A numeric Arrow column value, widened to f64.
89    Number(f64),
90    /// A utf8 / dictionary-encoded string value.
91    Text(String),
92}
93
94/// One deterministically-sampled source feature, retaining the full geometry
95/// and property values so `crate::measure` can push it through the real
96/// stt-core encoder. Only sampled rows pay this retention cost; the bulk
97/// [`AnalyzableFeature`] path keeps its compact summary shape.
98#[derive(Debug, Clone)]
99pub struct SampledFeature {
100    /// Full parsed geometry (WGS84 lon/lat).
101    pub geometry: geo_types::Geometry<f64>,
102    /// Timestamp in Unix milliseconds.
103    pub timestamp_ms: u64,
104    /// Property `(name, value)` pairs; columns with unsupported Arrow types
105    /// and null values are omitted.
106    pub properties: Vec<(String, PropValue)>,
107}
108
109/// Loaded dataset for analysis
110#[derive(Debug)]
111pub struct LoadedData {
112    pub features: Vec<AnalyzableFeature>,
113    pub bounds: BoundingBox,
114    pub time_range: TimeRange,
115    /// Deterministic stride sample (never random — the same file always yields
116    /// the same sample) of at most [`MAX_SAMPLE_FEATURES`] features, retained
117    /// in full for measured sample encoding.
118    pub sample: Vec<SampledFeature>,
119}
120
121/// Load data from a data source
122pub fn load_data(source: &DataSource) -> Result<LoadedData> {
123    match source {
124        DataSource::GeoParquet { path, time_field, time_format } => {
125            load_geoparquet(path, time_field, time_format)
126        }
127    }
128}
129
130/// Load features from a GeoParquet file
131fn load_geoparquet(path: &Path, time_field: &str, time_format: &str) -> Result<LoadedData> {
132    use indicatif::{ProgressBar, ProgressStyle};
133
134    let pb = ProgressBar::new_spinner();
135    pb.set_style(
136        ProgressStyle::default_spinner()
137            .template("{spinner:.green} {msg}")
138            .unwrap(),
139    );
140    pb.set_message("Loading GeoParquet file...");
141
142    let file = std::fs::File::open(path).context("Failed to open GeoParquet file")?;
143    let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
144    let schema = builder.schema().clone();
145
146    // Find geometry and time columns
147    let geom_col_name = find_geometry_column(&schema)?;
148    let time_col_idx = schema.fields().iter().position(|f| f.name() == time_field)
149        .ok_or_else(|| anyhow::anyhow!("Time field '{}' not found", time_field))?;
150
151    // Deterministic stride sample: every Nth row (first row included), sized
152    // so at most MAX_SAMPLE_FEATURES rows are retained across the whole file.
153    let total_rows = builder.metadata().file_metadata().num_rows().max(0) as usize;
154    let sample_stride = ((total_rows + MAX_SAMPLE_FEATURES - 1) / MAX_SAMPLE_FEATURES).max(1);
155    let mut sample: Vec<SampledFeature> = Vec::new();
156    let mut row_index: usize = 0;
157
158    let reader = builder.build()?;
159
160    let mut features = Vec::new();
161    let mut min_lon = f64::MAX;
162    let mut max_lon = f64::MIN;
163    let mut min_lat = f64::MAX;
164    let mut max_lat = f64::MIN;
165    let mut min_time = u64::MAX;
166    let mut max_time = u64::MIN;
167
168    for batch_result in reader {
169        let batch = batch_result.context("Failed to read Parquet batch")?;
170
171        let geometries = extract_geometries_from_batch(&batch, &geom_col_name)?;
172        let timestamps = extract_timestamps_from_batch(&batch, time_col_idx, time_format)?;
173
174        // Count property columns
175        let property_count = schema.fields().len() - 2; // Exclude geometry and time
176
177        for i in 0..batch.num_rows() {
178            let (geom_type, vertex_count, lon, lat) = geometries.get(i)
179                .cloned()
180                .unwrap_or((GeometryType::Unknown, 0, 0.0, 0.0));
181            let timestamp = timestamps.get(i).copied().unwrap_or(0);
182
183            // Update bounds
184            min_lon = min_lon.min(lon);
185            max_lon = max_lon.max(lon);
186            min_lat = min_lat.min(lat);
187            max_lat = max_lat.max(lat);
188            min_time = min_time.min(timestamp);
189            max_time = max_time.max(timestamp);
190
191            // Estimate size: base overhead + vertices + properties
192            let estimated_size = 100 + (vertex_count * 16) + (property_count * 20);
193
194            features.push(AnalyzableFeature {
195                lon,
196                lat,
197                timestamp,
198                geometry_type: geom_type,
199                vertex_count,
200                estimated_size,
201                property_count,
202            });
203
204            // Sample retention: rows on the stride keep their full geometry
205            // and property values (rows whose geometry fails to parse are
206            // skipped, matching the (Unknown, 0, ...) summary above).
207            if row_index % sample_stride == 0 && sample.len() < MAX_SAMPLE_FEATURES {
208                if let Some(geometry) = sample_geometry_at(&batch, &geom_col_name, i) {
209                    sample.push(SampledFeature {
210                        geometry,
211                        timestamp_ms: timestamp,
212                        properties: sample_properties_at(&batch, i, &geom_col_name, time_field),
213                    });
214                }
215            }
216            row_index += 1;
217        }
218
219        if features.len() % 100_000 == 0 {
220            pb.set_message(format!("Loaded {} features...", features.len()));
221        }
222    }
223
224    pb.finish_with_message(format!("Loaded {} features", features.len()));
225
226    Ok(LoadedData {
227        features,
228        bounds: BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
229        time_range: TimeRange::new(min_time, max_time),
230        sample,
231    })
232}
233
234// =============================================================================
235// Helper Functions
236// =============================================================================
237
238/// Find the geometry column in a Parquet schema
239fn find_geometry_column(schema: &arrow::datatypes::Schema) -> Result<String> {
240    let common_names = ["geometry", "geom", "wkb_geometry", "the_geom", "shape"];
241
242    for name in common_names {
243        if schema.field_with_name(name).is_ok() {
244            return Ok(name.to_string());
245        }
246    }
247
248    // Look for binary columns (WKB)
249    for field in schema.fields() {
250        if matches!(field.data_type(), DataType::Binary | DataType::LargeBinary) {
251            return Ok(field.name().clone());
252        }
253    }
254
255    // Look for struct columns (GeoArrow)
256    for field in schema.fields() {
257        if matches!(field.data_type(), DataType::Struct(_)) {
258            return Ok(field.name().clone());
259        }
260    }
261
262    // Check for separate lon/lat columns
263    let has_lon = schema.field_with_name("lon").is_ok()
264        || schema.field_with_name("longitude").is_ok()
265        || schema.field_with_name("x").is_ok();
266    let has_lat = schema.field_with_name("lat").is_ok()
267        || schema.field_with_name("latitude").is_ok()
268        || schema.field_with_name("y").is_ok();
269
270    if has_lon && has_lat {
271        return Ok("__lon_lat__".to_string());
272    }
273
274    anyhow::bail!("Could not find geometry column in Parquet schema")
275}
276
277/// Extract geometries from a batch
278fn extract_geometries_from_batch(
279    batch: &arrow::record_batch::RecordBatch,
280    geom_col_name: &str,
281) -> Result<Vec<(GeometryType, usize, f64, f64)>> {
282    let mut results = Vec::with_capacity(batch.num_rows());
283
284    // Handle separate lon/lat columns
285    if geom_col_name == "__lon_lat__" {
286        let lon_col = batch.column_by_name("lon")
287            .or_else(|| batch.column_by_name("longitude"))
288            .or_else(|| batch.column_by_name("x"));
289        let lat_col = batch.column_by_name("lat")
290            .or_else(|| batch.column_by_name("latitude"))
291            .or_else(|| batch.column_by_name("y"));
292
293        if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
294            if let (Some(lon_arr), Some(lat_arr)) = (
295                lon.as_any().downcast_ref::<Float64Array>(),
296                lat.as_any().downcast_ref::<Float64Array>(),
297            ) {
298                for i in 0..batch.num_rows() {
299                    if lon_arr.is_valid(i) && lat_arr.is_valid(i) {
300                        results.push((GeometryType::Point, 1, lon_arr.value(i), lat_arr.value(i)));
301                    } else {
302                        results.push((GeometryType::Unknown, 0, 0.0, 0.0));
303                    }
304                }
305                return Ok(results);
306            }
307        }
308        anyhow::bail!("Expected lon/lat columns but could not read them");
309    }
310
311    let geom_col = batch.column_by_name(geom_col_name)
312        .ok_or_else(|| anyhow::anyhow!("Geometry column '{}' not found", geom_col_name))?;
313
314    // Try GeoArrow struct
315    if let Some(struct_array) = geom_col.as_any().downcast_ref::<arrow::array::StructArray>() {
316        let x_col = struct_array.column_by_name("x")
317            .or_else(|| struct_array.column_by_name("longitude"))
318            .or_else(|| struct_array.column_by_name("lon"));
319        let y_col = struct_array.column_by_name("y")
320            .or_else(|| struct_array.column_by_name("latitude"))
321            .or_else(|| struct_array.column_by_name("lat"));
322
323        if let (Some(x), Some(y)) = (x_col, y_col) {
324            if let (Some(x_arr), Some(y_arr)) = (
325                x.as_any().downcast_ref::<Float64Array>(),
326                y.as_any().downcast_ref::<Float64Array>(),
327            ) {
328                for i in 0..batch.num_rows() {
329                    if x_arr.is_valid(i) && y_arr.is_valid(i) {
330                        results.push((GeometryType::Point, 1, x_arr.value(i), y_arr.value(i)));
331                    } else {
332                        results.push((GeometryType::Unknown, 0, 0.0, 0.0));
333                    }
334                }
335                return Ok(results);
336            }
337        }
338    }
339
340    // Try WKB binary column
341    if let Some(binary_array) = geom_col.as_any().downcast_ref::<arrow::array::BinaryArray>() {
342        for i in 0..batch.num_rows() {
343            if binary_array.is_valid(i) {
344                let wkb = binary_array.value(i);
345                if let Some((geom_type, vertex_count, lon, lat)) = parse_wkb_info(wkb) {
346                    results.push((geom_type, vertex_count, lon, lat));
347                } else {
348                    results.push((GeometryType::Unknown, 0, 0.0, 0.0));
349                }
350            } else {
351                results.push((GeometryType::Unknown, 0, 0.0, 0.0));
352            }
353        }
354        return Ok(results);
355    }
356
357    // Fallback: try separate lon/lat columns
358    let lon_col = batch.column_by_name("lon")
359        .or_else(|| batch.column_by_name("longitude"))
360        .or_else(|| batch.column_by_name("x"));
361    let lat_col = batch.column_by_name("lat")
362        .or_else(|| batch.column_by_name("latitude"))
363        .or_else(|| batch.column_by_name("y"));
364
365    if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
366        if let (Some(lon_arr), Some(lat_arr)) = (
367            lon.as_any().downcast_ref::<Float64Array>(),
368            lat.as_any().downcast_ref::<Float64Array>(),
369        ) {
370            for i in 0..batch.num_rows() {
371                if lon_arr.is_valid(i) && lat_arr.is_valid(i) {
372                    results.push((GeometryType::Point, 1, lon_arr.value(i), lat_arr.value(i)));
373                } else {
374                    results.push((GeometryType::Unknown, 0, 0.0, 0.0));
375                }
376            }
377            return Ok(results);
378        }
379    }
380
381    anyhow::bail!("Could not extract geometries from column '{}'", geom_col_name)
382}
383
384// =============================================================================
385// Sample retention (full geometry + property values for measured encoding)
386// =============================================================================
387
388/// Column names that can serve as a bare lon/lat geometry pair (see
389/// `find_geometry_column`); excluded from sampled properties for `__lon_lat__`
390/// sources so the coordinates aren't double-counted as numeric columns.
391const LONLAT_COLUMN_NAMES: &[&str] = &["lon", "longitude", "lat", "latitude", "x", "y"];
392
393/// Full geometry for one sampled row, mirroring `extract_geometries_from_batch`'s
394/// column resolution: WKB parses through [`parse_wkb_geometry`]; GeoArrow
395/// structs and bare lon/lat column pairs become Points.
396fn sample_geometry_at(
397    batch: &arrow::record_batch::RecordBatch,
398    geom_col_name: &str,
399    row: usize,
400) -> Option<geo_types::Geometry<f64>> {
401    if geom_col_name != "__lon_lat__" {
402        if let Some(col) = batch.column_by_name(geom_col_name) {
403            if let Some(binary) = col.as_any().downcast_ref::<arrow::array::BinaryArray>() {
404                if !binary.is_valid(row) {
405                    return None;
406                }
407                return parse_wkb_geometry(binary.value(row));
408            }
409            if let Some(struct_array) = col.as_any().downcast_ref::<arrow::array::StructArray>() {
410                if let Some(point) = struct_point_at(struct_array, row) {
411                    return Some(point);
412                }
413                // Struct without readable x/y falls through to the lon/lat
414                // fallback, like the summary extractor.
415            }
416        }
417    }
418    lonlat_point_at(batch, row)
419}
420
421/// Point geometry from a GeoArrow-style struct column's x/y children.
422fn struct_point_at(
423    struct_array: &arrow::array::StructArray,
424    row: usize,
425) -> Option<geo_types::Geometry<f64>> {
426    let x = struct_array
427        .column_by_name("x")
428        .or_else(|| struct_array.column_by_name("longitude"))
429        .or_else(|| struct_array.column_by_name("lon"))?;
430    let y = struct_array
431        .column_by_name("y")
432        .or_else(|| struct_array.column_by_name("latitude"))
433        .or_else(|| struct_array.column_by_name("lat"))?;
434    let x_arr = x.as_any().downcast_ref::<Float64Array>()?;
435    let y_arr = y.as_any().downcast_ref::<Float64Array>()?;
436    (x_arr.is_valid(row) && y_arr.is_valid(row)).then(|| {
437        geo_types::Geometry::Point(geo_types::Point::new(x_arr.value(row), y_arr.value(row)))
438    })
439}
440
441/// Point geometry from bare lon/lat columns.
442fn lonlat_point_at(
443    batch: &arrow::record_batch::RecordBatch,
444    row: usize,
445) -> Option<geo_types::Geometry<f64>> {
446    let lon = batch
447        .column_by_name("lon")
448        .or_else(|| batch.column_by_name("longitude"))
449        .or_else(|| batch.column_by_name("x"))?;
450    let lat = batch
451        .column_by_name("lat")
452        .or_else(|| batch.column_by_name("latitude"))
453        .or_else(|| batch.column_by_name("y"))?;
454    let lon_arr = lon.as_any().downcast_ref::<Float64Array>()?;
455    let lat_arr = lat.as_any().downcast_ref::<Float64Array>()?;
456    (lon_arr.is_valid(row) && lat_arr.is_valid(row)).then(|| {
457        geo_types::Geometry::Point(geo_types::Point::new(lon_arr.value(row), lat_arr.value(row)))
458    })
459}
460
461/// Property values for one sampled row: every column except the geometry, the
462/// time field, and (for `__lon_lat__` sources) the coordinate pair itself.
463/// Columns with unsupported Arrow types are omitted.
464fn sample_properties_at(
465    batch: &arrow::record_batch::RecordBatch,
466    row: usize,
467    geom_col_name: &str,
468    time_field: &str,
469) -> Vec<(String, PropValue)> {
470    let schema = batch.schema();
471    let mut properties = Vec::new();
472    for (idx, field) in schema.fields().iter().enumerate() {
473        let name = field.name();
474        if name == geom_col_name || name == time_field {
475            continue;
476        }
477        if geom_col_name == "__lon_lat__" && LONLAT_COLUMN_NAMES.contains(&name.as_str()) {
478            continue;
479        }
480        if let Some(value) = prop_value_at(batch.column(idx).as_ref(), row) {
481            properties.push((name.clone(), value));
482        }
483    }
484    properties
485}
486
487/// One property value as a [`PropValue`]: numeric Arrow types widen to f64,
488/// utf8/dictionary strings copy out. Nulls and unsupported types yield `None`.
489fn prop_value_at(col: &dyn Array, row: usize) -> Option<PropValue> {
490    use arrow::array::{
491        Float32Array, Int16Array, Int32Array, Int8Array, LargeStringArray, UInt16Array,
492        UInt32Array, UInt64Array, UInt8Array,
493    };
494
495    if col.is_null(row) {
496        return None;
497    }
498    macro_rules! num {
499        ($t:ty) => {
500            col.as_any()
501                .downcast_ref::<$t>()
502                .map(|a| PropValue::Number(a.value(row) as f64))
503        };
504    }
505    match col.data_type() {
506        DataType::Float64 => num!(Float64Array),
507        DataType::Float32 => num!(Float32Array),
508        DataType::Int64 => num!(Int64Array),
509        DataType::Int32 => num!(Int32Array),
510        DataType::Int16 => num!(Int16Array),
511        DataType::Int8 => num!(Int8Array),
512        DataType::UInt64 => num!(UInt64Array),
513        DataType::UInt32 => num!(UInt32Array),
514        DataType::UInt16 => num!(UInt16Array),
515        DataType::UInt8 => num!(UInt8Array),
516        DataType::Utf8 => col
517            .as_any()
518            .downcast_ref::<StringArray>()
519            .map(|a| PropValue::Text(a.value(row).to_string())),
520        DataType::LargeUtf8 => col
521            .as_any()
522            .downcast_ref::<LargeStringArray>()
523            .map(|a| PropValue::Text(a.value(row).to_string())),
524        // Dictionary<*, Utf8>: cast the single sampled row rather than
525        // matching every possible key width.
526        DataType::Dictionary(_, values)
527            if matches!(values.as_ref(), DataType::Utf8 | DataType::LargeUtf8) =>
528        {
529            let one = col.slice(row, 1);
530            let casted = arrow::compute::cast(one.as_ref(), &DataType::Utf8).ok()?;
531            let strings = casted.as_any().downcast_ref::<StringArray>()?;
532            strings
533                .is_valid(0)
534                .then(|| PropValue::Text(strings.value(0).to_string()))
535        }
536        _ => None,
537    }
538}
539
540/// Extract timestamps from a column
541fn extract_timestamps_from_batch(
542    batch: &arrow::record_batch::RecordBatch,
543    col_idx: usize,
544    time_format: &str,
545) -> Result<Vec<u64>> {
546    let column = batch.column(col_idx);
547    let mut timestamps = Vec::with_capacity(batch.num_rows());
548
549    // Timestamp-unit scaling routes through the shared `normalize_timestamp_to_ms`
550    // (stt_core::timestamp) so this analysis loader agrees byte-for-byte with the
551    // stt-build scalar/vertex readers and the DuckDB reader — the divergent local
552    // `.max(0)`/hand-rolled ÷ arithmetic here was the audited bug. Null entries
553    // still coerce to 0 (this loader's historical tolerance); a pre-1970 or
554    // second→ms-overflowing value now hard-errors like the build path.
555    macro_rules! push_ts_column {
556        ($arr:expr, $unit:expr) => {{
557            for i in 0..batch.num_rows() {
558                if $arr.is_valid(i) {
559                    timestamps.push(normalize_timestamp_to_ms(i, $arr.value(i), $unit)?);
560                } else {
561                    timestamps.push(0);
562                }
563            }
564            return Ok(timestamps);
565        }};
566    }
567
568    if let Some(ts_array) = column.as_any().downcast_ref::<TimestampSecondArray>() {
569        push_ts_column!(ts_array, TimestampUnit::Second);
570    }
571    if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMillisecondArray>() {
572        push_ts_column!(ts_array, TimestampUnit::Millisecond);
573    }
574    if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMicrosecondArray>() {
575        push_ts_column!(ts_array, TimestampUnit::Microsecond);
576    }
577    if let Some(ts_array) = column.as_any().downcast_ref::<TimestampNanosecondArray>() {
578        push_ts_column!(ts_array, TimestampUnit::Nanosecond);
579    }
580
581    // Try as i64 array (unix timestamp), interpreted per `--time-format`.
582    if let Some(int_array) = column.as_any().downcast_ref::<Int64Array>() {
583        let unit = match time_format {
584            "unix-sec" => TimestampUnit::Second,
585            _ => TimestampUnit::Millisecond,
586        };
587        for i in 0..batch.num_rows() {
588            if int_array.is_valid(i) {
589                timestamps.push(normalize_timestamp_to_ms(i, int_array.value(i), unit)?);
590            } else {
591                timestamps.push(0);
592            }
593        }
594        return Ok(timestamps);
595    }
596
597    // Try as string array (ISO8601)
598    if let Some(str_array) = column.as_any().downcast_ref::<StringArray>() {
599        for i in 0..batch.num_rows() {
600            if str_array.is_valid(i) {
601                let s = str_array.value(i);
602                let ts = parse_iso8601(s).unwrap_or(0);
603                timestamps.push(ts);
604            } else {
605                timestamps.push(0);
606            }
607        }
608        return Ok(timestamps);
609    }
610
611    anyhow::bail!("Unsupported timestamp column type")
612}
613
614/// Parse ISO 8601 timestamp to Unix milliseconds
615fn parse_iso8601(s: &str) -> Result<u64> {
616    use chrono::{DateTime, NaiveDateTime};
617
618    if let Ok(dt) = s.parse::<DateTime<chrono::Utc>>() {
619        return Ok(dt.timestamp_millis() as u64);
620    }
621
622    if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
623        return Ok(dt.and_utc().timestamp_millis() as u64);
624    }
625
626    if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
627        let dt = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
628        return Ok(dt.timestamp_millis() as u64);
629    }
630
631    anyhow::bail!("Failed to parse timestamp: {}", s)
632}
633
634// =============================================================================
635// WKB Parsing
636// =============================================================================
637
638/// Parse a WKB/EWKB blob into a `geo_types` geometry.
639///
640/// Delegates to `geozero` (the same reader stt-build uses), which correctly
641/// handles 2D, 3D (WKB Z/M), and SRID-prefixed EWKB inputs. Returns `None`
642/// for malformed bytes.
643pub fn parse_wkb_geometry(bytes: &[u8]) -> Option<geo_types::Geometry<f64>> {
644    use geozero::ToGeo;
645    // `Ewkb` parses both plain ISO WKB and SRID-prefixed EWKB.
646    geozero::wkb::Ewkb(bytes.to_vec()).to_geo().ok()
647}
648
649/// Derive the analysis summary (type, total vertex count, centroid) from WKB.
650fn parse_wkb_info(wkb: &[u8]) -> Option<(GeometryType, usize, f64, f64)> {
651    let geom = parse_wkb_geometry(wkb)?;
652    let (lon, lat) = geometry_centroid(&geom)?;
653    Some((classify_geometry(&geom), count_vertices(&geom), lon, lat))
654}
655
656/// Map a parsed geometry onto the analysis `GeometryType` enum.
657fn classify_geometry(geom: &geo_types::Geometry<f64>) -> GeometryType {
658    use geo_types::Geometry as G;
659    match geom {
660        G::Point(_) => GeometryType::Point,
661        G::Line(_) | G::LineString(_) => GeometryType::LineString,
662        G::Polygon(_) | G::Rect(_) | G::Triangle(_) => GeometryType::Polygon,
663        G::MultiPoint(_) => GeometryType::MultiPoint,
664        G::MultiLineString(_) => GeometryType::MultiLineString,
665        G::MultiPolygon(_) => GeometryType::MultiPolygon,
666        G::GeometryCollection(_) => GeometryType::Unknown,
667    }
668}
669
670fn polygon_vertex_count(polygon: &geo_types::Polygon<f64>) -> usize {
671    polygon.exterior().0.len()
672        + polygon.interiors().iter().map(|ring| ring.0.len()).sum::<usize>()
673}
674
675/// Total vertex count across ALL rings and parts (the hand-rolled parser this
676/// replaced only counted the first ring / first member geometry).
677fn count_vertices(geom: &geo_types::Geometry<f64>) -> usize {
678    use geo_types::Geometry as G;
679    match geom {
680        G::Point(_) => 1,
681        G::Line(_) => 2,
682        G::LineString(ls) => ls.0.len(),
683        G::Polygon(polygon) => polygon_vertex_count(polygon),
684        G::MultiPoint(mp) => mp.0.len(),
685        G::MultiLineString(mls) => mls.0.iter().map(|ls| ls.0.len()).sum(),
686        G::MultiPolygon(mp) => mp.0.iter().map(polygon_vertex_count).sum(),
687        G::GeometryCollection(gc) => gc.0.iter().map(count_vertices).sum(),
688        G::Rect(_) => 4,
689        G::Triangle(_) => 3,
690    }
691}
692
693/// Geometric centroid as `(lon, lat)`, falling back to the bounding-rect
694/// center when the centroid is undefined (e.g. empty geometries).
695fn geometry_centroid(geom: &geo_types::Geometry<f64>) -> Option<(f64, f64)> {
696    use geo::algorithm::bounding_rect::BoundingRect;
697    use geo::algorithm::centroid::Centroid;
698
699    if let Some(c) = geom.centroid() {
700        return Some((c.x(), c.y()));
701    }
702    geom.bounding_rect().map(|rect| {
703        let c = rect.center();
704        (c.x, c.y)
705    })
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use geo_types::{Geometry, LineString, MultiPolygon, Point, Polygon};
712    use geozero::{CoordDimensions, ToWkb};
713
714    /// Encode a geo-types geometry as ISO WKB via geozero's writer.
715    fn wkb(geom: &Geometry<f64>) -> Vec<u8> {
716        geom.to_wkb(CoordDimensions::xy()).expect("encode WKB fixture")
717    }
718
719    fn closed_ring(coords: &[(f64, f64)]) -> LineString<f64> {
720        LineString::from(coords.to_vec())
721    }
722
723    #[test]
724    fn parses_point() {
725        let bytes = wkb(&Geometry::Point(Point::new(1.5, -2.5)));
726        let (geom_type, vertices, lon, lat) = parse_wkb_info(&bytes).unwrap();
727        assert_eq!(geom_type, GeometryType::Point);
728        assert_eq!(vertices, 1);
729        assert_eq!((lon, lat), (1.5, -2.5));
730    }
731
732    #[test]
733    fn parses_linestring() {
734        // Evenly spaced straight line so geo's length-weighted centroid
735        // lands on the middle vertex.
736        let line = LineString::from(vec![(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)]);
737        let bytes = wkb(&Geometry::LineString(line));
738        let (geom_type, vertices, lon, lat) = parse_wkb_info(&bytes).unwrap();
739        assert_eq!(geom_type, GeometryType::LineString);
740        assert_eq!(vertices, 3);
741        assert!((lon - 1.0).abs() < 1e-9);
742        assert!(lat.abs() < 1e-9);
743    }
744
745    #[test]
746    fn polygon_vertex_count_includes_interior_rings() {
747        let exterior = closed_ring(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0)]);
748        let interior = closed_ring(&[(1.0, 1.0), (2.0, 1.0), (2.0, 2.0), (1.0, 2.0), (1.0, 1.0)]);
749        let bytes = wkb(&Geometry::Polygon(Polygon::new(exterior, vec![interior])));
750        let (geom_type, vertices, _, _) = parse_wkb_info(&bytes).unwrap();
751        assert_eq!(geom_type, GeometryType::Polygon);
752        // 5 exterior + 5 interior vertices (the old parser reported 5).
753        assert_eq!(vertices, 10);
754    }
755
756    #[test]
757    fn multipolygon_counts_all_parts() {
758        let tri_a = Polygon::new(
759            closed_ring(&[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (0.0, 0.0)]),
760            vec![],
761        );
762        let tri_b = Polygon::new(
763            closed_ring(&[(10.0, 10.0), (11.0, 10.0), (10.0, 11.0), (10.0, 10.0)]),
764            vec![],
765        );
766        let bytes = wkb(&Geometry::MultiPolygon(MultiPolygon(vec![tri_a, tri_b])));
767        let (geom_type, vertices, _, _) = parse_wkb_info(&bytes).unwrap();
768        assert_eq!(geom_type, GeometryType::MultiPolygon);
769        // 4 + 4 vertices (the old parser reported the first ring only = 4).
770        assert_eq!(vertices, 8);
771    }
772
773    #[test]
774    fn unit_square_centroid() {
775        let square = Polygon::new(
776            closed_ring(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]),
777            vec![],
778        );
779        let bytes = wkb(&Geometry::Polygon(square));
780        let (_, _, lon, lat) = parse_wkb_info(&bytes).unwrap();
781        assert!((lon - 0.5).abs() < 1e-9);
782        assert!((lat - 0.5).abs() < 1e-9);
783    }
784
785    #[test]
786    fn malformed_bytes_return_none() {
787        assert!(parse_wkb_geometry(&[]).is_none());
788        assert!(parse_wkb_geometry(&[0x01, 0x02, 0x03]).is_none());
789        // Valid header claiming a point, but truncated coordinates.
790        assert!(parse_wkb_geometry(&[0x01, 0x01, 0x00, 0x00, 0x00, 0x00]).is_none());
791        assert!(parse_wkb_info(&[0xff; 16]).is_none());
792    }
793
794    #[test]
795    fn load_geoparquet_retains_deterministic_sample() {
796        use arrow::array::{BinaryArray, Float64Array, Int64Array, StringArray};
797        use arrow::datatypes::{DataType, Field, Schema};
798        use parquet::arrow::ArrowWriter;
799        use std::sync::Arc;
800
801        let n = 120usize;
802        let wkbs: Vec<Vec<u8>> = (0..n)
803            .map(|i| wkb(&Geometry::Point(Point::new(-73.0 + i as f64 * 0.01, 45.0))))
804            .collect();
805        let schema = Arc::new(Schema::new(vec![
806            Field::new("geometry", DataType::Binary, false),
807            Field::new("timestamp", DataType::Int64, false),
808            Field::new("value", DataType::Float64, false),
809            Field::new("name", DataType::Utf8, false),
810        ]));
811        let batch = arrow::record_batch::RecordBatch::try_new(
812            schema.clone(),
813            vec![
814                Arc::new(BinaryArray::from_iter_values(wkbs.iter().map(|v| v.as_slice()))),
815                Arc::new(Int64Array::from(
816                    (0..n as i64).map(|i| 1_600_000_000_000 + i * 1_000).collect::<Vec<_>>(),
817                )),
818                Arc::new(Float64Array::from(
819                    (0..n).map(|i| i as f64 * 0.5).collect::<Vec<_>>(),
820                )),
821                Arc::new(StringArray::from(
822                    (0..n).map(|i| format!("cat-{}", i % 3)).collect::<Vec<_>>(),
823                )),
824            ],
825        )
826        .unwrap();
827
828        let dir = tempfile::tempdir().unwrap();
829        let path = dir.path().join("sample.parquet");
830        let file = std::fs::File::create(&path).unwrap();
831        let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
832        writer.write(&batch).unwrap();
833        writer.close().unwrap();
834
835        let data = load_data(&DataSource::GeoParquet {
836            path,
837            time_field: "timestamp".to_string(),
838            time_format: "unix-ms".to_string(),
839        })
840        .unwrap();
841
842        assert_eq!(data.features.len(), n);
843        // Below the cap the stride is 1: every row is retained.
844        assert_eq!(data.sample.len(), n);
845        let s = &data.sample[3];
846        assert!(matches!(s.geometry, Geometry::Point(_)));
847        assert_eq!(s.timestamp_ms, 1_600_000_000_000 + 3 * 1_000);
848        assert_eq!(
849            s.properties,
850            vec![
851                ("value".to_string(), PropValue::Number(1.5)),
852                ("name".to_string(), PropValue::Text("cat-0".to_string())),
853            ]
854        );
855
856        // The retained sample is measurable end-to-end.
857        let measured =
858            crate::measure::measure_sample(&data.sample, &crate::measure::MeasureSettings::default())
859                .unwrap()
860                .expect("sample is large enough to measure");
861        assert_eq!(measured.features, n);
862        assert_eq!(measured.geometry_kind, "point");
863    }
864
865    #[test]
866    fn prop_value_at_widens_numerics_and_copies_strings() {
867        use arrow::array::{DictionaryArray, Float64Array, Int32Array, StringArray};
868        use arrow::datatypes::Int32Type;
869
870        let floats = Float64Array::from(vec![Some(1.5), None]);
871        assert_eq!(prop_value_at(&floats, 0), Some(PropValue::Number(1.5)));
872        assert_eq!(prop_value_at(&floats, 1), None);
873
874        let ints = Int32Array::from(vec![7]);
875        assert_eq!(prop_value_at(&ints, 0), Some(PropValue::Number(7.0)));
876
877        let strings = StringArray::from(vec!["storm"]);
878        assert_eq!(
879            prop_value_at(&strings, 0),
880            Some(PropValue::Text("storm".to_string()))
881        );
882
883        let dict: DictionaryArray<Int32Type> = vec!["a", "b", "a"].into_iter().collect();
884        assert_eq!(prop_value_at(&dict, 2), Some(PropValue::Text("a".to_string())));
885    }
886}
887