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/// Loaded dataset for analysis
81#[derive(Debug)]
82pub struct LoadedData {
83    pub features: Vec<AnalyzableFeature>,
84    pub bounds: BoundingBox,
85    pub time_range: TimeRange,
86}
87
88/// Load data from a data source
89pub fn load_data(source: &DataSource) -> Result<LoadedData> {
90    match source {
91        DataSource::GeoParquet { path, time_field, time_format } => {
92            load_geoparquet(path, time_field, time_format)
93        }
94    }
95}
96
97/// Load features from a GeoParquet file
98fn load_geoparquet(path: &Path, time_field: &str, time_format: &str) -> Result<LoadedData> {
99    use indicatif::{ProgressBar, ProgressStyle};
100
101    let pb = ProgressBar::new_spinner();
102    pb.set_style(
103        ProgressStyle::default_spinner()
104            .template("{spinner:.green} {msg}")
105            .unwrap(),
106    );
107    pb.set_message("Loading GeoParquet file...");
108
109    let file = std::fs::File::open(path).context("Failed to open GeoParquet file")?;
110    let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
111    let schema = builder.schema().clone();
112
113    // Find geometry and time columns
114    let geom_col_name = find_geometry_column(&schema)?;
115    let time_col_idx = schema.fields().iter().position(|f| f.name() == time_field)
116        .ok_or_else(|| anyhow::anyhow!("Time field '{}' not found", time_field))?;
117
118    let reader = builder.build()?;
119
120    let mut features = Vec::new();
121    let mut min_lon = f64::MAX;
122    let mut max_lon = f64::MIN;
123    let mut min_lat = f64::MAX;
124    let mut max_lat = f64::MIN;
125    let mut min_time = u64::MAX;
126    let mut max_time = u64::MIN;
127
128    for batch_result in reader {
129        let batch = batch_result.context("Failed to read Parquet batch")?;
130
131        let geometries = extract_geometries_from_batch(&batch, &geom_col_name)?;
132        let timestamps = extract_timestamps_from_batch(&batch, time_col_idx, time_format)?;
133
134        // Count property columns
135        let property_count = schema.fields().len() - 2; // Exclude geometry and time
136
137        for i in 0..batch.num_rows() {
138            let (geom_type, vertex_count, lon, lat) = geometries.get(i)
139                .cloned()
140                .unwrap_or((GeometryType::Unknown, 0, 0.0, 0.0));
141            let timestamp = timestamps.get(i).copied().unwrap_or(0);
142
143            // Update bounds
144            min_lon = min_lon.min(lon);
145            max_lon = max_lon.max(lon);
146            min_lat = min_lat.min(lat);
147            max_lat = max_lat.max(lat);
148            min_time = min_time.min(timestamp);
149            max_time = max_time.max(timestamp);
150
151            // Estimate size: base overhead + vertices + properties
152            let estimated_size = 100 + (vertex_count * 16) + (property_count * 20);
153
154            features.push(AnalyzableFeature {
155                lon,
156                lat,
157                timestamp,
158                geometry_type: geom_type,
159                vertex_count,
160                estimated_size,
161                property_count,
162            });
163        }
164
165        if features.len() % 100_000 == 0 {
166            pb.set_message(format!("Loaded {} features...", features.len()));
167        }
168    }
169
170    pb.finish_with_message(format!("Loaded {} features", features.len()));
171
172    Ok(LoadedData {
173        features,
174        bounds: BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
175        time_range: TimeRange::new(min_time, max_time),
176    })
177}
178
179/// Load features from an STT archive
180// =============================================================================
181// Helper Functions
182// =============================================================================
183
184/// Find the geometry column in a Parquet schema
185fn find_geometry_column(schema: &arrow::datatypes::Schema) -> Result<String> {
186    let common_names = ["geometry", "geom", "wkb_geometry", "the_geom", "shape"];
187
188    for name in common_names {
189        if schema.field_with_name(name).is_ok() {
190            return Ok(name.to_string());
191        }
192    }
193
194    // Look for binary columns (WKB)
195    for field in schema.fields() {
196        if matches!(field.data_type(), DataType::Binary | DataType::LargeBinary) {
197            return Ok(field.name().clone());
198        }
199    }
200
201    // Look for struct columns (GeoArrow)
202    for field in schema.fields() {
203        if matches!(field.data_type(), DataType::Struct(_)) {
204            return Ok(field.name().clone());
205        }
206    }
207
208    // Check for separate lon/lat columns
209    let has_lon = schema.field_with_name("lon").is_ok()
210        || schema.field_with_name("longitude").is_ok()
211        || schema.field_with_name("x").is_ok();
212    let has_lat = schema.field_with_name("lat").is_ok()
213        || schema.field_with_name("latitude").is_ok()
214        || schema.field_with_name("y").is_ok();
215
216    if has_lon && has_lat {
217        return Ok("__lon_lat__".to_string());
218    }
219
220    anyhow::bail!("Could not find geometry column in Parquet schema")
221}
222
223/// Extract geometries from a batch
224fn extract_geometries_from_batch(
225    batch: &arrow::record_batch::RecordBatch,
226    geom_col_name: &str,
227) -> Result<Vec<(GeometryType, usize, f64, f64)>> {
228    let mut results = Vec::with_capacity(batch.num_rows());
229
230    // Handle separate lon/lat columns
231    if geom_col_name == "__lon_lat__" {
232        let lon_col = batch.column_by_name("lon")
233            .or_else(|| batch.column_by_name("longitude"))
234            .or_else(|| batch.column_by_name("x"));
235        let lat_col = batch.column_by_name("lat")
236            .or_else(|| batch.column_by_name("latitude"))
237            .or_else(|| batch.column_by_name("y"));
238
239        if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
240            if let (Some(lon_arr), Some(lat_arr)) = (
241                lon.as_any().downcast_ref::<Float64Array>(),
242                lat.as_any().downcast_ref::<Float64Array>(),
243            ) {
244                for i in 0..batch.num_rows() {
245                    if lon_arr.is_valid(i) && lat_arr.is_valid(i) {
246                        results.push((GeometryType::Point, 1, lon_arr.value(i), lat_arr.value(i)));
247                    } else {
248                        results.push((GeometryType::Unknown, 0, 0.0, 0.0));
249                    }
250                }
251                return Ok(results);
252            }
253        }
254        anyhow::bail!("Expected lon/lat columns but could not read them");
255    }
256
257    let geom_col = batch.column_by_name(geom_col_name)
258        .ok_or_else(|| anyhow::anyhow!("Geometry column '{}' not found", geom_col_name))?;
259
260    // Try GeoArrow struct
261    if let Some(struct_array) = geom_col.as_any().downcast_ref::<arrow::array::StructArray>() {
262        let x_col = struct_array.column_by_name("x")
263            .or_else(|| struct_array.column_by_name("longitude"))
264            .or_else(|| struct_array.column_by_name("lon"));
265        let y_col = struct_array.column_by_name("y")
266            .or_else(|| struct_array.column_by_name("latitude"))
267            .or_else(|| struct_array.column_by_name("lat"));
268
269        if let (Some(x), Some(y)) = (x_col, y_col) {
270            if let (Some(x_arr), Some(y_arr)) = (
271                x.as_any().downcast_ref::<Float64Array>(),
272                y.as_any().downcast_ref::<Float64Array>(),
273            ) {
274                for i in 0..batch.num_rows() {
275                    if x_arr.is_valid(i) && y_arr.is_valid(i) {
276                        results.push((GeometryType::Point, 1, x_arr.value(i), y_arr.value(i)));
277                    } else {
278                        results.push((GeometryType::Unknown, 0, 0.0, 0.0));
279                    }
280                }
281                return Ok(results);
282            }
283        }
284    }
285
286    // Try WKB binary column
287    if let Some(binary_array) = geom_col.as_any().downcast_ref::<arrow::array::BinaryArray>() {
288        for i in 0..batch.num_rows() {
289            if binary_array.is_valid(i) {
290                let wkb = binary_array.value(i);
291                if let Some((geom_type, vertex_count, lon, lat)) = parse_wkb_info(wkb) {
292                    results.push((geom_type, vertex_count, lon, lat));
293                } else {
294                    results.push((GeometryType::Unknown, 0, 0.0, 0.0));
295                }
296            } else {
297                results.push((GeometryType::Unknown, 0, 0.0, 0.0));
298            }
299        }
300        return Ok(results);
301    }
302
303    // Fallback: try separate lon/lat columns
304    let lon_col = batch.column_by_name("lon")
305        .or_else(|| batch.column_by_name("longitude"))
306        .or_else(|| batch.column_by_name("x"));
307    let lat_col = batch.column_by_name("lat")
308        .or_else(|| batch.column_by_name("latitude"))
309        .or_else(|| batch.column_by_name("y"));
310
311    if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
312        if let (Some(lon_arr), Some(lat_arr)) = (
313            lon.as_any().downcast_ref::<Float64Array>(),
314            lat.as_any().downcast_ref::<Float64Array>(),
315        ) {
316            for i in 0..batch.num_rows() {
317                if lon_arr.is_valid(i) && lat_arr.is_valid(i) {
318                    results.push((GeometryType::Point, 1, lon_arr.value(i), lat_arr.value(i)));
319                } else {
320                    results.push((GeometryType::Unknown, 0, 0.0, 0.0));
321                }
322            }
323            return Ok(results);
324        }
325    }
326
327    anyhow::bail!("Could not extract geometries from column '{}'", geom_col_name)
328}
329
330/// Extract timestamps from a column
331fn extract_timestamps_from_batch(
332    batch: &arrow::record_batch::RecordBatch,
333    col_idx: usize,
334    time_format: &str,
335) -> Result<Vec<u64>> {
336    let column = batch.column(col_idx);
337    let mut timestamps = Vec::with_capacity(batch.num_rows());
338
339    // Timestamp-unit scaling routes through the shared `normalize_timestamp_to_ms`
340    // (stt_core::timestamp) so this analysis loader agrees byte-for-byte with the
341    // stt-build scalar/vertex readers and the DuckDB reader — the divergent local
342    // `.max(0)`/hand-rolled ÷ arithmetic here was the audited bug. Null entries
343    // still coerce to 0 (this loader's historical tolerance); a pre-1970 or
344    // second→ms-overflowing value now hard-errors like the build path.
345    macro_rules! push_ts_column {
346        ($arr:expr, $unit:expr) => {{
347            for i in 0..batch.num_rows() {
348                if $arr.is_valid(i) {
349                    timestamps.push(normalize_timestamp_to_ms(i, $arr.value(i), $unit)?);
350                } else {
351                    timestamps.push(0);
352                }
353            }
354            return Ok(timestamps);
355        }};
356    }
357
358    if let Some(ts_array) = column.as_any().downcast_ref::<TimestampSecondArray>() {
359        push_ts_column!(ts_array, TimestampUnit::Second);
360    }
361    if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMillisecondArray>() {
362        push_ts_column!(ts_array, TimestampUnit::Millisecond);
363    }
364    if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMicrosecondArray>() {
365        push_ts_column!(ts_array, TimestampUnit::Microsecond);
366    }
367    if let Some(ts_array) = column.as_any().downcast_ref::<TimestampNanosecondArray>() {
368        push_ts_column!(ts_array, TimestampUnit::Nanosecond);
369    }
370
371    // Try as i64 array (unix timestamp), interpreted per `--time-format`.
372    if let Some(int_array) = column.as_any().downcast_ref::<Int64Array>() {
373        let unit = match time_format {
374            "unix-sec" => TimestampUnit::Second,
375            _ => TimestampUnit::Millisecond,
376        };
377        for i in 0..batch.num_rows() {
378            if int_array.is_valid(i) {
379                timestamps.push(normalize_timestamp_to_ms(i, int_array.value(i), unit)?);
380            } else {
381                timestamps.push(0);
382            }
383        }
384        return Ok(timestamps);
385    }
386
387    // Try as string array (ISO8601)
388    if let Some(str_array) = column.as_any().downcast_ref::<StringArray>() {
389        for i in 0..batch.num_rows() {
390            if str_array.is_valid(i) {
391                let s = str_array.value(i);
392                let ts = parse_iso8601(s).unwrap_or(0);
393                timestamps.push(ts);
394            } else {
395                timestamps.push(0);
396            }
397        }
398        return Ok(timestamps);
399    }
400
401    anyhow::bail!("Unsupported timestamp column type")
402}
403
404/// Parse ISO 8601 timestamp to Unix milliseconds
405fn parse_iso8601(s: &str) -> Result<u64> {
406    use chrono::{DateTime, NaiveDateTime};
407
408    if let Ok(dt) = s.parse::<DateTime<chrono::Utc>>() {
409        return Ok(dt.timestamp_millis() as u64);
410    }
411
412    if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
413        return Ok(dt.and_utc().timestamp_millis() as u64);
414    }
415
416    if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
417        let dt = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
418        return Ok(dt.timestamp_millis() as u64);
419    }
420
421    anyhow::bail!("Failed to parse timestamp: {}", s)
422}
423
424// =============================================================================
425// WKB Parsing
426// =============================================================================
427
428const WKB_POINT: u32 = 1;
429const WKB_LINESTRING: u32 = 2;
430const WKB_POLYGON: u32 = 3;
431const WKB_MULTIPOINT: u32 = 4;
432const WKB_MULTILINESTRING: u32 = 5;
433const WKB_MULTIPOLYGON: u32 = 6;
434
435fn parse_wkb_info(wkb: &[u8]) -> Option<(GeometryType, usize, f64, f64)> {
436    if wkb.len() < 5 {
437        return None;
438    }
439
440    let little_endian = wkb[0] == 1;
441    let geom_type = if little_endian {
442        u32::from_le_bytes([wkb[1], wkb[2], wkb[3], wkb[4]]) % 1000
443    } else {
444        u32::from_be_bytes([wkb[1], wkb[2], wkb[3], wkb[4]]) % 1000
445    };
446
447    match geom_type {
448        WKB_POINT => parse_wkb_point_info(wkb, little_endian),
449        WKB_LINESTRING => parse_wkb_linestring_info(wkb, little_endian),
450        WKB_POLYGON => parse_wkb_polygon_info(wkb, little_endian),
451        WKB_MULTIPOINT => parse_wkb_multipoint_info(wkb, little_endian),
452        WKB_MULTILINESTRING => parse_wkb_multilinestring_info(wkb, little_endian),
453        WKB_MULTIPOLYGON => parse_wkb_multipolygon_info(wkb, little_endian),
454        _ => None,
455    }
456}
457
458fn read_f64(wkb: &[u8], offset: usize, little_endian: bool) -> Option<f64> {
459    if offset + 8 > wkb.len() {
460        return None;
461    }
462    let bytes: [u8; 8] = wkb[offset..offset+8].try_into().ok()?;
463    Some(if little_endian {
464        f64::from_le_bytes(bytes)
465    } else {
466        f64::from_be_bytes(bytes)
467    })
468}
469
470fn read_u32(wkb: &[u8], offset: usize, little_endian: bool) -> Option<u32> {
471    if offset + 4 > wkb.len() {
472        return None;
473    }
474    let bytes: [u8; 4] = wkb[offset..offset+4].try_into().ok()?;
475    Some(if little_endian {
476        u32::from_le_bytes(bytes)
477    } else {
478        u32::from_be_bytes(bytes)
479    })
480}
481
482fn parse_wkb_point_info(wkb: &[u8], little_endian: bool) -> Option<(GeometryType, usize, f64, f64)> {
483    let x = read_f64(wkb, 5, little_endian)?;
484    let y = read_f64(wkb, 13, little_endian)?;
485    Some((GeometryType::Point, 1, x, y))
486}
487
488fn parse_wkb_linestring_info(wkb: &[u8], little_endian: bool) -> Option<(GeometryType, usize, f64, f64)> {
489    let num_points = read_u32(wkb, 5, little_endian)? as usize;
490    if num_points == 0 {
491        return None;
492    }
493
494    let mut sum_x = 0.0;
495    let mut sum_y = 0.0;
496    let mut offset = 9;
497
498    for _ in 0..num_points {
499        let x = read_f64(wkb, offset, little_endian)?;
500        let y = read_f64(wkb, offset + 8, little_endian)?;
501        sum_x += x;
502        sum_y += y;
503        offset += 16;
504    }
505
506    Some((GeometryType::LineString, num_points, sum_x / num_points as f64, sum_y / num_points as f64))
507}
508
509fn parse_wkb_polygon_info(wkb: &[u8], little_endian: bool) -> Option<(GeometryType, usize, f64, f64)> {
510    let num_rings = read_u32(wkb, 5, little_endian)? as usize;
511    if num_rings == 0 {
512        return None;
513    }
514
515    let mut total_points = 0usize;
516    let mut sum_x = 0.0;
517    let mut sum_y = 0.0;
518    let mut offset = 9;
519
520    for ring_idx in 0..num_rings {
521        let num_points = read_u32(wkb, offset, little_endian)? as usize;
522        offset += 4;
523
524        for _ in 0..num_points {
525            let x = read_f64(wkb, offset, little_endian)?;
526            let y = read_f64(wkb, offset + 8, little_endian)?;
527            
528            if ring_idx == 0 {
529                sum_x += x;
530                sum_y += y;
531                total_points += 1;
532            }
533            
534            offset += 16;
535        }
536    }
537
538    let vertex_count: usize = total_points;
539    let centroid_x = if total_points > 0 { sum_x / total_points as f64 } else { 0.0 };
540    let centroid_y = if total_points > 0 { sum_y / total_points as f64 } else { 0.0 };
541
542    Some((GeometryType::Polygon, vertex_count, centroid_x, centroid_y))
543}
544
545fn parse_wkb_multipoint_info(wkb: &[u8], little_endian: bool) -> Option<(GeometryType, usize, f64, f64)> {
546    let num_geoms = read_u32(wkb, 5, little_endian)? as usize;
547    if num_geoms == 0 {
548        return None;
549    }
550
551    let mut sum_x = 0.0;
552    let mut sum_y = 0.0;
553    let mut offset = 9;
554
555    for _ in 0..num_geoms {
556        offset += 5; // Skip WKB header
557        let x = read_f64(wkb, offset, little_endian)?;
558        let y = read_f64(wkb, offset + 8, little_endian)?;
559        sum_x += x;
560        sum_y += y;
561        offset += 16;
562    }
563
564    Some((GeometryType::MultiPoint, num_geoms, sum_x / num_geoms as f64, sum_y / num_geoms as f64))
565}
566
567fn parse_wkb_multilinestring_info(wkb: &[u8], little_endian: bool) -> Option<(GeometryType, usize, f64, f64)> {
568    let num_geoms = read_u32(wkb, 5, little_endian)? as usize;
569    if num_geoms == 0 {
570        return None;
571    }
572
573    let mut total_points = 0usize;
574    let mut sum_x = 0.0;
575    let mut sum_y = 0.0;
576    let mut offset = 9;
577
578    for _ in 0..num_geoms {
579        offset += 5; // Skip WKB header
580        let num_points = read_u32(wkb, offset, little_endian)? as usize;
581        offset += 4;
582
583        for _ in 0..num_points {
584            let x = read_f64(wkb, offset, little_endian)?;
585            let y = read_f64(wkb, offset + 8, little_endian)?;
586            sum_x += x;
587            sum_y += y;
588            total_points += 1;
589            offset += 16;
590        }
591    }
592
593    let centroid_x = if total_points > 0 { sum_x / total_points as f64 } else { 0.0 };
594    let centroid_y = if total_points > 0 { sum_y / total_points as f64 } else { 0.0 };
595
596    Some((GeometryType::MultiLineString, total_points, centroid_x, centroid_y))
597}
598
599fn parse_wkb_multipolygon_info(wkb: &[u8], little_endian: bool) -> Option<(GeometryType, usize, f64, f64)> {
600    let num_geoms = read_u32(wkb, 5, little_endian)? as usize;
601    if num_geoms == 0 {
602        return None;
603    }
604
605    let mut total_points = 0usize;
606    let mut sum_x = 0.0;
607    let mut sum_y = 0.0;
608    let mut offset = 9;
609
610    for poly_idx in 0..num_geoms {
611        offset += 5; // Skip WKB header
612        let num_rings = read_u32(wkb, offset, little_endian)? as usize;
613        offset += 4;
614
615        for ring_idx in 0..num_rings {
616            let num_points = read_u32(wkb, offset, little_endian)? as usize;
617            offset += 4;
618
619            for _ in 0..num_points {
620                let x = read_f64(wkb, offset, little_endian)?;
621                let y = read_f64(wkb, offset + 8, little_endian)?;
622                
623                if poly_idx == 0 && ring_idx == 0 {
624                    sum_x += x;
625                    sum_y += y;
626                    total_points += 1;
627                }
628                
629                offset += 16;
630            }
631        }
632    }
633
634    let centroid_x = if total_points > 0 { sum_x / total_points as f64 } else { 0.0 };
635    let centroid_y = if total_points > 0 { sum_y / total_points as f64 } else { 0.0 };
636
637    Some((GeometryType::MultiPolygon, total_points, centroid_x, centroid_y))
638}
639