use crate::clip::{clip_trajectory, is_clippable_trajectory, ClipConfig, ClippedSegment};
use crate::columnar::{
build_layer_from_segments, build_layers_from_features_with, AttributeFilter, ColumnarOptions,
};
use crate::input::ParsedFeature;
use anyhow::Result;
use rayon::prelude::*;
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use stt_core::arrow_tile::{encode_tile, ColumnarLayer};
use stt_core::budget::TileBudget;
use stt_core::projection;
use stt_core::tile::TileId;
#[derive(Debug)]
pub struct GeneratedTile {
pub id: TileId,
pub time_start: i64,
pub time_end: i64,
pub cover_t_min: i64,
pub layers: Vec<ColumnarLayer>,
}
impl GeneratedTile {
pub fn feature_count(&self) -> u32 {
self.layers.iter().map(|l| l.feature_count() as u32).sum()
}
}
pub trait TileWriter {
fn write_tile(&mut self, tile: &GeneratedTile) -> Result<()>;
}
#[derive(Debug, Default)]
pub struct TileStats {
pub total_tiles: usize,
pub clipped_segments: usize,
pub original_features: usize,
}
#[derive(Debug, Clone)]
pub struct TileConfig {
pub min_zoom: u8,
pub max_zoom: u8,
pub layer_name: String,
pub temporal_bucket_ms: u64,
pub clip_trajectories: bool,
pub clip_min_vertices: usize,
pub simplify: bool,
pub simplify_max_zoom: u8,
pub pre_tessellate: bool,
pub temporal_lod: Vec<stt_core::metadata::TemporalLodLevel>,
pub min_features_per_tile: u32,
pub time_aware_simplify: bool,
pub adaptive_target_features: Option<u32>,
pub min_zoom_field: Option<String>,
pub max_zoom_field: Option<String>,
pub tile_budget: Option<TileBudget>,
pub attribute_filter: AttributeFilter,
pub property_types: Arc<crate::columnar::PropertyTypes>,
}
impl Default for TileConfig {
fn default() -> Self {
Self {
min_zoom: 0,
max_zoom: 14,
layer_name: "default".to_string(),
temporal_bucket_ms: 3600 * 1000,
clip_trajectories: true,
clip_min_vertices: 2,
simplify: false,
simplify_max_zoom: 14,
pre_tessellate: false,
temporal_lod: Vec::new(),
min_features_per_tile: 1,
time_aware_simplify: false,
adaptive_target_features: None,
min_zoom_field: None,
max_zoom_field: None,
tile_budget: None,
attribute_filter: AttributeFilter::KeepAll,
property_types: Arc::default(),
}
}
}
impl TileConfig {
fn columnar_options(&self) -> ColumnarOptions {
ColumnarOptions {
pre_tessellate: self.pre_tessellate,
attribute_filter: self.attribute_filter.clone(),
property_types: Arc::clone(&self.property_types),
}
}
}
impl TileConfig {
pub fn lod_for_zoom(&self, zoom: u8) -> Option<&stt_core::metadata::TemporalLodLevel> {
self.temporal_lod
.iter()
.filter(|l| zoom <= l.max_zoom_level)
.max_by_key(|l| l.bucket_ms)
}
}
#[derive(Debug)]
pub struct LodTaggedTile {
pub tile: GeneratedTile,
pub temporal_bucket_ms: Option<u64>,
}
#[derive(Debug, Clone)]
enum TileFeature<'a> {
Original(&'a ParsedFeature),
Clipped(ClippedSegment),
}
impl<'a> TileFeature<'a> {
fn timestamp(&self) -> u64 {
match self {
TileFeature::Original(f) => f.timestamp,
TileFeature::Clipped(s) => s.start_time,
}
}
fn end_timestamp(&self) -> u64 {
match self {
TileFeature::Original(f) => f.end_timestamp.unwrap_or(f.timestamp),
TileFeature::Clipped(s) => s.end_time,
}
}
}
pub fn generate_tiles_with_lod(
features: &[ParsedFeature],
config: &TileConfig,
workers: usize,
) -> Result<Vec<LodTaggedTile>> {
validate_lod(config)?;
let base = generate_tiles(features, config, workers)?;
let mut out: Vec<LodTaggedTile> = base
.into_iter()
.map(|tile| LodTaggedTile {
tile,
temporal_bucket_ms: Some(config.temporal_bucket_ms),
})
.collect();
if !config.temporal_lod.is_empty() {
let pool = build_pool(workers)?;
pool.install(|| -> Result<()> {
for level in &config.temporal_lod {
let lod_tiles = generate_lod_level(features, level, config)?;
tracing::info!(
"temporal LOD level bucket={}ms max_zoom={}: {} tiles",
level.bucket_ms,
level.max_zoom_level,
lod_tiles.len()
);
for tile in lod_tiles {
out.push(LodTaggedTile {
tile,
temporal_bucket_ms: Some(level.bucket_ms),
});
}
}
Ok(())
})?;
}
Ok(out)
}
fn generate_lod_level(
features: &[ParsedFeature],
level: &stt_core::metadata::TemporalLodLevel,
base_config: &TileConfig,
) -> Result<Vec<GeneratedTile>> {
let lod_config = TileConfig {
temporal_bucket_ms: level.bucket_ms,
max_zoom: base_config.max_zoom.min(level.max_zoom_level),
temporal_lod: Vec::new(), ..base_config.clone()
};
if lod_config.max_zoom < lod_config.min_zoom {
return Ok(Vec::new());
}
let clip_config = clip_config_from(&lod_config);
let total_clipped = AtomicUsize::new(0);
let total_original = AtomicUsize::new(0);
let mut all = Vec::new();
for zoom in lod_config.min_zoom..=lod_config.max_zoom {
let tiles = process_zoom_level(
features,
zoom,
&lod_config,
&clip_config,
&total_clipped,
&total_original,
)?;
all.extend(tiles);
}
Ok(all)
}
fn validate_lod(config: &TileConfig) -> Result<()> {
if config.temporal_lod.is_empty() {
return Ok(());
}
let base = config.temporal_bucket_ms;
anyhow::ensure!(base > 0, "temporal_bucket_ms must be > 0 when using LOD");
let mut prev: Option<u64> = None;
for (i, level) in config.temporal_lod.iter().enumerate() {
anyhow::ensure!(
level.bucket_ms > base,
"temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
level.bucket_ms,
base
);
anyhow::ensure!(
level.bucket_ms % base == 0,
"temporal_lod[{i}].bucket_ms ({}) must be a multiple of base ({})",
level.bucket_ms,
base
);
if let Some(p) = prev {
anyhow::ensure!(
level.bucket_ms > p,
"temporal_lod must be sorted by ascending bucket_ms"
);
}
prev = Some(level.bucket_ms);
}
Ok(())
}
pub fn generate_tiles(
features: &[ParsedFeature],
config: &TileConfig,
workers: usize,
) -> Result<Vec<GeneratedTile>> {
let pool = build_pool(workers)?;
let clip_config = clip_config_from(config);
let total_clipped = AtomicUsize::new(0);
let total_original = AtomicUsize::new(0);
let mut all = Vec::new();
pool.install(|| -> Result<()> {
for zoom in config.min_zoom..=config.max_zoom {
let start = std::time::Instant::now();
let tiles = process_zoom_level(
features,
zoom,
config,
&clip_config,
&total_clipped,
&total_original,
)?;
tracing::info!(
"zoom {}: {} tiles in {:.1}s",
zoom,
tiles.len(),
start.elapsed().as_secs_f64()
);
all.extend(tiles);
}
Ok(())
})?;
Ok(all)
}
pub fn encode_single_tile_counted(
features: &[ParsedFeature],
z: u8,
x: u32,
y: u32,
t: i64,
config: &TileConfig,
encoder: &stt_core::arrow_tile::EncoderConfig,
) -> Result<Option<(Vec<u8>, u32)>> {
let clip_config = clip_config_from(config);
let bucket_ms = config.temporal_bucket_ms.max(1);
let bucket_start = (t.max(0) as u64 / bucket_ms) * bucket_ms;
let mut chunk: Vec<TileFeature> = Vec::new();
for feature in features {
if feature_out_of_band(feature, z, config) {
continue;
}
let should_clip = config.clip_trajectories
&& is_clippable_trajectory(&feature.geojson, feature.end_timestamp);
if should_clip {
let segments = clip_trajectory(
&feature.geojson,
feature.shared_properties.clone(),
feature.timestamp,
feature.end_timestamp.unwrap_or(feature.timestamp),
z,
&clip_config,
feature.vertex_timestamps.as_deref(),
feature.vertex_values.as_deref(),
feature.vertex_value_matrix.as_deref(),
);
if segments.is_empty() {
let (fx, fy) =
projection::lonlat_to_tile(feature.lon, feature.lat, z).unwrap_or((0, 0));
if fx == x && fy == y {
chunk.push(TileFeature::Original(feature));
}
} else {
for s in segments {
if s.tile_x == x && s.tile_y == y {
chunk.push(TileFeature::Clipped(s));
}
}
}
} else {
let (fx, fy) =
projection::lonlat_to_tile(feature.lon, feature.lat, z).unwrap_or((0, 0));
if fx == x && fy == y {
chunk.push(TileFeature::Original(feature));
}
}
}
chunk.retain(|f| (f.timestamp() / bucket_ms) * bucket_ms == bucket_start);
if chunk.is_empty() {
return Ok(None);
}
let time_end = chunk
.iter()
.map(|f| f.end_timestamp())
.max()
.unwrap_or(bucket_start + bucket_ms);
let id = TileId::new(z, x, y, bucket_start);
match build_tile(id, &chunk, config, bucket_start as i64, time_end as i64)? {
Some(tile) => {
let feature_count = tile.feature_count();
Ok(Some((
stt_core::arrow_tile::encode_tile_with(&tile.layers, encoder)?,
feature_count,
)))
}
None => Ok(None),
}
}
pub fn encode_single_tile(
features: &[ParsedFeature],
z: u8,
x: u32,
y: u32,
t: i64,
config: &TileConfig,
encoder: &stt_core::arrow_tile::EncoderConfig,
) -> Result<Option<Vec<u8>>> {
Ok(encode_single_tile_counted(features, z, x, y, t, config, encoder)?.map(|(bytes, _)| bytes))
}
pub fn generate_tiles_streaming<W: TileWriter + Send>(
features: &[ParsedFeature],
config: &TileConfig,
writer: &mut W,
workers: usize,
) -> Result<TileStats> {
let pool = build_pool(workers)?;
let clip_config = clip_config_from(config);
let total_clipped = AtomicUsize::new(0);
let total_original = AtomicUsize::new(0);
let mut total_tiles = 0;
pool.install(|| -> Result<()> {
for zoom in config.min_zoom..=config.max_zoom {
let start = std::time::Instant::now();
let tiles = process_zoom_level(
features,
zoom,
config,
&clip_config,
&total_clipped,
&total_original,
)?;
let min_features = config.min_features_per_tile.max(1);
let mut written = 0usize;
for tile in &tiles {
if tile.feature_count() < min_features {
continue;
}
writer.write_tile(tile)?;
written += 1;
}
total_tiles += written;
tracing::info!(
"zoom {}: {} tiles written (of {} generated) in {:.1}s",
zoom,
written,
tiles.len(),
start.elapsed().as_secs_f64()
);
}
Ok(())
})?;
Ok(TileStats {
total_tiles,
clipped_segments: total_clipped.load(Ordering::Relaxed),
original_features: total_original.load(Ordering::Relaxed),
})
}
fn build_pool(workers: usize) -> Result<rayon::ThreadPool> {
let threads = workers.max(1);
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.thread_name(|i| format!("stt-build-{i}"))
.build()
.map_err(|e| anyhow::anyhow!("failed to build rayon pool: {e}"))
}
fn clip_config_from(config: &TileConfig) -> ClipConfig {
ClipConfig {
min_vertices: config.clip_min_vertices,
buffer_degrees: 0.001,
temporal_granularity_ms: if config.adaptive_target_features.is_some() {
None
} else {
Some(config.temporal_bucket_ms)
},
simplify: config.simplify,
simplify_max_zoom: config.simplify_max_zoom,
time_aware_simplify: config.time_aware_simplify,
}
}
fn feature_min_zoom(feature: &ParsedFeature, field: &Option<String>) -> Option<u8> {
feature_zoom_bound(feature, field)
}
fn feature_max_zoom(feature: &ParsedFeature, field: &Option<String>) -> Option<u8> {
feature_zoom_bound(feature, field)
}
fn feature_zoom_bound(feature: &ParsedFeature, field: &Option<String>) -> Option<u8> {
let field = field.as_deref()?;
feature
.shared_properties
.as_ref()?
.get(field)
.and_then(|v| v.as_f64())
.map(|z| z.round() as u8)
}
fn feature_out_of_band(feature: &ParsedFeature, zoom: u8, config: &TileConfig) -> bool {
if let Some(mz) = feature_min_zoom(feature, &config.min_zoom_field) {
if zoom < mz {
return true;
}
}
if let Some(mx) = feature_max_zoom(feature, &config.max_zoom_field) {
if zoom > mx {
return true;
}
}
false
}
fn process_zoom_level(
features: &[ParsedFeature],
zoom: u8,
config: &TileConfig,
clip_config: &ClipConfig,
total_clipped: &AtomicUsize,
total_original: &AtomicUsize,
) -> Result<Vec<GeneratedTile>> {
let placed: Vec<(u32, u32, TileFeature)> = features
.par_iter()
.flat_map(|feature| {
if feature_out_of_band(feature, zoom, config) {
return Vec::new();
}
let should_clip = config.clip_trajectories
&& is_clippable_trajectory(&feature.geojson, feature.end_timestamp);
if should_clip {
let segments = clip_trajectory(
&feature.geojson,
feature.shared_properties.clone(),
feature.timestamp,
feature.end_timestamp.unwrap_or(feature.timestamp),
zoom,
clip_config,
feature.vertex_timestamps.as_deref(),
feature.vertex_values.as_deref(),
feature.vertex_value_matrix.as_deref(),
);
if segments.is_empty() {
total_original.fetch_add(1, Ordering::Relaxed);
let (x, y) = projection::lonlat_to_tile(feature.lon, feature.lat, zoom)
.unwrap_or((0, 0));
vec![(x, y, TileFeature::Original(feature))]
} else {
total_clipped.fetch_add(segments.len(), Ordering::Relaxed);
segments
.into_iter()
.map(|s| (s.tile_x, s.tile_y, TileFeature::Clipped(s)))
.collect()
}
} else {
total_original.fetch_add(1, Ordering::Relaxed);
let (x, y) =
projection::lonlat_to_tile(feature.lon, feature.lat, zoom).unwrap_or((0, 0));
vec![(x, y, TileFeature::Original(feature))]
}
})
.collect();
let mut spatial: HashMap<(u32, u32), Vec<TileFeature>> = HashMap::new();
for (x, y, f) in placed {
spatial.entry((x, y)).or_default().push(f);
}
let tiles: Vec<GeneratedTile> = spatial
.into_par_iter()
.flat_map(|((x, y), feats)| {
let buckets = match config.adaptive_target_features {
Some(target) => chunk_adaptive_by_count(feats, target),
None => chunk_by_temporal_bucket(feats, config.temporal_bucket_ms),
};
let mut out = Vec::new();
for (bucket_start, chunk) in buckets {
if chunk.is_empty() {
continue;
}
let time_end = chunk
.iter()
.map(|f| f.end_timestamp())
.max()
.unwrap_or(bucket_start + config.temporal_bucket_ms);
let id = TileId::new(zoom, x, y, bucket_start);
match build_tile(id, &chunk, config, bucket_start as i64, time_end as i64) {
Ok(Some(tile)) => out.push(tile),
Ok(None) => {}
Err(e) => tracing::warn!("failed to build tile {id:?}: {e}"),
}
}
out
})
.collect();
Ok(tiles)
}
fn chunk_by_temporal_bucket(
features: Vec<TileFeature>,
bucket_ms: u64,
) -> Vec<(u64, Vec<TileFeature>)> {
let bucket_ms = bucket_ms.max(1);
let mut buckets: BTreeMap<u64, Vec<TileFeature>> = BTreeMap::new();
for f in features {
let bucket = (f.timestamp() / bucket_ms) * bucket_ms;
buckets.entry(bucket).or_default().push(f);
}
buckets.into_iter().collect()
}
fn chunk_adaptive_by_count(
mut features: Vec<TileFeature>,
target: u32,
) -> Vec<(u64, Vec<TileFeature>)> {
let target = target.max(1) as usize;
features.sort_by_key(|f| f.timestamp());
let mut out: Vec<(u64, Vec<TileFeature>)> = Vec::new();
let mut current: Vec<TileFeature> = Vec::new();
let mut current_start = 0u64;
for f in features {
if current.is_empty() {
current_start = f.timestamp();
} else if current.len() >= target
&& f.timestamp() != current.last().unwrap().timestamp()
{
out.push((current_start, std::mem::take(&mut current)));
current_start = f.timestamp();
}
current.push(f);
}
if !current.is_empty() {
out.push((current_start, current));
}
out
}
fn build_tile(
id: TileId,
features: &[TileFeature],
config: &TileConfig,
time_start: i64,
time_end: i64,
) -> Result<Option<GeneratedTile>> {
let kept_indices = config
.tile_budget
.as_ref()
.map(|budget| apply_tile_budget(budget, id, features));
let kept_features: Vec<&TileFeature> = match &kept_indices {
Some(keep) if keep.len() < features.len() => {
keep.iter().map(|&i| &features[i]).collect()
}
_ => features.iter().collect(),
};
let mut originals: Vec<&ParsedFeature> = Vec::new();
let mut segments: Vec<&ClippedSegment> = Vec::new();
for f in &kept_features {
match f {
TileFeature::Original(o) => originals.push(o),
TileFeature::Clipped(s) => segments.push(s),
}
}
let mut layers: Vec<ColumnarLayer> = Vec::new();
if !segments.is_empty() {
layers.push(build_layer_from_segments(
&segments,
&config.layer_name,
&config.columnar_options(),
)?);
}
if !originals.is_empty() {
let base = if segments.is_empty() {
config.layer_name.clone()
} else {
format!("{}_originals", config.layer_name)
};
layers.extend(build_layers_from_features_with(
&originals,
&base,
config.columnar_options(),
)?);
}
if layers.is_empty() {
return Ok(None);
}
let cover_t_min = kept_features
.iter()
.map(|f| f.timestamp() as i64)
.min()
.unwrap_or(time_start);
Ok(Some(GeneratedTile {
id,
time_start,
time_end,
cover_t_min,
layers,
}))
}
fn tile_feature_size(f: &TileFeature) -> usize {
let (verts, props) = tile_feature_signals(f);
verts * 16 + props * 16 + 32
}
fn tile_feature_signals(f: &TileFeature) -> (usize, usize) {
match f {
TileFeature::Original(o) => {
let verts = geojson_vertex_count(&o.geojson);
let props = o.shared_properties.as_ref().map(|p| p.len()).unwrap_or(0);
(verts, props)
}
TileFeature::Clipped(s) => {
let props = s.properties.as_ref().map(|p| p.len()).unwrap_or(0);
(s.coordinates.len(), props)
}
}
}
fn geojson_vertex_count(f: &geojson::Feature) -> usize {
use geojson::Value as G;
let Some(geom) = f.geometry.as_ref() else {
return 1;
};
match &geom.value {
G::Point(_) => 1,
G::MultiPoint(pts) => pts.len(),
G::LineString(c) => c.len(),
G::MultiLineString(lines) => lines.iter().map(|l| l.len()).sum(),
G::Polygon(rings) => rings.iter().map(|r| r.len()).sum(),
G::MultiPolygon(polys) => polys.iter().flatten().map(|r| r.len()).sum(),
G::GeometryCollection(_) => 1,
}
}
fn apply_tile_budget(
budget: &TileBudget,
id: TileId,
features: &[TileFeature],
) -> Vec<usize> {
let keep = budget.enforce_indexed(
features.len(),
|i| {
let (v, p) = tile_feature_signals(&features[i]);
budget.score_signals(v, p)
},
|i| tile_feature_size(&features[i]),
);
let dropped = features.len() - keep.len();
if dropped > 0 {
tracing::warn!(
"tile z{} x{} y{} t{}: dropped {} of {} features to fit budget \
(max_features={}, max_bytes={})",
id.z,
id.x,
id.y,
id.t,
dropped,
features.len(),
budget.max_feature_count,
budget.max_uncompressed_size,
);
}
keep
}
impl TileWriter for stt_core::archive::ArchiveWriter {
fn write_tile(&mut self, tile: &GeneratedTile) -> Result<()> {
let payload = encode_tile(&tile.layers)?;
self.add_tile_full(
&tile.id,
tile.time_start,
tile.time_end,
Some(tile.cover_t_min),
tile.feature_count(),
None,
&payload,
)?;
Ok(())
}
}
impl TileWriter for stt_core::PackWriter {
fn write_tile(&mut self, tile: &GeneratedTile) -> Result<()> {
let payload = encode_tile(&tile.layers)?;
self.add_tile_full(
&tile.id,
tile.time_start,
tile.time_end,
Some(tile.cover_t_min),
tile.feature_count(),
None,
&payload,
)?;
Ok(())
}
}
pub trait LodTileWriter {
fn write_lod_tile(
&mut self,
tile: &GeneratedTile,
temporal_bucket_ms: Option<u64>,
) -> Result<()>;
}
impl LodTileWriter for stt_core::archive::ArchiveWriter {
fn write_lod_tile(
&mut self,
tile: &GeneratedTile,
temporal_bucket_ms: Option<u64>,
) -> Result<()> {
let payload = encode_tile(&tile.layers)?;
self.add_tile_full(
&tile.id,
tile.time_start,
tile.time_end,
Some(tile.cover_t_min),
tile.feature_count(),
temporal_bucket_ms,
&payload,
)?;
Ok(())
}
}
impl LodTileWriter for stt_core::PackWriter {
fn write_lod_tile(
&mut self,
tile: &GeneratedTile,
temporal_bucket_ms: Option<u64>,
) -> Result<()> {
let payload = encode_tile(&tile.layers)?;
self.add_tile_full(
&tile.id,
tile.time_start,
tile.time_end,
Some(tile.cover_t_min),
tile.feature_count(),
temporal_bucket_ms,
&payload,
)?;
Ok(())
}
}
#[derive(Debug)]
enum OwnedTileFeature {
Original(ParsedFeature),
Clipped(ClippedSegment),
}
impl OwnedTileFeature {
fn start_timestamp(&self) -> u64 {
match self {
OwnedTileFeature::Original(f) => f.timestamp,
OwnedTileFeature::Clipped(s) => s.start_time,
}
}
fn end_timestamp(&self) -> u64 {
match self {
OwnedTileFeature::Original(f) => f.end_timestamp.unwrap_or(f.timestamp),
OwnedTileFeature::Clipped(s) => s.end_time,
}
}
fn estimated_size(&self) -> usize {
match self {
OwnedTileFeature::Original(f) => {
let mut s = 200usize;
if let Some(geom) = f.geojson.geometry.as_ref() {
use geojson::Value as G;
match &geom.value {
G::LineString(c) => s += c.len() * 32,
G::MultiLineString(lines) => {
s += lines.iter().map(|l| l.len() * 32).sum::<usize>()
}
G::Polygon(rings) => s += rings.iter().map(|r| r.len() * 32).sum::<usize>(),
G::MultiPolygon(polys) => {
s += polys
.iter()
.flat_map(|p| p.iter())
.map(|r| r.len() * 32)
.sum::<usize>()
}
_ => {}
}
}
if let Some(props) = &f.shared_properties {
s += props.len() * 48;
}
s
}
OwnedTileFeature::Clipped(s) => 96 + s.coordinates.len() * 32 + s.timestamps.len() * 8,
}
}
}
#[derive(Debug, Default)]
struct TileBucket {
features: Vec<OwnedTileFeature>,
bytes: usize,
}
pub fn build_streaming_from_batches<W, I>(
batches: I,
config: &TileConfig,
writer: &mut W,
_workers: usize,
spill_bytes: usize,
) -> Result<TileStats>
where
W: TileWriter,
I: IntoIterator<Item = Result<Vec<ParsedFeature>>>,
{
let clip_config = clip_config_from(config);
let total_clipped = AtomicUsize::new(0);
let total_original = AtomicUsize::new(0);
let mut total_tiles = 0usize;
type ZoomBuckets = BTreeMap<(u32, u32, u64), TileBucket>;
let mut by_zoom: Vec<ZoomBuckets> =
(0..=(config.max_zoom.saturating_sub(config.min_zoom)) as usize)
.map(|_| BTreeMap::new())
.collect();
let bucket_ms = config.temporal_bucket_ms.max(1);
let zooms: Vec<u8> = (config.min_zoom..=config.max_zoom).collect();
let spill = spill_bytes.max(1024);
let mut process = |features: Vec<ParsedFeature>,
by_zoom: &mut Vec<ZoomBuckets>|
-> Result<()> {
for (zi, &zoom) in zooms.iter().enumerate() {
for feature in &features {
if feature_out_of_band(feature, zoom, config) {
continue;
}
let should_clip = config.clip_trajectories
&& is_clippable_trajectory(&feature.geojson, feature.end_timestamp);
if should_clip {
let segments = clip_trajectory(
&feature.geojson,
feature.shared_properties.clone(),
feature.timestamp,
feature.end_timestamp.unwrap_or(feature.timestamp),
zoom,
&clip_config,
feature.vertex_timestamps.as_deref(),
feature.vertex_values.as_deref(),
feature.vertex_value_matrix.as_deref(),
);
if segments.is_empty() {
let (x, y) = projection::lonlat_to_tile(feature.lon, feature.lat, zoom)
.unwrap_or((0, 0));
let bucket = (feature.timestamp / bucket_ms) * bucket_ms;
push_feature(
&mut by_zoom[zi],
zoom,
(x, y, bucket),
OwnedTileFeature::Original(feature.clone()),
spill,
config,
writer,
&mut total_tiles,
)?;
total_original.fetch_add(1, Ordering::Relaxed);
} else {
total_clipped.fetch_add(segments.len(), Ordering::Relaxed);
for seg in segments {
let bucket = (seg.start_time / bucket_ms) * bucket_ms;
let key = (seg.tile_x, seg.tile_y, bucket);
push_feature(
&mut by_zoom[zi],
zoom,
key,
OwnedTileFeature::Clipped(seg),
spill,
config,
writer,
&mut total_tiles,
)?;
}
}
} else {
let (x, y) = projection::lonlat_to_tile(feature.lon, feature.lat, zoom)
.unwrap_or((0, 0));
let bucket = (feature.timestamp / bucket_ms) * bucket_ms;
push_feature(
&mut by_zoom[zi],
zoom,
(x, y, bucket),
OwnedTileFeature::Original(feature.clone()),
spill,
config,
writer,
&mut total_tiles,
)?;
total_original.fetch_add(1, Ordering::Relaxed);
}
}
}
Ok(())
};
for batch in batches {
let batch = batch?;
process(batch, &mut by_zoom)?;
}
for (zi, &zoom) in zooms.iter().enumerate() {
let map = std::mem::take(&mut by_zoom[zi]);
for ((x, y, bucket), bucket_data) in map {
flush_bucket(zoom, x, y, bucket, bucket_data, config, writer, &mut total_tiles)?;
}
}
Ok(TileStats {
total_tiles,
clipped_segments: total_clipped.load(Ordering::Relaxed),
original_features: total_original.load(Ordering::Relaxed),
})
}
#[allow(clippy::too_many_arguments)]
fn push_feature<W: TileWriter>(
buckets: &mut BTreeMap<(u32, u32, u64), TileBucket>,
zoom: u8,
key: (u32, u32, u64),
feat: OwnedTileFeature,
spill: usize,
config: &TileConfig,
writer: &mut W,
total_tiles: &mut usize,
) -> Result<()> {
let size = feat.estimated_size();
let bucket = buckets.entry(key).or_default();
bucket.features.push(feat);
bucket.bytes += size;
if bucket.bytes >= spill {
let drained = std::mem::take(bucket);
buckets.remove(&key);
flush_bucket(zoom, key.0, key.1, key.2, drained, config, writer, total_tiles)?;
}
Ok(())
}
fn flush_bucket<W: TileWriter>(
zoom: u8,
x: u32,
y: u32,
bucket_start: u64,
bucket: TileBucket,
config: &TileConfig,
writer: &mut W,
total_tiles: &mut usize,
) -> Result<()> {
if bucket.features.is_empty() {
return Ok(());
}
let time_end = bucket
.features
.iter()
.map(|f| f.end_timestamp())
.max()
.unwrap_or(bucket_start + config.temporal_bucket_ms);
let cover_t_min = bucket
.features
.iter()
.map(|f| f.start_timestamp() as i64)
.min()
.unwrap_or(bucket_start as i64);
let id = TileId::new(zoom, x, y, bucket_start);
let mut originals: Vec<&ParsedFeature> = Vec::new();
let mut segments: Vec<&ClippedSegment> = Vec::new();
for f in &bucket.features {
match f {
OwnedTileFeature::Original(o) => originals.push(o),
OwnedTileFeature::Clipped(s) => segments.push(s),
}
}
let mut layers: Vec<ColumnarLayer> = Vec::new();
if !segments.is_empty() {
layers.push(crate::columnar::build_layer_from_segments(
&segments,
&config.layer_name,
&config.columnar_options(),
)?);
}
if !originals.is_empty() {
let base = if segments.is_empty() {
config.layer_name.clone()
} else {
format!("{}_originals", config.layer_name)
};
layers.extend(crate::columnar::build_layers_from_features_with(
&originals,
&base,
config.columnar_options(),
)?);
}
if layers.is_empty() {
return Ok(());
}
let tile = GeneratedTile {
id,
time_start: bucket_start as i64,
time_end: time_end as i64,
cover_t_min,
layers,
};
if tile.feature_count() < config.min_features_per_tile.max(1) {
return Ok(());
}
writer.write_tile(&tile)?;
*total_tiles += 1;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use geojson::{Feature, Geometry, Value as GeomValue};
use stt_core::archive::{Archive, ArchiveReader};
use stt_core::metadata::Metadata;
use stt_core::types::Compression;
fn point(lon: f64, lat: f64, ts: u64) -> ParsedFeature {
let props = serde_json::json!({ "v": ts as f64 })
.as_object()
.cloned()
.map(std::sync::Arc::new);
ParsedFeature {
geojson: Feature {
bbox: None,
geometry: Some(Geometry::new(GeomValue::Point(vec![lon, lat]))),
id: None,
properties: None,
foreign_members: None,
},
shared_properties: props,
timestamp: ts,
end_timestamp: None,
vertex_timestamps: None,
vertex_values: None,
vertex_value_matrix: None,
lon,
lat,
}
}
#[test]
fn encode_single_tile_selects_tile_and_bucket() {
use stt_core::arrow_tile::decode_tile;
let z = 12u8;
let bucket_ms = 3_600_000u64; let lon = -122.42;
let lat = 37.77;
let (x, y) = projection::lonlat_to_tile(lon, lat, z).unwrap();
let base = 1_700_000_000_000u64;
let bucket_start = (base / bucket_ms) * bucket_ms;
let feats = vec![
point(lon, lat, bucket_start + 10),
point(lon + 0.0003, lat + 0.0003, bucket_start + 20),
point(lon, lat, bucket_start + bucket_ms + 5),
];
let config = TileConfig {
min_zoom: z,
max_zoom: z,
layer_name: "obs".to_string(),
temporal_bucket_ms: bucket_ms,
clip_trajectories: false,
..TileConfig::default()
};
let enc = stt_core::arrow_tile::EncoderConfig::default();
let bytes = encode_single_tile(&feats, z, x, y, bucket_start as i64, &config, &enc)
.unwrap()
.expect("tile should be non-empty");
let rows: usize = decode_tile(&bytes)
.unwrap()
.iter()
.map(|l| l.batch.num_rows())
.sum();
assert_eq!(rows, 2, "only the two points in this (tile, bucket)");
assert!(encode_single_tile(&feats, z, x + 9, y, bucket_start as i64, &config, &enc)
.unwrap()
.is_none());
let next =
encode_single_tile(&feats, z, x, y, (bucket_start + bucket_ms) as i64, &config, &enc)
.unwrap()
.expect("next bucket tile");
let n: usize = decode_tile(&next)
.unwrap()
.iter()
.map(|l| l.batch.num_rows())
.sum();
assert_eq!(n, 1);
}
fn trajectory(start: u64, end: u64) -> ParsedFeature {
let coords: Vec<Vec<f64>> = (0..20)
.map(|i| vec![-122.5 + i as f64 * 0.02, 37.7 + i as f64 * 0.01])
.collect();
let first = coords[0].clone();
ParsedFeature {
geojson: Feature {
bbox: None,
geometry: Some(Geometry::new(GeomValue::LineString(coords))),
id: None,
properties: None,
foreign_members: None,
},
shared_properties: None,
timestamp: start,
end_timestamp: Some(end),
vertex_timestamps: None,
vertex_values: None,
vertex_value_matrix: None,
lon: first[0],
lat: first[1],
}
}
#[test]
fn matrix_corridor_builds_one_tile_spanning_range() {
let num_buckets = 4usize;
let bucket_ms = 900_000u64; let start = 1_420_070_400_000u64;
let end = start + num_buckets as u64 * bucket_ms;
let coords: Vec<Vec<f64>> = vec![
vec![-73.980, 40.750],
vec![-73.979, 40.751],
vec![-73.978, 40.752],
];
let nverts = coords.len();
let first = coords[0].clone();
let matrix: Vec<f32> = (0..nverts * num_buckets).map(|i| i as f32).collect();
let feature = ParsedFeature {
geojson: Feature {
bbox: None,
geometry: Some(Geometry::new(GeomValue::LineString(coords))),
id: None,
properties: None,
foreign_members: None,
},
shared_properties: None,
timestamp: start,
end_timestamp: Some(end),
vertex_timestamps: None,
vertex_values: None,
vertex_value_matrix: Some(matrix),
lon: first[0],
lat: first[1],
};
let config = TileConfig {
min_zoom: 10,
max_zoom: 10,
layer_name: "flows".to_string(),
temporal_bucket_ms: bucket_ms,
clip_trajectories: true,
clip_min_vertices: 2,
..TileConfig::default()
};
let tiles = generate_tiles(&[feature], &config, 1).unwrap();
assert_eq!(
tiles.len(),
1,
"matrix corridor must build exactly one tile, got {}",
tiles.len()
);
let tile = &tiles[0];
assert_eq!(tile.time_start, start as i64);
assert_eq!(tile.time_end, end as i64);
let layer = &tile.layers[0];
let vm = layer
.vertex_value_matrix
.as_ref()
.expect("tile layer must carry the per-vertex value matrix");
assert_eq!(vm.len(), 1);
assert_eq!(vm[0].len(), nverts * num_buckets);
}
#[test]
fn max_zoom_field_confines_feature_to_band() {
let mut p = point(-73.98, 40.75, 1_600_000_000_000);
{
let props = std::sync::Arc::make_mut(p.shared_properties.as_mut().unwrap());
props.insert("min_zoom".to_string(), serde_json::json!(11));
props.insert("max_zoom".to_string(), serde_json::json!(11));
}
let config = TileConfig {
min_zoom: 10,
max_zoom: 12,
layer_name: "flows".to_string(),
min_zoom_field: Some("min_zoom".to_string()),
max_zoom_field: Some("max_zoom".to_string()),
..TileConfig::default()
};
let tiles = generate_tiles(&[p], &config, 1).unwrap();
let zooms: Vec<u8> = tiles.iter().map(|t| t.id.z).collect();
assert_eq!(
zooms,
vec![11],
"feature must appear only at its single-zoom band, got {zooms:?}"
);
}
#[test]
fn cover_t_min_tracks_earliest_feature_through_build_and_read() {
let hour = 3_600_000u64;
let base = 1_600_000_000_000u64;
let mut features = Vec::new();
for i in 0..12u64 {
let lon = -122.45 + i as f64 * 0.02; let ts = base + hour / 2 + i * 1000;
features.push(point(lon, 37.75, ts));
}
let config = TileConfig {
min_zoom: 8,
max_zoom: 11,
layer_name: "default".to_string(),
temporal_bucket_ms: hour,
clip_trajectories: false,
..TileConfig::default()
};
let tiles = generate_tiles(&features, &config, 2).unwrap();
assert!(!tiles.is_empty());
assert!(tiles.iter().all(|t| t.cover_t_min >= t.time_start));
assert!(
tiles.iter().any(|t| t.cover_t_min > t.time_start),
"expected a tile whose earliest feature is after the bucket edge"
);
let path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
let mut writer = Archive::create(&path, Compression::Zstd).unwrap();
for tile in &tiles {
writer.write_tile(tile).unwrap();
}
writer.finalize(&Metadata::new("cover")).unwrap();
let reader = ArchiveReader::open(&path).unwrap();
assert!(reader.entries().iter().all(|e| e.cover_t_min.is_some()));
assert!(reader
.entries()
.iter()
.any(|e| e.cover_t_min.unwrap() > e.time_start));
}
#[test]
fn end_to_end_points_archive_roundtrip() {
let hour = 3_600_000u64;
let mut features = Vec::new();
for i in 0..40u64 {
let lon = -122.45 + (i % 8) as f64 * 0.01;
let lat = 37.75 + (i / 8) as f64 * 0.01;
let ts = 1_600_000_000_000 + (i % 2) * hour + i * 1000;
features.push(point(lon, lat, ts));
}
let config = TileConfig {
min_zoom: 8,
max_zoom: 11,
layer_name: "default".to_string(),
temporal_bucket_ms: hour,
clip_trajectories: false,
..TileConfig::default()
};
let tiles = generate_tiles(&features, &config, 2).unwrap();
assert!(!tiles.is_empty(), "expected tiles to be generated");
let path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
let mut writer = Archive::create(&path, Compression::Zstd).unwrap();
for tile in &tiles {
writer.write_tile(tile).unwrap();
}
let total_features: usize =
tiles.iter().map(|t| t.feature_count() as usize).sum();
writer.finalize(&Metadata::new("e2e-points")).unwrap();
let mut reader = ArchiveReader::open(&path).unwrap();
assert_eq!(reader.entries().len(), tiles.len());
let archived: usize =
reader.entries().iter().map(|e| e.feature_count as usize).sum();
assert_eq!(archived, total_features);
let entry = reader.entries()[0].clone();
let layers = reader.read_layers(&entry).unwrap();
assert!(!layers.is_empty());
assert!(layers[0].batch.num_rows() > 0);
assert!(layers[0].batch.column_by_name("geometry").is_some());
assert!(layers[0].batch.column_by_name("v").is_some());
}
#[test]
fn end_to_end_trajectory_clipping() {
let features = vec![trajectory(1_000_000, 1_000_000 + 3_600_000)];
let config = TileConfig {
min_zoom: 9,
max_zoom: 10,
layer_name: "tracks".to_string(),
temporal_bucket_ms: 3_600_000,
clip_trajectories: true,
clip_min_vertices: 2,
..TileConfig::default()
};
let tiles = generate_tiles(&features, &config, 2).unwrap();
assert!(
tiles.len() > 1,
"a multi-tile trajectory should clip into several tiles, got {}",
tiles.len()
);
let path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
let mut writer = Archive::create(&path, Compression::Zstd).unwrap();
for tile in &tiles {
writer.write_tile(tile).unwrap();
}
writer.finalize(&Metadata::new("e2e-tracks")).unwrap();
let mut reader = ArchiveReader::open(&path).unwrap();
let entry = reader.entries()[0].clone();
let layers = reader.read_layers(&entry).unwrap();
assert!(layers[0].batch.column_by_name("vertex_time").is_some());
assert!(layers[0].batch.column_by_name("geometry").is_some());
}
#[test]
fn streaming_matches_in_memory_for_points() {
let hour = 3_600_000u64;
let mut features = Vec::new();
for i in 0..200u64 {
let lon = -122.45 + (i % 16) as f64 * 0.01;
let lat = 37.75 + (i / 16) as f64 * 0.005;
let ts = 1_600_000_000_000 + (i % 3) * hour + i * 1000;
features.push(point(lon, lat, ts));
}
let config = TileConfig {
min_zoom: 8,
max_zoom: 11,
layer_name: "default".to_string(),
temporal_bucket_ms: hour,
clip_trajectories: false,
..TileConfig::default()
};
let in_mem_tiles = generate_tiles(&features, &config, 2).unwrap();
let in_mem_total: usize = in_mem_tiles.iter().map(|t| t.feature_count() as usize).sum();
let mut batches: Vec<Result<Vec<ParsedFeature>>> = Vec::new();
for chunk in features.chunks(50) {
batches.push(Ok(chunk.to_vec()));
}
let path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
let mut writer = Archive::create(&path, Compression::Zstd).unwrap();
let stats =
build_streaming_from_batches(batches.into_iter(), &config, &mut writer, 2, 1024 * 1024)
.unwrap();
writer.finalize(&Metadata::new("streaming")).unwrap();
let reader = ArchiveReader::open(&path).unwrap();
let archived: usize = reader.entries().iter().map(|e| e.feature_count as usize).sum();
assert_eq!(archived, in_mem_total, "feature count mismatch");
assert!(
stats.total_tiles >= in_mem_tiles.len()
&& stats.total_tiles <= in_mem_tiles.len() + 4,
"tile count diverged: stream={} in_mem={}",
stats.total_tiles,
in_mem_tiles.len()
);
}
#[test]
fn streaming_handles_trajectory_clipping() {
let features = vec![trajectory(1_000_000, 1_000_000 + 3_600_000)];
let config = TileConfig {
min_zoom: 9,
max_zoom: 10,
layer_name: "tracks".to_string(),
temporal_bucket_ms: 3_600_000,
clip_trajectories: true,
clip_min_vertices: 2,
..TileConfig::default()
};
let path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
let mut writer = Archive::create(&path, Compression::Zstd).unwrap();
let stats = build_streaming_from_batches(
std::iter::once(Ok(features)),
&config,
&mut writer,
2,
16 * 1024,
)
.unwrap();
writer.finalize(&Metadata::new("stream-tracks")).unwrap();
assert!(stats.total_tiles > 1, "trajectory should cover multiple tiles");
let reader = ArchiveReader::open(&path).unwrap();
let entry = reader.entries()[0].clone();
let layers = reader.read_layers(&entry).unwrap();
assert!(layers[0].batch.column_by_name("vertex_time").is_some());
}
use stt_core::metadata::TemporalLodLevel;
#[test]
fn lod_aggregator_emits_base_plus_per_level_tiles() {
let hour = 3_600_000u64;
let day = 24 * hour;
let day_aligned = 1_700_006_400_000u64;
assert_eq!(day_aligned % day, 0);
let mut features = Vec::new();
for hour_idx in 0..72u64 {
features.push(point(-122.45, 37.75, day_aligned + hour_idx * hour));
}
let config = TileConfig {
min_zoom: 8,
max_zoom: 9,
layer_name: "default".to_string(),
temporal_bucket_ms: hour,
clip_trajectories: false,
temporal_lod: vec![TemporalLodLevel {
bucket_ms: day,
max_zoom_level: 9,
}],
..TileConfig::default()
};
let tagged = generate_tiles_with_lod(&features, &config, 1).unwrap();
let base: Vec<&LodTaggedTile> = tagged
.iter()
.filter(|t| t.temporal_bucket_ms == Some(hour))
.collect();
let lod: Vec<&LodTaggedTile> = tagged
.iter()
.filter(|t| t.temporal_bucket_ms == Some(day))
.collect();
assert_eq!(base.len(), 72 * 2);
assert_eq!(lod.len(), 3 * 2);
assert!(tagged.iter().all(|t| t.temporal_bucket_ms.is_some()));
}
#[test]
fn lod_aggregator_skips_zooms_above_max_zoom_level() {
let hour = 3_600_000u64;
let day = 24 * hour;
let features = vec![point(0.0, 0.0, 1_000_000_000)];
let config = TileConfig {
min_zoom: 0,
max_zoom: 10,
layer_name: "default".to_string(),
temporal_bucket_ms: hour,
clip_trajectories: false,
temporal_lod: vec![TemporalLodLevel {
bucket_ms: day,
max_zoom_level: 4,
}],
..TileConfig::default()
};
let tagged = generate_tiles_with_lod(&features, &config, 1).unwrap();
let lod: Vec<u8> = tagged
.iter()
.filter(|t| t.temporal_bucket_ms == Some(day))
.map(|t| t.tile.id.z)
.collect();
assert!(!lod.is_empty());
assert!(lod.iter().all(|&z| z <= 4));
let base_zooms: std::collections::BTreeSet<u8> = tagged
.iter()
.filter(|t| t.temporal_bucket_ms == Some(hour))
.map(|t| t.tile.id.z)
.collect();
assert_eq!(base_zooms, (0..=10).collect());
}
#[test]
fn lod_aggregator_rejects_non_multiple_bucket() {
let config = TileConfig {
temporal_bucket_ms: 3_600_000,
temporal_lod: vec![TemporalLodLevel {
bucket_ms: 3_600_000 + 1, max_zoom_level: 6,
}],
..TileConfig::default()
};
let err = generate_tiles_with_lod(&[], &config, 1).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("multiple"), "got: {msg}");
}
#[test]
fn lod_aggregator_rejects_unsorted_levels() {
let config = TileConfig {
temporal_bucket_ms: 3_600_000,
temporal_lod: vec![
TemporalLodLevel { bucket_ms: 24 * 3_600_000, max_zoom_level: 6 },
TemporalLodLevel { bucket_ms: 2 * 3_600_000, max_zoom_level: 6 },
],
..TileConfig::default()
};
assert!(generate_tiles_with_lod(&[], &config, 1).is_err());
}
#[test]
fn lod_tiles_carry_bucket_size_through_archive_round_trip() {
let hour = 3_600_000u64;
let day = 24 * hour;
let features: Vec<ParsedFeature> = (0..48u64)
.map(|h| point(-122.45, 37.75, 1_700_000_000_000 + h * hour))
.collect();
let config = TileConfig {
min_zoom: 8,
max_zoom: 9,
layer_name: "default".to_string(),
temporal_bucket_ms: hour,
clip_trajectories: false,
temporal_lod: vec![TemporalLodLevel { bucket_ms: day, max_zoom_level: 9 }],
..TileConfig::default()
};
let tagged = generate_tiles_with_lod(&features, &config, 1).unwrap();
let path = tempfile::NamedTempFile::new().unwrap().into_temp_path();
let mut writer = Archive::create(&path, Compression::Zstd).unwrap();
for t in &tagged {
writer.write_lod_tile(&t.tile, t.temporal_bucket_ms).unwrap();
}
let metadata = stt_core::metadata::Metadata::new("lod")
.with_temporal_bucket_ms(hour)
.with_temporal_lod(vec![TemporalLodLevel { bucket_ms: day, max_zoom_level: 9 }])
.unwrap();
writer.finalize(&metadata).unwrap();
let reader = ArchiveReader::open(&path).unwrap();
let buckets: std::collections::BTreeSet<Option<u64>> =
reader.entries().iter().map(|e| e.temporal_bucket_ms).collect();
assert!(buckets.contains(&Some(hour)));
assert!(buckets.contains(&Some(day)));
assert!(!buckets.contains(&None));
}
#[test]
fn base_path_temporally_clips_trajectory_into_buckets() {
let hour = 3_600_000u64;
let coords: Vec<Vec<f64>> = (0..=20)
.map(|i| vec![-122.45 + i as f64 * 0.001, 37.75 + i as f64 * 0.0005])
.collect();
let first = coords[0].clone();
let feat = ParsedFeature {
geojson: Feature {
bbox: None,
geometry: Some(Geometry::new(GeomValue::LineString(coords))),
id: None,
properties: None,
foreign_members: None,
},
shared_properties: None,
timestamp: 0,
end_timestamp: Some(2 * hour),
vertex_timestamps: None,
vertex_values: None,
vertex_value_matrix: None,
lon: first[0],
lat: first[1],
};
let config = TileConfig {
min_zoom: 12,
max_zoom: 12,
layer_name: "tracks".to_string(),
temporal_bucket_ms: hour,
clip_trajectories: true,
..TileConfig::default()
};
let tiles = generate_tiles(&[feat], &config, 1).unwrap();
let h = hour as i64;
let buckets: std::collections::BTreeSet<i64> = tiles
.iter()
.map(|t| t.time_start - t.time_start.rem_euclid(h))
.collect();
assert!(
buckets.len() >= 2,
"trajectory should temporally clip into >=2 buckets, got {buckets:?}"
);
}
#[test]
fn adaptive_temporal_chunking_sizes_windows_by_count() {
let features: Vec<ParsedFeature> = (0..100u64)
.map(|i| point(-122.45, 37.75, 1_700_000_000_000 + i * 60_000))
.collect();
let config = TileConfig {
min_zoom: 8,
max_zoom: 8,
layer_name: "default".to_string(),
temporal_bucket_ms: 3_600_000,
clip_trajectories: false,
adaptive_target_features: Some(10),
..TileConfig::default()
};
let tiles = generate_tiles(&features, &config, 1).unwrap();
let total: usize = tiles.iter().map(|t| t.feature_count() as usize).sum();
assert_eq!(total, 100, "every feature must appear exactly once");
assert_eq!(tiles.len(), 10, "expected 10 windows of 10, got {}", tiles.len());
let keys: std::collections::BTreeSet<(u8, u32, u32, i64)> = tiles
.iter()
.map(|t| (t.id.z, t.id.x, t.id.y, t.time_start))
.collect();
assert_eq!(keys.len(), tiles.len(), "window keys must be distinct");
for t in &tiles {
assert!(t.feature_count() <= 11, "window over budget: {}", t.feature_count());
}
}
#[test]
fn adaptive_chunking_keeps_identical_timestamps_together() {
let features: Vec<ParsedFeature> = (0..50)
.map(|_| point(-122.45, 37.75, 1_700_000_000_000))
.collect();
let config = TileConfig {
min_zoom: 8,
max_zoom: 8,
layer_name: "default".to_string(),
temporal_bucket_ms: 3_600_000,
clip_trajectories: false,
adaptive_target_features: Some(10),
..TileConfig::default()
};
let tiles = generate_tiles(&features, &config, 1).unwrap();
assert_eq!(tiles.len(), 1, "identical-timestamp features can't be split into tiles");
assert_eq!(tiles[0].feature_count(), 50);
}
#[test]
fn time_aware_simplify_builds_tiles() {
let feat = trajectory(0, 3_600_000);
let config = TileConfig {
min_zoom: 5,
max_zoom: 5,
layer_name: "tracks".to_string(),
temporal_bucket_ms: 3_600_000,
clip_trajectories: true,
simplify: true,
time_aware_simplify: true,
simplify_max_zoom: 14,
..TileConfig::default()
};
let tiles = generate_tiles(&[feat], &config, 1).unwrap();
assert!(!tiles.is_empty(), "time-aware simplify should still produce tiles");
for t in &tiles {
for l in &t.layers {
assert!(l.vertex_times.is_some(), "trajectory layer should carry vertex_times");
}
}
}
fn dense_single_tile_features(n: u64) -> (Vec<ParsedFeature>, TileConfig) {
let base = 1_700_000_000_000u64;
let features: Vec<ParsedFeature> = (0..n)
.map(|i| point(-122.40 + i as f64 * 1e-6, 37.75, base))
.collect();
let config = TileConfig {
min_zoom: 6,
max_zoom: 6,
layer_name: "default".to_string(),
temporal_bucket_ms: 3_600_000,
clip_trajectories: false,
..TileConfig::default()
};
(features, config)
}
#[test]
fn budget_off_by_default_keeps_all_features() {
let (features, config) = dense_single_tile_features(30);
assert!(config.tile_budget.is_none());
let tiles = generate_tiles(&features, &config, 1).unwrap();
let total: u32 = tiles.iter().map(|t| t.feature_count()).sum();
assert_eq!(total, 30, "no budget => no features dropped");
}
#[test]
fn maximum_tile_features_caps_feature_count() {
let (features, mut config) = dense_single_tile_features(30);
config.tile_budget = Some(
TileBudget::new(usize::MAX, usize::MAX, 10)
.with_scorer(stt_core::budget::ImportanceScorer::Combined),
);
let tiles = generate_tiles(&features, &config, 1).unwrap();
assert_eq!(tiles.len(), 1, "all points share one tile");
assert_eq!(
tiles[0].feature_count(),
10,
"feature cap of 10 must be enforced"
);
}
#[test]
fn budget_under_cap_is_noop() {
let (features, mut config) = dense_single_tile_features(5);
config.tile_budget = Some(TileBudget::new(usize::MAX, usize::MAX, 10));
let tiles = generate_tiles(&features, &config, 1).unwrap();
let total: u32 = tiles.iter().map(|t| t.feature_count()).sum();
assert_eq!(total, 5, "under-cap tile keeps every feature");
}
#[test]
fn maximum_tile_bytes_drops_to_fit() {
let (features, mut config) = dense_single_tile_features(30);
config.tile_budget = Some(TileBudget::new(200, 200, usize::MAX));
let tiles = generate_tiles(&features, &config, 1).unwrap();
let total: u32 = tiles.iter().map(|t| t.feature_count()).sum();
assert!(total < 30, "byte cap must drop some features, kept {total}");
assert!(total >= 1, "byte cap should still keep at least one feature");
}
}