use std::sync::Arc;
use anyhow::{Context, Result};
use arrow::array::RecordBatch;
use arrow::datatypes::Schema;
use arrow::ipc::writer::StreamWriter;
use serde::{Deserialize, Serialize};
use stt_core::arrow_tile::{
decode_tile, encode_tile_with, ColumnarLayer, Coord, EncoderConfig, GeometryColumn,
PropertyColumn,
};
use stt_core::compression::compress_zstd_with_dict_level;
use crate::loader::{PropValue, SampledFeature};
const MIN_MEASURE_FEATURES: usize = 50;
#[derive(Debug, Clone)]
pub struct MeasureSettings {
pub zstd_level: i32,
pub quantize_coords_m: Option<f64>,
pub quantize_attrs_auto: bool,
}
impl Default for MeasureSettings {
fn default() -> Self {
Self {
zstd_level: 3,
quantize_coords_m: None,
quantize_attrs_auto: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeasuredEncoding {
pub features: usize,
pub geometry_kind: String,
pub bytes_total: usize,
pub bytes_per_feature: f64,
pub zstd_ratio: f64,
pub per_column: Vec<ColumnCost>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColumnCost {
pub name: String,
pub compressed_bytes: usize,
pub share: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GeomKind {
Point = 0,
Line = 1,
Polygon = 2,
}
impl GeomKind {
fn name(self) -> &'static str {
match self {
GeomKind::Point => "point",
GeomKind::Line => "line",
GeomKind::Polygon => "polygon",
}
}
}
fn kind_of(geom: &geo_types::Geometry<f64>) -> Option<GeomKind> {
use geo_types::Geometry as G;
match geom {
G::Point(_) | G::MultiPoint(_) => Some(GeomKind::Point),
G::Line(_) | G::LineString(_) | G::MultiLineString(_) => Some(GeomKind::Line),
G::Polygon(_) | G::MultiPolygon(_) | G::Rect(_) | G::Triangle(_) => {
Some(GeomKind::Polygon)
}
G::GeometryCollection(_) => None,
}
}
pub fn measure_sample(
sample: &[SampledFeature],
settings: &MeasureSettings,
) -> Result<Option<MeasuredEncoding>> {
let mut counts = [0usize; 3];
for f in sample {
if let Some(kind) = kind_of(&f.geometry) {
counts[kind as usize] += 1;
}
}
let mut dominant = GeomKind::Point;
for kind in [GeomKind::Line, GeomKind::Polygon] {
if counts[kind as usize] > counts[dominant as usize] {
dominant = kind;
}
}
let subset: Vec<&SampledFeature> = sample
.iter()
.filter(|f| kind_of(&f.geometry) == Some(dominant))
.collect();
if subset.len() < MIN_MEASURE_FEATURES {
return Ok(None);
}
let layer = build_layer(&subset, dominant);
let cfg = EncoderConfig {
quantize_coords_m: settings.quantize_coords_m,
quantize_attrs_auto: settings.quantize_attrs_auto,
..EncoderConfig::default()
};
let payload = encode_tile_with(&[layer], &cfg).context("sample tile encode failed")?;
let compressed = compress_zstd_with_dict_level(&payload, None, settings.zstd_level)
.context("sample tile compression failed")?;
let per_column = attribute_columns(&payload, settings.zstd_level)?;
Ok(Some(MeasuredEncoding {
features: subset.len(),
geometry_kind: dominant.name().to_string(),
bytes_total: compressed.len(),
bytes_per_feature: compressed.len() as f64 / subset.len() as f64,
zstd_ratio: payload.len() as f64 / compressed.len().max(1) as f64,
per_column,
}))
}
fn line_coords(ls: &geo_types::LineString<f64>) -> Vec<Coord> {
ls.0.iter().map(|c| [c.x, c.y]).collect()
}
fn polygon_rings(polygon: &geo_types::Polygon<f64>) -> Vec<Vec<Coord>> {
std::iter::once(polygon.exterior())
.chain(polygon.interiors().iter())
.map(line_coords)
.collect()
}
fn point_parts(geom: &geo_types::Geometry<f64>) -> Vec<Coord> {
use geo_types::Geometry as G;
match geom {
G::Point(p) => vec![[p.x(), p.y()]],
G::MultiPoint(mp) => mp.0.iter().map(|p| [p.x(), p.y()]).collect(),
_ => Vec::new(),
}
}
fn line_parts(geom: &geo_types::Geometry<f64>) -> Vec<Vec<Coord>> {
use geo_types::Geometry as G;
let parts = match geom {
G::Line(l) => vec![vec![[l.start.x, l.start.y], [l.end.x, l.end.y]]],
G::LineString(ls) => vec![line_coords(ls)],
G::MultiLineString(mls) => mls.0.iter().map(line_coords).collect(),
_ => Vec::new(),
};
parts.into_iter().filter(|p| !p.is_empty()).collect()
}
fn polygon_parts(geom: &geo_types::Geometry<f64>) -> Vec<Vec<Vec<Coord>>> {
use geo_types::Geometry as G;
let parts = match geom {
G::Polygon(p) => vec![polygon_rings(p)],
G::MultiPolygon(mp) => mp.0.iter().map(polygon_rings).collect(),
G::Rect(r) => vec![polygon_rings(&r.to_polygon())],
G::Triangle(t) => vec![polygon_rings(&t.to_polygon())],
_ => Vec::new(),
};
parts
.into_iter()
.filter(|rings| rings.iter().any(|ring| !ring.is_empty()))
.collect()
}
fn build_layer(subset: &[&SampledFeature], kind: GeomKind) -> ColumnarLayer {
let mut names: Vec<String> = Vec::new();
let mut is_numeric: Vec<bool> = Vec::new();
for feature in subset {
for (name, value) in &feature.properties {
if !names.iter().any(|n| n == name) {
names.push(name.clone());
is_numeric.push(matches!(value, PropValue::Number(_)));
}
}
}
let mut ids: Vec<u64> = Vec::new();
let mut starts: Vec<i64> = Vec::new();
let mut ends: Vec<i64> = Vec::new();
let mut points: Vec<Coord> = Vec::new();
let mut lines: Vec<Vec<Coord>> = Vec::new();
let mut polygons: Vec<Vec<Vec<Coord>>> = Vec::new();
let mut numeric_cols: Vec<Vec<Option<f64>>> = vec![Vec::new(); names.len()];
let mut categorical_cols: Vec<Vec<Option<String>>> = vec![Vec::new(); names.len()];
for feature in subset {
let n_parts = match kind {
GeomKind::Point => {
let parts = point_parts(&feature.geometry);
let n = parts.len();
points.extend(parts);
n
}
GeomKind::Line => {
let parts = line_parts(&feature.geometry);
let n = parts.len();
lines.extend(parts);
n
}
GeomKind::Polygon => {
let parts = polygon_parts(&feature.geometry);
let n = parts.len();
polygons.extend(parts);
n
}
};
for _ in 0..n_parts {
ids.push(ids.len() as u64);
starts.push(feature.timestamp_ms as i64);
ends.push(feature.timestamp_ms as i64);
for (col, name) in names.iter().enumerate() {
let value = feature
.properties
.iter()
.find(|(n, _)| n == name)
.map(|(_, v)| v);
if is_numeric[col] {
numeric_cols[col].push(match value {
Some(PropValue::Number(x)) => Some(*x),
_ => None,
});
} else {
categorical_cols[col].push(match value {
Some(PropValue::Text(s)) => Some(s.clone()),
_ => None,
});
}
}
}
}
let geometry = match kind {
GeomKind::Point => GeometryColumn::Point(points),
GeomKind::Line => GeometryColumn::LineString(lines),
GeomKind::Polygon => GeometryColumn::Polygon(polygons),
};
let properties = names
.into_iter()
.enumerate()
.map(|(col, name)| {
let column = if is_numeric[col] {
PropertyColumn::Numeric(std::mem::take(&mut numeric_cols[col]))
} else {
PropertyColumn::Categorical(std::mem::take(&mut categorical_cols[col]))
};
(name, column)
})
.collect();
ColumnarLayer {
name: "default".to_string(),
feature_ids: ids,
start_times: starts,
end_times: ends,
geometry,
vertex_times: None,
vertex_values: None,
vertex_value_matrix: None,
triangles: None,
properties,
}
}
fn attribute_columns(payload: &[u8], zstd_level: i32) -> Result<Vec<ColumnCost>> {
let layers = decode_tile(payload).context("sample tile decode failed")?;
let mut costs: Vec<(String, usize)> = Vec::new();
for layer in &layers {
let batch = &layer.batch;
let schema = batch.schema();
for (idx, field) in schema.fields().iter().enumerate() {
let one = RecordBatch::try_new(
Arc::new(Schema::new(vec![field.as_ref().clone()])),
vec![batch.column(idx).clone()],
)
.context("single-column batch build failed")?;
let mut ipc = Vec::new();
{
let mut writer = StreamWriter::try_new(&mut ipc, &one.schema())
.context("column IPC writer init failed")?;
writer.write(&one).context("column IPC write failed")?;
writer.finish().context("column IPC finish failed")?;
}
let compressed = compress_zstd_with_dict_level(&ipc, None, zstd_level)
.context("column compression failed")?;
costs.push((field.name().clone(), compressed.len()));
}
}
let total: usize = costs.iter().map(|(_, bytes)| bytes).sum();
let mut out: Vec<ColumnCost> = costs
.into_iter()
.map(|(name, compressed_bytes)| ColumnCost {
name,
compressed_bytes,
share: compressed_bytes as f64 / total.max(1) as f64,
})
.collect();
out.sort_by(|a, b| {
b.compressed_bytes
.cmp(&a.compressed_bytes)
.then_with(|| a.name.cmp(&b.name))
});
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use geo_types::{Geometry, LineString, Point};
fn point_sample(n: usize) -> Vec<SampledFeature> {
(0..n)
.map(|i| {
let jitter = |salt: u64| {
((i as u64).wrapping_add(salt).wrapping_mul(2_654_435_761) % 100_000) as f64
* 1e-7
};
SampledFeature {
geometry: Geometry::Point(Point::new(
-73.5 + i as f64 * 0.0013 + jitter(0),
45.5 + (i % 7) as f64 * 0.0021 + jitter(17),
)),
timestamp_ms: 1_600_000_000_000 + i as u64 * 1_000,
properties: vec![
(
"magnitude".to_string(),
PropValue::Number(1.0 + (i % 90) as f64 * 0.137),
),
(
"region".to_string(),
PropValue::Text(format!("region-{}", i % 5)),
),
],
}
})
.collect()
}
#[test]
fn measures_point_sample() {
let sample = point_sample(200);
let measured = measure_sample(&sample, &MeasureSettings::default())
.unwrap()
.expect("200 features is enough to measure");
assert_eq!(measured.features, 200);
assert_eq!(measured.geometry_kind, "point");
assert!(measured.bytes_total > 0);
assert!(measured.bytes_per_feature > 0.0);
assert!(measured.zstd_ratio > 0.0);
let share_sum: f64 = measured.per_column.iter().map(|c| c.share).sum();
assert!((share_sum - 1.0).abs() < 1e-9, "shares sum to {share_sum}");
for name in ["geometry", "magnitude", "region", "id", "start_time"] {
assert!(
measured.per_column.iter().any(|c| c.name == name),
"missing column {name}"
);
}
for pair in measured.per_column.windows(2) {
assert!(pair[0].compressed_bytes >= pair[1].compressed_bytes);
}
}
#[test]
fn quantized_coords_never_larger() {
let sample = point_sample(500);
let base = measure_sample(&sample, &MeasureSettings::default())
.unwrap()
.unwrap();
let quantized = measure_sample(
&sample,
&MeasureSettings {
quantize_coords_m: Some(0.1),
..MeasureSettings::default()
},
)
.unwrap()
.unwrap();
assert!(
quantized.bytes_total <= base.bytes_total,
"quantized {} > unquantized {}",
quantized.bytes_total,
base.bytes_total
);
}
#[test]
fn mixed_geometry_measures_dominant_kind_subset() {
let mut sample = point_sample(150);
for i in 0..50 {
sample.push(SampledFeature {
geometry: Geometry::LineString(LineString::from(vec![
(0.0, 0.0),
(i as f64 * 0.01, 1.0),
])),
timestamp_ms: 0,
properties: vec![],
});
}
let measured = measure_sample(&sample, &MeasureSettings::default())
.unwrap()
.unwrap();
assert_eq!(measured.geometry_kind, "point");
assert_eq!(measured.features, 150);
}
#[test]
fn empty_or_tiny_sample_returns_none() {
assert!(measure_sample(&[], &MeasureSettings::default())
.unwrap()
.is_none());
let tiny = point_sample(MIN_MEASURE_FEATURES - 1);
assert!(measure_sample(&tiny, &MeasureSettings::default())
.unwrap()
.is_none());
}
}