1use std::sync::Arc;
15
16use anyhow::{Context, Result};
17use arrow::array::RecordBatch;
18use arrow::datatypes::Schema;
19use arrow::ipc::writer::StreamWriter;
20use serde::{Deserialize, Serialize};
21use stt_core::arrow_tile::{
22 decode_tile, encode_tile_with, ColumnarLayer, Coord, EncoderConfig, GeometryColumn,
23 PropertyColumn,
24};
25use stt_core::compression::compress_zstd_with_dict_level;
26
27use crate::loader::{PropValue, SampledFeature};
28
29const MIN_MEASURE_FEATURES: usize = 50;
33
34#[derive(Debug, Clone)]
38pub struct MeasureSettings {
39 pub zstd_level: i32,
41 pub quantize_coords_m: Option<f64>,
44 pub quantize_attrs_auto: bool,
46}
47
48impl Default for MeasureSettings {
49 fn default() -> Self {
51 Self {
52 zstd_level: 3,
53 quantize_coords_m: None,
54 quantize_attrs_auto: false,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct MeasuredEncoding {
63 pub features: usize,
67 pub geometry_kind: String,
70 pub bytes_total: usize,
72 pub bytes_per_feature: f64,
74 pub zstd_ratio: f64,
76 pub per_column: Vec<ColumnCost>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ColumnCost {
83 pub name: String,
85 pub compressed_bytes: usize,
87 pub share: f64,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96enum GeomKind {
97 Point = 0,
98 Line = 1,
99 Polygon = 2,
100}
101
102impl GeomKind {
103 fn name(self) -> &'static str {
104 match self {
105 GeomKind::Point => "point",
106 GeomKind::Line => "line",
107 GeomKind::Polygon => "polygon",
108 }
109 }
110}
111
112fn kind_of(geom: &geo_types::Geometry<f64>) -> Option<GeomKind> {
115 use geo_types::Geometry as G;
116 match geom {
117 G::Point(_) | G::MultiPoint(_) => Some(GeomKind::Point),
118 G::Line(_) | G::LineString(_) | G::MultiLineString(_) => Some(GeomKind::Line),
119 G::Polygon(_) | G::MultiPolygon(_) | G::Rect(_) | G::Triangle(_) => {
120 Some(GeomKind::Polygon)
121 }
122 G::GeometryCollection(_) => None,
123 }
124}
125
126pub fn measure_sample(
135 sample: &[SampledFeature],
136 settings: &MeasureSettings,
137) -> Result<Option<MeasuredEncoding>> {
138 let mut counts = [0usize; 3];
140 for f in sample {
141 if let Some(kind) = kind_of(&f.geometry) {
142 counts[kind as usize] += 1;
143 }
144 }
145 let mut dominant = GeomKind::Point;
146 for kind in [GeomKind::Line, GeomKind::Polygon] {
147 if counts[kind as usize] > counts[dominant as usize] {
148 dominant = kind;
149 }
150 }
151
152 let subset: Vec<&SampledFeature> = sample
153 .iter()
154 .filter(|f| kind_of(&f.geometry) == Some(dominant))
155 .collect();
156 if subset.len() < MIN_MEASURE_FEATURES {
157 return Ok(None);
158 }
159
160 let layer = build_layer(&subset, dominant);
161 let cfg = EncoderConfig {
162 quantize_coords_m: settings.quantize_coords_m,
163 quantize_attrs_auto: settings.quantize_attrs_auto,
164 ..EncoderConfig::default()
165 };
166 let payload = encode_tile_with(&[layer], &cfg).context("sample tile encode failed")?;
167 let compressed = compress_zstd_with_dict_level(&payload, None, settings.zstd_level)
168 .context("sample tile compression failed")?;
169 let per_column = attribute_columns(&payload, settings.zstd_level)?;
170
171 Ok(Some(MeasuredEncoding {
172 features: subset.len(),
173 geometry_kind: dominant.name().to_string(),
174 bytes_total: compressed.len(),
175 bytes_per_feature: compressed.len() as f64 / subset.len() as f64,
176 zstd_ratio: payload.len() as f64 / compressed.len().max(1) as f64,
177 per_column,
178 }))
179}
180
181fn line_coords(ls: &geo_types::LineString<f64>) -> Vec<Coord> {
182 ls.0.iter().map(|c| [c.x, c.y]).collect()
183}
184
185fn polygon_rings(polygon: &geo_types::Polygon<f64>) -> Vec<Vec<Coord>> {
186 std::iter::once(polygon.exterior())
187 .chain(polygon.interiors().iter())
188 .map(line_coords)
189 .collect()
190}
191
192fn point_parts(geom: &geo_types::Geometry<f64>) -> Vec<Coord> {
195 use geo_types::Geometry as G;
196 match geom {
197 G::Point(p) => vec![[p.x(), p.y()]],
198 G::MultiPoint(mp) => mp.0.iter().map(|p| [p.x(), p.y()]).collect(),
199 _ => Vec::new(),
200 }
201}
202
203fn line_parts(geom: &geo_types::Geometry<f64>) -> Vec<Vec<Coord>> {
205 use geo_types::Geometry as G;
206 let parts = match geom {
207 G::Line(l) => vec![vec![[l.start.x, l.start.y], [l.end.x, l.end.y]]],
208 G::LineString(ls) => vec![line_coords(ls)],
209 G::MultiLineString(mls) => mls.0.iter().map(line_coords).collect(),
210 _ => Vec::new(),
211 };
212 parts.into_iter().filter(|p| !p.is_empty()).collect()
213}
214
215fn polygon_parts(geom: &geo_types::Geometry<f64>) -> Vec<Vec<Vec<Coord>>> {
217 use geo_types::Geometry as G;
218 let parts = match geom {
219 G::Polygon(p) => vec![polygon_rings(p)],
220 G::MultiPolygon(mp) => mp.0.iter().map(polygon_rings).collect(),
221 G::Rect(r) => vec![polygon_rings(&r.to_polygon())],
222 G::Triangle(t) => vec![polygon_rings(&t.to_polygon())],
223 _ => Vec::new(),
224 };
225 parts
226 .into_iter()
227 .filter(|rings| rings.iter().any(|ring| !ring.is_empty()))
228 .collect()
229}
230
231fn build_layer(subset: &[&SampledFeature], kind: GeomKind) -> ColumnarLayer {
236 let mut names: Vec<String> = Vec::new();
240 let mut is_numeric: Vec<bool> = Vec::new();
241 for feature in subset {
242 for (name, value) in &feature.properties {
243 if !names.iter().any(|n| n == name) {
244 names.push(name.clone());
245 is_numeric.push(matches!(value, PropValue::Number(_)));
246 }
247 }
248 }
249
250 let mut ids: Vec<u64> = Vec::new();
251 let mut starts: Vec<i64> = Vec::new();
252 let mut ends: Vec<i64> = Vec::new();
253 let mut points: Vec<Coord> = Vec::new();
254 let mut lines: Vec<Vec<Coord>> = Vec::new();
255 let mut polygons: Vec<Vec<Vec<Coord>>> = Vec::new();
256 let mut numeric_cols: Vec<Vec<Option<f64>>> = vec![Vec::new(); names.len()];
257 let mut categorical_cols: Vec<Vec<Option<String>>> = vec![Vec::new(); names.len()];
258
259 for feature in subset {
260 let n_parts = match kind {
261 GeomKind::Point => {
262 let parts = point_parts(&feature.geometry);
263 let n = parts.len();
264 points.extend(parts);
265 n
266 }
267 GeomKind::Line => {
268 let parts = line_parts(&feature.geometry);
269 let n = parts.len();
270 lines.extend(parts);
271 n
272 }
273 GeomKind::Polygon => {
274 let parts = polygon_parts(&feature.geometry);
275 let n = parts.len();
276 polygons.extend(parts);
277 n
278 }
279 };
280 for _ in 0..n_parts {
281 ids.push(ids.len() as u64);
282 starts.push(feature.timestamp_ms as i64);
283 ends.push(feature.timestamp_ms as i64);
284 for (col, name) in names.iter().enumerate() {
285 let value = feature
286 .properties
287 .iter()
288 .find(|(n, _)| n == name)
289 .map(|(_, v)| v);
290 if is_numeric[col] {
291 numeric_cols[col].push(match value {
292 Some(PropValue::Number(x)) => Some(*x),
293 _ => None,
294 });
295 } else {
296 categorical_cols[col].push(match value {
297 Some(PropValue::Text(s)) => Some(s.clone()),
298 _ => None,
299 });
300 }
301 }
302 }
303 }
304
305 let geometry = match kind {
306 GeomKind::Point => GeometryColumn::Point(points),
307 GeomKind::Line => GeometryColumn::LineString(lines),
308 GeomKind::Polygon => GeometryColumn::Polygon(polygons),
309 };
310 let properties = names
311 .into_iter()
312 .enumerate()
313 .map(|(col, name)| {
314 let column = if is_numeric[col] {
315 PropertyColumn::Numeric(std::mem::take(&mut numeric_cols[col]))
316 } else {
317 PropertyColumn::Categorical(std::mem::take(&mut categorical_cols[col]))
318 };
319 (name, column)
320 })
321 .collect();
322
323 ColumnarLayer {
324 name: "default".to_string(),
325 feature_ids: ids,
326 start_times: starts,
327 end_times: ends,
328 geometry,
329 vertex_times: None,
330 vertex_values: None,
331 vertex_value_matrix: None,
332 triangles: None,
333 properties,
334 }
335}
336
337fn attribute_columns(payload: &[u8], zstd_level: i32) -> Result<Vec<ColumnCost>> {
342 let layers = decode_tile(payload).context("sample tile decode failed")?;
343 let mut costs: Vec<(String, usize)> = Vec::new();
344 for layer in &layers {
345 let batch = &layer.batch;
346 let schema = batch.schema();
347 for (idx, field) in schema.fields().iter().enumerate() {
348 let one = RecordBatch::try_new(
349 Arc::new(Schema::new(vec![field.as_ref().clone()])),
350 vec![batch.column(idx).clone()],
351 )
352 .context("single-column batch build failed")?;
353 let mut ipc = Vec::new();
354 {
355 let mut writer = StreamWriter::try_new(&mut ipc, &one.schema())
356 .context("column IPC writer init failed")?;
357 writer.write(&one).context("column IPC write failed")?;
358 writer.finish().context("column IPC finish failed")?;
359 }
360 let compressed = compress_zstd_with_dict_level(&ipc, None, zstd_level)
361 .context("column compression failed")?;
362 costs.push((field.name().clone(), compressed.len()));
363 }
364 }
365 let total: usize = costs.iter().map(|(_, bytes)| bytes).sum();
366 let mut out: Vec<ColumnCost> = costs
367 .into_iter()
368 .map(|(name, compressed_bytes)| ColumnCost {
369 name,
370 compressed_bytes,
371 share: compressed_bytes as f64 / total.max(1) as f64,
372 })
373 .collect();
374 out.sort_by(|a, b| {
375 b.compressed_bytes
376 .cmp(&a.compressed_bytes)
377 .then_with(|| a.name.cmp(&b.name))
378 });
379 Ok(out)
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use geo_types::{Geometry, LineString, Point};
386
387 fn point_sample(n: usize) -> Vec<SampledFeature> {
391 (0..n)
392 .map(|i| {
393 let jitter = |salt: u64| {
394 ((i as u64).wrapping_add(salt).wrapping_mul(2_654_435_761) % 100_000) as f64
395 * 1e-7
396 };
397 SampledFeature {
398 geometry: Geometry::Point(Point::new(
399 -73.5 + i as f64 * 0.0013 + jitter(0),
400 45.5 + (i % 7) as f64 * 0.0021 + jitter(17),
401 )),
402 timestamp_ms: 1_600_000_000_000 + i as u64 * 1_000,
403 properties: vec![
404 (
405 "magnitude".to_string(),
406 PropValue::Number(1.0 + (i % 90) as f64 * 0.137),
407 ),
408 (
409 "region".to_string(),
410 PropValue::Text(format!("region-{}", i % 5)),
411 ),
412 ],
413 }
414 })
415 .collect()
416 }
417
418 #[test]
419 fn measures_point_sample() {
420 let sample = point_sample(200);
421 let measured = measure_sample(&sample, &MeasureSettings::default())
422 .unwrap()
423 .expect("200 features is enough to measure");
424 assert_eq!(measured.features, 200);
425 assert_eq!(measured.geometry_kind, "point");
426 assert!(measured.bytes_total > 0);
427 assert!(measured.bytes_per_feature > 0.0);
428 assert!(measured.zstd_ratio > 0.0);
429
430 let share_sum: f64 = measured.per_column.iter().map(|c| c.share).sum();
431 assert!((share_sum - 1.0).abs() < 1e-9, "shares sum to {share_sum}");
432 for name in ["geometry", "magnitude", "region", "id", "start_time"] {
433 assert!(
434 measured.per_column.iter().any(|c| c.name == name),
435 "missing column {name}"
436 );
437 }
438 for pair in measured.per_column.windows(2) {
440 assert!(pair[0].compressed_bytes >= pair[1].compressed_bytes);
441 }
442 }
443
444 #[test]
445 fn quantized_coords_never_larger() {
446 let sample = point_sample(500);
447 let base = measure_sample(&sample, &MeasureSettings::default())
448 .unwrap()
449 .unwrap();
450 let quantized = measure_sample(
451 &sample,
452 &MeasureSettings {
453 quantize_coords_m: Some(0.1),
454 ..MeasureSettings::default()
455 },
456 )
457 .unwrap()
458 .unwrap();
459 assert!(
460 quantized.bytes_total <= base.bytes_total,
461 "quantized {} > unquantized {}",
462 quantized.bytes_total,
463 base.bytes_total
464 );
465 }
466
467 #[test]
468 fn mixed_geometry_measures_dominant_kind_subset() {
469 let mut sample = point_sample(150);
470 for i in 0..50 {
471 sample.push(SampledFeature {
472 geometry: Geometry::LineString(LineString::from(vec![
473 (0.0, 0.0),
474 (i as f64 * 0.01, 1.0),
475 ])),
476 timestamp_ms: 0,
477 properties: vec![],
478 });
479 }
480 let measured = measure_sample(&sample, &MeasureSettings::default())
481 .unwrap()
482 .unwrap();
483 assert_eq!(measured.geometry_kind, "point");
484 assert_eq!(measured.features, 150);
485 }
486
487 #[test]
488 fn empty_or_tiny_sample_returns_none() {
489 assert!(measure_sample(&[], &MeasureSettings::default())
490 .unwrap()
491 .is_none());
492 let tiny = point_sample(MIN_MEASURE_FEATURES - 1);
493 assert!(measure_sample(&tiny, &MeasureSettings::default())
494 .unwrap()
495 .is_none());
496 }
497}