use crate::columnar::{PropertyKind, PropertyTypes};
use anyhow::{Context, Result};
use arrow::array::{Array, BinaryViewArray, Float32Array, Float64Array, Int64Array, LargeBinaryArray, ListArray, StringArray, TimestampSecondArray, TimestampMillisecondArray, TimestampMicrosecondArray, TimestampNanosecondArray};
use arrow::datatypes::DataType;
use geojson::{Feature, Geometry, Value as GeomValue};
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use parquet::file::metadata::KeyValue;
use std::collections::HashMap;
use std::fs::File;
use std::path::Path;
use std::sync::Arc;
use stt_core::types::{BoundingBox, TimeRange};
pub type SharedProperties = Arc<serde_json::Map<String, serde_json::Value>>;
#[derive(Debug, Clone)]
pub struct ParsedFeature {
pub geojson: Feature,
pub shared_properties: Option<SharedProperties>,
pub timestamp: u64,
pub end_timestamp: Option<u64>,
pub vertex_timestamps: Option<Vec<u64>>,
pub vertex_values: Option<Vec<f32>>,
pub vertex_value_matrix: Option<Vec<f32>>,
pub lon: f64,
pub lat: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputStrictness {
Warn,
Strict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, serde::Deserialize)]
pub enum TimeFormat {
#[serde(rename = "iso8601")]
Iso8601,
#[serde(rename = "unix-sec")]
UnixSec,
#[serde(rename = "unix-ms")]
UnixMs,
}
impl TimeFormat {
pub fn as_str(&self) -> &'static str {
match self {
TimeFormat::Iso8601 => "iso8601",
TimeFormat::UnixSec => "unix-sec",
TimeFormat::UnixMs => "unix-ms",
}
}
}
pub fn load_features(
path: &Path,
time_field: &str,
end_time_field: Option<&str>,
time_format: TimeFormat,
time_strictness: InputStrictness,
geometry_strictness: InputStrictness,
) -> Result<Vec<ParsedFeature>> {
let mut features = Vec::new();
let mut row_count = 0usize;
stream_features(path, time_field, end_time_field, time_format, time_strictness, geometry_strictness, |batch| {
row_count += batch.len();
features.extend(batch);
if row_count.is_multiple_of(100_000) {
tracing::info!("Loaded {} features...", row_count);
}
Ok(())
})?;
tracing::info!("Loaded {} total features", features.len());
Ok(features)
}
pub fn stream_features<F>(
path: &Path,
time_field: &str,
end_time_field: Option<&str>,
time_format: TimeFormat,
time_strictness: InputStrictness,
geometry_strictness: InputStrictness,
mut on_batch: F,
) -> Result<()>
where
F: FnMut(Vec<ParsedFeature>) -> Result<()>,
{
let file = File::open(path).context("Failed to open GeoParquet file")?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
let schema = builder.schema().clone();
let geo_meta = parse_geo_metadata(
builder.metadata().file_metadata().key_value_metadata(),
&schema,
);
let geom_col_name = find_geometry_column(&schema, geo_meta.as_ref())?;
if let Some(meta) = geo_meta.as_ref() {
validate_geo_column(meta, &geom_col_name, geometry_strictness)?;
}
let time_col_idx = schema
.fields()
.iter()
.position(|f| f.name() == time_field)
.ok_or_else(|| anyhow::anyhow!("Time field '{}' not found", time_field))?;
if time_format == TimeFormat::Iso8601
&& matches!(schema.field(time_col_idx).data_type(), DataType::Int64)
{
tracing::warn!(
"--time-format iso8601 but time column '{}' is Int64; integer \
values are interpreted as unix-ms (pass --time-format unix-ms \
or unix-sec to make this explicit)",
time_field
);
}
let end_time_col_idx = end_time_field
.and_then(|field| schema.fields().iter().position(|f| f.name() == field));
let vertex_times_col_idx = schema.fields().iter().position(|f| f.name() == "vertex_timestamps");
let vertex_values_col_idx = schema.fields().iter().position(|f| f.name() == "vertex_values");
let vertex_value_matrix_col_idx =
schema.fields().iter().position(|f| f.name() == "vertex_value_matrix");
let reader = builder.build()?;
let property_cols: Vec<usize> = schema
.fields()
.iter()
.enumerate()
.filter_map(|(idx, field)| {
if is_property_column(field.name(), &geom_col_name, time_field, end_time_field) {
Some(idx)
} else {
None
}
})
.collect();
let mut row_count = 0usize;
let mut seen_props = vec![false; property_cols.len()];
let mut time_parse_failures = 0usize;
for batch_result in reader {
let batch = batch_result.context("Failed to read Parquet batch")?;
let parsed = parse_batch(
&batch,
&schema,
&geom_col_name,
time_col_idx,
end_time_col_idx,
vertex_times_col_idx,
vertex_values_col_idx,
vertex_value_matrix_col_idx,
&property_cols,
&mut seen_props,
time_format,
time_strictness,
geometry_strictness,
row_count,
&mut time_parse_failures,
)?;
row_count += batch.num_rows();
on_batch(parsed)?;
}
if row_count > 0 && time_parse_failures == row_count {
anyhow::bail!(
"all {row_count} rows in time column '{time_field}' were null or unparseable — \
refusing to write an archive whose every feature is coerced to epoch 0 \
(1970-01-01). Check --time-field/--time-format: zone-less ISO 8601 strings \
are read as UTC; integer columns are unix-ms unless --time-format unix-sec"
);
}
warn_dropped_property_columns(&schema, &property_cols, &seen_props, row_count);
Ok(())
}
fn warn_dropped_property_columns(
schema: &arrow::datatypes::Schema,
property_cols: &[usize],
seen: &[bool],
total_rows: usize,
) {
if total_rows == 0 {
return;
}
let dropped: Vec<String> = property_cols
.iter()
.zip(seen)
.filter(|&(_, &s)| !s)
.map(|(&idx, _)| {
let field = schema.field(idx);
format!("{} ({})", field.name(), field.data_type())
})
.collect();
if !dropped.is_empty() {
tracing::warn!(
"{} source column(s) carried no value in any of {total_rows} rows and were \
dropped from tiles (unmappable Arrow column type — e.g. decimal/struct/binary — \
or entirely NULL): {}",
dropped.len(),
dropped.join(", ")
);
}
}
#[allow(clippy::too_many_arguments)]
fn parse_batch(
batch: &arrow::record_batch::RecordBatch,
schema: &arrow::datatypes::Schema,
geom_col_name: &str,
time_col_idx: usize,
end_time_col_idx: Option<usize>,
vertex_times_col_idx: Option<usize>,
vertex_values_col_idx: Option<usize>,
vertex_value_matrix_col_idx: Option<usize>,
property_cols: &[usize],
seen_props: &mut [bool],
time_format: TimeFormat,
time_strictness: InputStrictness,
geometry_strictness: InputStrictness,
row_offset: usize,
time_parse_failures: &mut usize,
) -> Result<Vec<ParsedFeature>> {
let geometries = extract_geometries_from_batch(batch, geom_col_name)?;
let (timestamps, batch_time_failures) =
extract_timestamps_from_batch(batch, time_col_idx, time_format, time_strictness, row_offset)?;
*time_parse_failures += batch_time_failures;
let end_timestamps = end_time_col_idx
.map(|idx| extract_timestamps_from_batch(batch, idx, time_format, time_strictness, row_offset))
.transpose()?
.map(|(ts, _)| ts);
let vertex_times = vertex_times_col_idx
.map(|idx| extract_vertex_timestamps_from_batch(batch, idx, row_offset))
.transpose()?;
let vertex_values = vertex_values_col_idx
.map(|idx| extract_vertex_values_from_batch(batch, idx))
.transpose()?;
let vertex_value_matrices = vertex_value_matrix_col_idx
.map(|idx| extract_vertex_values_from_batch(batch, idx))
.transpose()?;
let mut features = Vec::with_capacity(batch.num_rows());
let mut geometry_failures = 0usize;
for i in 0..batch.num_rows() {
let slot = geometries
.get(i)
.ok_or_else(|| anyhow::anyhow!("Missing geometry at row {}", row_offset + i))?;
let Some((geometry, lon, lat)) = slot.clone() else {
geometry_failures += 1;
if geometry_strictness == InputStrictness::Strict {
anyhow::bail!(
"row {}: null or unparseable geometry (rerun without \
--strict-geometry to skip such rows)",
row_offset + i
);
}
continue;
};
let timestamp = timestamps
.get(i)
.copied()
.ok_or_else(|| anyhow::anyhow!("Missing timestamp at row {}", row_offset + i))?;
let end_timestamp = end_timestamps.as_ref().and_then(|ts| ts.get(i).copied());
let row_vertex_times = vertex_times
.as_ref()
.and_then(|v| v.get(i).cloned().flatten());
let row_vertex_values = vertex_values
.as_ref()
.and_then(|v| v.get(i).cloned().flatten());
let row_vertex_value_matrix = vertex_value_matrices
.as_ref()
.and_then(|v| v.get(i).cloned().flatten());
let mut properties = serde_json::Map::new();
for (pc, &col_idx) in property_cols.iter().enumerate() {
if let Some(value) = extract_property_value(batch, col_idx, i) {
seen_props[pc] = true;
let name = schema.field(col_idx).name();
properties.insert(name.clone(), value);
}
}
let shared_properties = if properties.is_empty() {
None
} else {
Some(Arc::new(properties))
};
let feature = Feature {
bbox: None,
geometry: Some(geometry),
id: None,
properties: None,
foreign_members: None,
};
features.push(ParsedFeature {
geojson: feature,
shared_properties,
timestamp,
end_timestamp,
vertex_timestamps: row_vertex_times,
vertex_values: row_vertex_values,
vertex_value_matrix: row_vertex_value_matrix,
lon,
lat,
});
}
warn_geometry_failures(geometry_failures, batch.num_rows());
Ok(features)
}
pub fn is_coordinate_column_name(name: &str) -> bool {
matches!(name, "lon" | "lat" | "longitude" | "latitude" | "x" | "y")
}
pub const VERTEX_METADATA_COLUMNS: [&str; 3] =
["vertex_timestamps", "vertex_values", "vertex_value_matrix"];
pub fn is_vertex_metadata_column(name: &str) -> bool {
VERTEX_METADATA_COLUMNS.contains(&name)
}
fn is_property_column(
name: &str,
geom_col_name: &str,
time_field: &str,
end_time_field: Option<&str>,
) -> bool {
!(name == geom_col_name
|| name == time_field
|| end_time_field.map(|f| name == f).unwrap_or(false)
|| is_vertex_metadata_column(name)
|| is_coordinate_column_name(name))
}
fn property_kind_for(dt: &DataType) -> Option<PropertyKind> {
match dt {
DataType::Float64 | DataType::Float32 | DataType::Int64 | DataType::Int32 => {
Some(PropertyKind::Numeric)
}
DataType::Utf8 | DataType::Boolean => Some(PropertyKind::Categorical),
_ => None,
}
}
pub fn property_kinds(
path: &Path,
time_field: &str,
end_time_field: Option<&str>,
) -> Result<PropertyTypes> {
let file = File::open(path).context("Failed to open GeoParquet file")?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
let schema = builder.schema().clone();
let geo_meta = parse_geo_metadata(
builder.metadata().file_metadata().key_value_metadata(),
&schema,
);
let geom_col_name = find_geometry_column(&schema, geo_meta.as_ref())?;
let mut kinds = PropertyTypes::new();
for field in schema.fields() {
if !is_property_column(field.name(), &geom_col_name, time_field, end_time_field) {
continue;
}
if let Some(kind) = property_kind_for(field.data_type()) {
kinds.insert(field.name().clone(), kind);
}
}
Ok(kinds)
}
fn extract_vertex_timestamps_from_batch(
batch: &arrow::record_batch::RecordBatch,
col_idx: usize,
row_offset: usize,
) -> Result<Vec<Option<Vec<u64>>>> {
let column = batch.column(col_idx);
let list = column
.as_any()
.downcast_ref::<ListArray>()
.ok_or_else(|| anyhow::anyhow!("vertex_timestamps column is not a List array"))?;
let mut out: Vec<Option<Vec<u64>>> = Vec::with_capacity(batch.num_rows());
for row in 0..batch.num_rows() {
if !list.is_valid(row) {
out.push(None);
continue;
}
let values = list.value(row);
let row_no = row_offset + row;
let scale = |value: i64, unit: TimestampUnit| -> Result<u64> {
Ok(normalize_timestamp_to_ms(row_no, value, unit)?)
};
let row_times: Vec<u64> = if let Some(ts) =
values.as_any().downcast_ref::<TimestampSecondArray>()
{
(0..ts.len())
.map(|i| if ts.is_valid(i) { scale(ts.value(i), TimestampUnit::Second) } else { Ok(0) })
.collect::<Result<Vec<u64>>>()?
} else if let Some(ts) = values.as_any().downcast_ref::<TimestampMillisecondArray>() {
(0..ts.len())
.map(|i| if ts.is_valid(i) { scale(ts.value(i), TimestampUnit::Millisecond) } else { Ok(0) })
.collect::<Result<Vec<u64>>>()?
} else if let Some(ts) = values.as_any().downcast_ref::<TimestampMicrosecondArray>() {
(0..ts.len())
.map(|i| if ts.is_valid(i) { scale(ts.value(i), TimestampUnit::Microsecond) } else { Ok(0) })
.collect::<Result<Vec<u64>>>()?
} else if let Some(ts) = values.as_any().downcast_ref::<TimestampNanosecondArray>() {
(0..ts.len())
.map(|i| if ts.is_valid(i) { scale(ts.value(i), TimestampUnit::Nanosecond) } else { Ok(0) })
.collect::<Result<Vec<u64>>>()?
} else if let Some(ints) = values.as_any().downcast_ref::<Int64Array>() {
(0..ints.len())
.map(|i| if ints.is_valid(i) { scale(ints.value(i), TimestampUnit::Millisecond) } else { Ok(0) })
.collect::<Result<Vec<u64>>>()?
} else {
anyhow::bail!(
"vertex_timestamps child must be a Timestamp (second/millisecond/microsecond/\
nanosecond) or Int64 (raw ms) array; got {:?}",
values.data_type()
);
};
out.push(Some(row_times));
}
Ok(out)
}
fn extract_vertex_values_from_batch(
batch: &arrow::record_batch::RecordBatch,
col_idx: usize,
) -> Result<Vec<Option<Vec<f32>>>> {
let column = batch.column(col_idx);
let list = column
.as_any()
.downcast_ref::<ListArray>()
.ok_or_else(|| anyhow::anyhow!("vertex_values column is not a List array"))?;
let mut out: Vec<Option<Vec<f32>>> = Vec::with_capacity(batch.num_rows());
for row in 0..batch.num_rows() {
if !list.is_valid(row) {
out.push(None);
continue;
}
let values = list.value(row);
let row_vals: Vec<f32> = if let Some(f32s) =
values.as_any().downcast_ref::<Float32Array>()
{
(0..f32s.len())
.map(|i| if f32s.is_valid(i) { f32s.value(i) } else { f32::NAN })
.collect()
} else if let Some(f64s) = values.as_any().downcast_ref::<Float64Array>() {
(0..f64s.len())
.map(|i| if f64s.is_valid(i) { f64s.value(i) as f32 } else { f32::NAN })
.collect()
} else {
anyhow::bail!(
"vertex_values child must be Float32 or Float64; got {:?}",
values.data_type()
);
};
out.push(Some(row_vals));
}
Ok(out)
}
pub fn calculate_bounds(features: &[ParsedFeature]) -> Result<(BoundingBox, TimeRange)> {
if features.is_empty() {
return Ok((
BoundingBox::new(-180.0, -90.0, 180.0, 90.0),
TimeRange::new(0, 0),
));
}
let mut min_time = u64::MAX;
let mut max_time = u64::MIN;
for f in features {
min_time = min_time.min(f.timestamp);
max_time = max_time.max(f.end_timestamp.unwrap_or(f.timestamp));
}
let mut min_lon = f64::MAX;
let mut max_lon = f64::MIN;
let mut min_lat = f64::MAX;
let mut max_lat = f64::MIN;
let mut counted = 0usize;
let mut skipped_null_island = 0usize;
for f in features {
if f.lon == 0.0 && f.lat == 0.0 {
skipped_null_island += 1;
continue;
}
min_lon = min_lon.min(f.lon);
max_lon = max_lon.max(f.lon);
min_lat = min_lat.min(f.lat);
max_lat = max_lat.max(f.lat);
counted += 1;
}
if counted == 0 {
for f in features {
min_lon = min_lon.min(f.lon);
max_lon = max_lon.max(f.lon);
min_lat = min_lat.min(f.lat);
max_lat = max_lat.max(f.lat);
}
} else if skipped_null_island > 0 {
tracing::warn!(
"excluded {} null-island (0,0) feature(s) from archive bounds \
(likely coerced bad/missing geometry)",
skipped_null_island
);
}
Ok((
BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
TimeRange::new(min_time, max_time),
))
}
#[derive(Debug, Default, serde::Deserialize)]
struct GeoFileMeta {
#[serde(default)]
primary_column: Option<String>,
#[serde(default)]
columns: HashMap<String, GeoColumnMeta>,
}
#[derive(Debug, Default, serde::Deserialize)]
struct GeoColumnMeta {
#[serde(default)]
encoding: Option<String>,
#[serde(default)]
crs: Option<serde_json::Value>,
}
fn parse_geo_metadata(
kv: Option<&Vec<KeyValue>>,
schema: &arrow::datatypes::Schema,
) -> Option<GeoFileMeta> {
let raw = kv
.and_then(|entries| {
entries
.iter()
.find(|e| e.key == "geo")
.and_then(|e| e.value.clone())
})
.or_else(|| schema.metadata().get("geo").cloned())?;
match serde_json::from_str::<GeoFileMeta>(&raw) {
Ok(meta) => Some(meta),
Err(e) => {
tracing::warn!(
"ignoring malformed GeoParquet 'geo' footer metadata ({e}); \
falling back to geometry-column name heuristics"
);
None
}
}
}
fn crs_is_lonlat_wgs84(crs: &serde_json::Value) -> std::result::Result<(), String> {
let auth_code_ok = |auth: &str, code: &str| -> bool {
(auth.eq_ignore_ascii_case("OGC") && code.eq_ignore_ascii_case("CRS84"))
|| (auth.eq_ignore_ascii_case("EPSG") && code == "4326")
};
match crs {
serde_json::Value::Null => Ok(()),
serde_json::Value::String(s) => {
let norm = s.trim();
let bare_ok = matches!(
norm.to_ascii_uppercase().as_str(),
"CRS84" | "4326" | "WGS 84" | "WGS84" | "WGS_1984" | "WGS84(DD)"
);
let ok = bare_ok
|| match norm.rsplit_once(':') {
Some((head, code)) => {
let auth = head.rsplit(':').find(|p| !p.is_empty()).unwrap_or(head);
auth_code_ok(auth, code)
|| (norm.to_ascii_lowercase().contains("ogc")
&& code.eq_ignore_ascii_case("CRS84"))
}
None => false,
};
if ok { Ok(()) } else { Err(format!("'{norm}'")) }
}
serde_json::Value::Object(obj) => {
let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
if let Some(id) = obj.get("id").and_then(|v| v.as_object()) {
let auth = id.get("authority").and_then(|v| v.as_str()).unwrap_or("");
let code = match id.get("code") {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Number(n)) => n.to_string(),
_ => String::new(),
};
if auth_code_ok(auth, &code) {
return Ok(());
}
return Err(if name.is_empty() {
format!("{auth}:{code}")
} else {
format!("{auth}:{code} ({name})")
});
}
if matches!(name, "WGS 84 (CRS84)" | "WGS 84") {
Ok(())
} else if name.is_empty() {
Err("an unrecognized PROJJSON CRS without an authority id".to_string())
} else {
Err(format!("'{name}'"))
}
}
other => Err(format!("{other}")),
}
}
fn validate_geo_column(
meta: &GeoFileMeta,
geom_col_name: &str,
geometry_strictness: InputStrictness,
) -> Result<()> {
let Some(col) = meta.columns.get(geom_col_name) else {
return Ok(());
};
if let Some(crs) = &col.crs {
if let Err(found) = crs_is_lonlat_wgs84(crs) {
if geometry_strictness == InputStrictness::Strict {
anyhow::bail!(
"GeoParquet geometry column '{geom_col_name}' declares CRS {found}, \
but stt-build requires lon/lat degrees (OGC:CRS84 / EPSG:4326). \
Reproject the input before export (e.g. geopandas: \
gdf.to_crs(4326).to_parquet(...))."
);
}
tracing::warn!(
"GeoParquet geometry column '{geom_col_name}' declares CRS {found}, but \
stt-build assumes WGS 84 lon/lat degrees (OGC:CRS84 / EPSG:4326) and \
tiles coordinates AS-IS — output tiles will be WRONG if the data is \
actually in another CRS. Reproject to EPSG:4326 first (e.g. geopandas: \
gdf.to_crs(4326).to_parquet(...)), or pass --strict-geometry to make \
this a hard error.",
);
}
}
if let Some(encoding) = col.encoding.as_deref() {
let unsupported = matches!(
encoding.to_ascii_lowercase().as_str(),
"linestring" | "polygon" | "multipoint" | "multilinestring" | "multipolygon"
);
if unsupported {
anyhow::bail!(
"GeoParquet geometry column '{geom_col_name}' uses the native \
geoarrow '{encoding}' encoding, which this reader cannot ingest. \
Re-export with WKB geometry encoding (e.g. geopandas: \
gdf.to_parquet(..., geometry_encoding='WKB'))."
);
}
}
Ok(())
}
fn find_geometry_column(
schema: &arrow::datatypes::Schema,
geo_meta: Option<&GeoFileMeta>,
) -> Result<String> {
if let Some(primary) = geo_meta.and_then(|m| m.primary_column.as_deref()) {
if schema.field_with_name(primary).is_ok() {
return Ok(primary.to_string());
}
tracing::warn!(
"GeoParquet metadata names primary_column '{primary}' but the \
schema has no such column; falling back to name heuristics"
);
}
let common_names = ["geometry", "geom", "wkb_geometry", "the_geom", "shape"];
for name in common_names {
if schema.field_with_name(name).is_ok() {
return Ok(name.to_string());
}
}
for field in schema.fields() {
if matches!(
field.data_type(),
DataType::Binary | DataType::LargeBinary | DataType::BinaryView
) {
return Ok(field.name().clone());
}
}
for field in schema.fields() {
if matches!(field.data_type(), DataType::Struct(_)) {
return Ok(field.name().clone());
}
}
let has_lon = schema.field_with_name("lon").is_ok()
|| schema.field_with_name("longitude").is_ok()
|| schema.field_with_name("x").is_ok();
let has_lat = schema.field_with_name("lat").is_ok()
|| schema.field_with_name("latitude").is_ok()
|| schema.field_with_name("y").is_ok();
if has_lon && has_lat {
return Ok("__lon_lat__".to_string());
}
anyhow::bail!("Could not find geometry column in Parquet schema. Expected columns: {:?}", common_names)
}
fn extract_geometries_from_batch(
batch: &arrow::record_batch::RecordBatch,
geom_col_name: &str,
) -> Result<Vec<Option<(Geometry, f64, f64)>>> {
fn points_from_xy(
x_arr: &Float64Array,
y_arr: &Float64Array,
num_rows: usize,
) -> Vec<Option<(Geometry, f64, f64)>> {
(0..num_rows)
.map(|i| {
if x_arr.is_valid(i) && y_arr.is_valid(i) {
let x = x_arr.value(i);
let y = y_arr.value(i);
Some((Geometry::new(GeomValue::Point(vec![x, y])), x, y))
} else {
None
}
})
.collect()
}
fn points_from_wkb<'a>(
rows: impl Iterator<Item = Option<&'a [u8]>>,
) -> Vec<Option<(Geometry, f64, f64)>> {
rows.map(|wkb| wkb.and_then(parse_wkb_geometry)).collect()
}
if geom_col_name == "__lon_lat__" {
let lon_col = batch.column_by_name("lon")
.or_else(|| batch.column_by_name("longitude"))
.or_else(|| batch.column_by_name("x"));
let lat_col = batch.column_by_name("lat")
.or_else(|| batch.column_by_name("latitude"))
.or_else(|| batch.column_by_name("y"));
if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
if let (Some(lon_arr), Some(lat_arr)) = (
lon.as_any().downcast_ref::<Float64Array>(),
lat.as_any().downcast_ref::<Float64Array>(),
) {
return Ok(points_from_xy(lon_arr, lat_arr, batch.num_rows()));
}
}
anyhow::bail!("Expected lon/lat columns but could not read them");
}
let geom_col = batch.column_by_name(geom_col_name)
.ok_or_else(|| anyhow::anyhow!("Geometry column '{}' not found", geom_col_name))?;
if let Some(struct_array) = geom_col.as_any().downcast_ref::<arrow::array::StructArray>() {
let x_col = struct_array.column_by_name("x")
.or_else(|| struct_array.column_by_name("longitude"))
.or_else(|| struct_array.column_by_name("lon"));
let y_col = struct_array.column_by_name("y")
.or_else(|| struct_array.column_by_name("latitude"))
.or_else(|| struct_array.column_by_name("lat"));
if let (Some(x), Some(y)) = (x_col, y_col) {
if let (Some(x_arr), Some(y_arr)) = (
x.as_any().downcast_ref::<Float64Array>(),
y.as_any().downcast_ref::<Float64Array>(),
) {
return Ok(points_from_xy(x_arr, y_arr, batch.num_rows()));
}
}
}
if let Some(arr) = geom_col.as_any().downcast_ref::<arrow::array::BinaryArray>() {
return Ok(points_from_wkb(
(0..batch.num_rows()).map(|i| arr.is_valid(i).then(|| arr.value(i))),
));
}
if let Some(arr) = geom_col.as_any().downcast_ref::<LargeBinaryArray>() {
return Ok(points_from_wkb(
(0..batch.num_rows()).map(|i| arr.is_valid(i).then(|| arr.value(i))),
));
}
if let Some(arr) = geom_col.as_any().downcast_ref::<BinaryViewArray>() {
return Ok(points_from_wkb(
(0..batch.num_rows()).map(|i| arr.is_valid(i).then(|| arr.value(i))),
));
}
let lon_col = batch.column_by_name("lon")
.or_else(|| batch.column_by_name("longitude"))
.or_else(|| batch.column_by_name("x"));
let lat_col = batch.column_by_name("lat")
.or_else(|| batch.column_by_name("latitude"))
.or_else(|| batch.column_by_name("y"));
if let (Some(lon), Some(lat)) = (lon_col, lat_col) {
if let (Some(lon_arr), Some(lat_arr)) = (
lon.as_any().downcast_ref::<Float64Array>(),
lat.as_any().downcast_ref::<Float64Array>(),
) {
return Ok(points_from_xy(lon_arr, lat_arr, batch.num_rows()));
}
}
anyhow::bail!(
"Could not extract geometries from column '{}' (Arrow type {:?}). \
Supported encodings: WKB (Binary/LargeBinary/BinaryView), separated \
x/y point structs, or top-level lon/lat columns",
geom_col_name,
geom_col.data_type()
)
}
pub use stt_core::timestamp::{
normalize_timestamp_to_ms, reject_negative_timestamp, scale_timestamp_to_ms, TimestampUnit,
};
fn extract_timestamps_from_batch(
batch: &arrow::record_batch::RecordBatch,
col_idx: usize,
time_format: TimeFormat,
strictness: InputStrictness,
row_offset: usize,
) -> Result<(Vec<u64>, usize)> {
let column = batch.column(col_idx);
let mut timestamps = Vec::with_capacity(batch.num_rows());
let mut parse_failures: usize = 0;
let record_failure =
|row: usize, parse_failures: &mut usize, reason: &str| -> Result<u64> {
*parse_failures += 1;
if strictness == InputStrictness::Strict {
anyhow::bail!(
"row {row}: {reason} (rerun without --strict-times to coerce to epoch 0)"
);
}
Ok(0)
};
macro_rules! push_timestamp_column {
($arr:expr, $unit:expr) => {{
for i in 0..batch.num_rows() {
if $arr.is_valid(i) {
timestamps.push(normalize_timestamp_to_ms(row_offset + i, $arr.value(i), $unit)?);
} else {
timestamps.push(record_failure(row_offset + i, &mut parse_failures, "null timestamp")?);
}
}
warn_timestamp_failures(parse_failures, batch.num_rows());
return Ok((timestamps, parse_failures));
}};
}
if let Some(ts_array) = column.as_any().downcast_ref::<TimestampSecondArray>() {
push_timestamp_column!(ts_array, TimestampUnit::Second);
}
if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMillisecondArray>() {
push_timestamp_column!(ts_array, TimestampUnit::Millisecond);
}
if let Some(ts_array) = column.as_any().downcast_ref::<TimestampMicrosecondArray>() {
push_timestamp_column!(ts_array, TimestampUnit::Microsecond);
}
if let Some(ts_array) = column.as_any().downcast_ref::<TimestampNanosecondArray>() {
push_timestamp_column!(ts_array, TimestampUnit::Nanosecond);
}
if let Some(int_array) = column.as_any().downcast_ref::<Int64Array>() {
let unit = match time_format {
TimeFormat::UnixSec => TimestampUnit::Second,
TimeFormat::UnixMs | TimeFormat::Iso8601 => TimestampUnit::Millisecond,
};
for i in 0..batch.num_rows() {
if int_array.is_valid(i) {
timestamps.push(normalize_timestamp_to_ms(row_offset + i, int_array.value(i), unit)?);
} else {
timestamps.push(record_failure(row_offset + i, &mut parse_failures, "null timestamp")?);
}
}
warn_timestamp_failures(parse_failures, batch.num_rows());
return Ok((timestamps, parse_failures));
}
if let Some(str_array) = column.as_any().downcast_ref::<StringArray>() {
for i in 0..batch.num_rows() {
if str_array.is_valid(i) {
let s = str_array.value(i);
match parse_iso8601(s) {
Ok(ms) => {
reject_negative_timestamp(row_offset + i, ms)?;
timestamps.push(ms as u64);
}
Err(_) => {
let reason = format!("unparseable ISO8601 timestamp {s:?}");
timestamps.push(record_failure(row_offset + i, &mut parse_failures, &reason)?);
}
}
} else {
timestamps.push(record_failure(row_offset + i, &mut parse_failures, "null timestamp")?);
}
}
warn_timestamp_failures(parse_failures, batch.num_rows());
return Ok((timestamps, parse_failures));
}
anyhow::bail!(
"unsupported timestamp column type {:?}: expected a Timestamp \
(second/millisecond/microsecond/nanosecond), an Int64 (unix seconds or \
milliseconds per --time-format), or an ISO 8601 String column",
column.data_type()
)
}
fn warn_timestamp_failures(failures: usize, total: usize) {
if failures > 0 {
tracing::warn!(
"{} of {} rows had null or unparseable timestamps; \
these were coerced to Unix epoch 0 (1970-01-01)",
failures,
total
);
}
}
fn warn_geometry_failures(failures: usize, total: usize) {
if failures > 0 {
tracing::warn!(
"{} of {} rows had null or unparseable geometries and were \
skipped (pass --strict-geometry to fail the build instead)",
failures,
total
);
}
}
fn extract_property_value(
batch: &arrow::record_batch::RecordBatch,
col_idx: usize,
row_idx: usize,
) -> Option<serde_json::Value> {
let column = batch.column(col_idx);
if !column.is_valid(row_idx) {
return None;
}
if let Some(arr) = column.as_any().downcast_ref::<Float64Array>() {
return Some(serde_json::json!(arr.value(row_idx)));
}
if let Some(arr) = column.as_any().downcast_ref::<Int64Array>() {
return Some(serde_json::json!(arr.value(row_idx)));
}
if let Some(arr) = column.as_any().downcast_ref::<StringArray>() {
return Some(serde_json::json!(arr.value(row_idx)));
}
if let Some(arr) = column.as_any().downcast_ref::<arrow::array::BooleanArray>() {
return Some(serde_json::json!(arr.value(row_idx)));
}
if let Some(arr) = column.as_any().downcast_ref::<arrow::array::Float32Array>() {
return Some(serde_json::json!(arr.value(row_idx) as f64));
}
if let Some(arr) = column.as_any().downcast_ref::<arrow::array::Int32Array>() {
return Some(serde_json::json!(arr.value(row_idx) as i64));
}
None
}
pub(crate) fn parse_iso8601(s: &str) -> Result<i64> {
use chrono::{DateTime, NaiveDateTime};
if let Ok(dt) = s.parse::<DateTime<chrono::Utc>>() {
return Ok(dt.timestamp_millis());
}
for fmt in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] {
if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
return Ok(dt.and_utc().timestamp_millis());
}
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
return Ok(dt.timestamp_millis());
}
anyhow::bail!("Failed to parse timestamp: {}", s)
}
pub(crate) fn parse_wkb_geometry(wkb: &[u8]) -> Option<(Geometry, f64, f64)> {
use geo::algorithm::centroid::Centroid;
use geozero::ToGeo;
let geo_geom = geozero::wkb::Ewkb(wkb.to_vec()).to_geo().ok()?;
let centroid = geo_geom.centroid()?;
Some((
Geometry::new(GeomValue::from(&geo_geom)),
centroid.x(),
centroid.y(),
))
}
#[cfg(test)]
mod tests {
use super::parse_iso8601;
const SEP_28_2024_NOON_MS: i64 = 1_727_524_800_000;
#[test]
fn parses_zoned_timestamps() {
assert_eq!(parse_iso8601("2024-09-28T12:00:00Z").unwrap(), SEP_28_2024_NOON_MS);
assert_eq!(parse_iso8601("2024-09-28T14:00:00+02:00").unwrap(), SEP_28_2024_NOON_MS);
}
#[test]
fn parses_naive_timestamps_as_utc() {
assert_eq!(parse_iso8601("2024-09-28T12:00:00").unwrap(), SEP_28_2024_NOON_MS);
assert_eq!(parse_iso8601("2024-09-28 12:00:00").unwrap(), SEP_28_2024_NOON_MS);
}
#[test]
fn parses_naive_fractional_seconds() {
assert_eq!(parse_iso8601("2024-09-28T12:00:00.250").unwrap(), SEP_28_2024_NOON_MS + 250);
assert_eq!(parse_iso8601("2024-09-28 12:00:00.250").unwrap(), SEP_28_2024_NOON_MS + 250);
}
#[test]
fn parses_date_only_as_utc_midnight() {
assert_eq!(parse_iso8601("2024-09-28").unwrap(), SEP_28_2024_NOON_MS - 12 * 3_600_000);
}
#[test]
fn rejects_garbage() {
assert!(parse_iso8601("not a time").is_err());
assert!(parse_iso8601("2024-13-40T99:00:00").is_err());
assert!(parse_iso8601("").is_err());
}
}