Skip to main content

stt_optimize/analysis/
density.rs

1//! Tile density analysis
2//!
3//! Buckets features into (zoom, x, y, time-bucket) tiles — the same cut a
4//! real `stt-build` run makes with `--temporal-bucket` — to predict tile
5//! counts, per-tile feature loads, and archive size, and to flag issues.
6
7use crate::analysis::spatial::SpatialAnalysis;
8use crate::analysis::temporal::TemporalAnalysis;
9use crate::loader::LoadedData;
10use crate::measure::MeasuredEncoding;
11use anyhow::Result;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use stt_core::projection;
15
16/// Density analysis results
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DensityAnalysis {
19    /// Per-zoom tile statistics across the recommended zoom range
20    pub per_zoom: Vec<ZoomDensity>,
21    /// Estimated total tile count at recommended settings (summed over zooms)
22    pub estimated_tile_count: usize,
23    /// Estimated archive size in bytes (compressed)
24    pub estimated_archive_size: usize,
25    /// Potential issues identified
26    pub issues: Vec<DensityIssue>,
27}
28
29/// Tile statistics for one zoom level, with features split into
30/// (x, y, time-bucket) tiles by the recommended temporal bucket.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ZoomDensity {
33    /// Zoom level
34    pub zoom: u8,
35    /// Number of (x, y, time-bucket) tiles at this zoom
36    pub tile_count: usize,
37    /// Average features per tile
38    pub avg_features_per_tile: f64,
39    /// Median features per tile
40    pub median_features_per_tile: usize,
41    /// Maximum features in any tile
42    pub max_features_per_tile: usize,
43    /// Number of oversized tiles (> 10,000 features)
44    pub oversized_tiles: usize,
45    /// Number of undersized tiles (< 10 features)
46    pub undersized_tiles: usize,
47    /// Estimated total size at this zoom, uncompressed (measured-sample
48    /// calibrated when a measurement is available, else summed per-feature
49    /// formula estimates)
50    pub estimated_size_uncompressed: usize,
51    /// Estimated total size at this zoom, compressed (measured bytes/feature
52    /// and zstd ratio when available, else an assumed 3x ratio)
53    pub estimated_size_compressed: usize,
54}
55
56/// A potential density issue
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct DensityIssue {
59    /// Issue severity
60    pub severity: IssueSeverity,
61    /// Issue description
62    pub description: String,
63    /// Suggested fix
64    pub suggestion: String,
65}
66
67/// Issue severity level
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub enum IssueSeverity {
70    Info,
71    Warning,
72    Error,
73}
74
75impl std::fmt::Display for IssueSeverity {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            IssueSeverity::Info => write!(f, "INFO"),
79            IssueSeverity::Warning => write!(f, "WARNING"),
80            IssueSeverity::Error => write!(f, "ERROR"),
81        }
82    }
83}
84
85/// Analyze tile density across the recommended zoom range.
86///
87/// Models the real build: at every zoom in the recommended
88/// `[min_zoom, max_zoom]` range each feature lands in its containing (x, y)
89/// tile, split by fixed `--temporal-bucket`-sized time buckets (the
90/// recommended bucket from the temporal analysis). This is the cut `stt-build`
91/// actually makes, so predicted tile counts and sizes track a real build.
92///
93/// When a `measured` sample encoding is present, size estimates use its real
94/// encoder bytes/feature and zstd ratio; otherwise the per-feature formula
95/// estimate with an assumed 3x compression ratio is the fallback.
96pub fn analyze(
97    data: &LoadedData,
98    spatial: &SpatialAnalysis,
99    temporal: &TemporalAnalysis,
100    measured: Option<&MeasuredEncoding>,
101) -> Result<DensityAnalysis> {
102    let bucket_ms = temporal.recommended_bucket_ms;
103    let zooms: Vec<u8> = (spatial.recommended_min_zoom..=spatial.recommended_max_zoom).collect();
104    tracing::debug!(
105        "density: bucketing {} features into (x, y, t/{}ms) tiles at zooms {:?}",
106        data.features.len(),
107        bucket_ms,
108        zooms
109    );
110
111    let mut per_zoom = Vec::with_capacity(zooms.len());
112    for &zoom in &zooms {
113        per_zoom.push(bucket_zoom(data, zoom, bucket_ms, measured));
114    }
115
116    let estimated_tile_count = per_zoom.iter().map(|z| z.tile_count).sum();
117    let estimated_archive_size = per_zoom.iter().map(|z| z.estimated_size_compressed).sum();
118
119    let issues = identify_issues(
120        data,
121        spatial,
122        &per_zoom,
123        estimated_tile_count,
124        estimated_archive_size,
125    );
126
127    Ok(DensityAnalysis {
128        per_zoom,
129        estimated_tile_count,
130        estimated_archive_size,
131        issues,
132    })
133}
134
135/// Bucket every feature into its (x, y, time-bucket) tile at one zoom and
136/// compute per-tile statistics. `bucket_ms == 0` (no temporal bucketing, e.g.
137/// an instantaneous dataset) collapses to a single time bucket per tile.
138fn bucket_zoom(
139    data: &LoadedData,
140    zoom: u8,
141    bucket_ms: u64,
142    measured: Option<&MeasuredEncoding>,
143) -> ZoomDensity {
144    // (feature count, estimated bytes) per (x, y, t_bucket) tile.
145    let mut tiles: HashMap<(u32, u32, u64), (usize, usize)> = HashMap::new();
146
147    for feature in &data.features {
148        if let Ok((x, y)) = projection::lonlat_to_tile(feature.lon, feature.lat, zoom) {
149            let t_bucket = if bucket_ms > 0 {
150                feature.timestamp / bucket_ms
151            } else {
152                0
153            };
154            let entry = tiles.entry((x, y, t_bucket)).or_insert((0, 0));
155            entry.0 += 1;
156            entry.1 += feature.estimated_size;
157        }
158    }
159
160    let mut feature_counts: Vec<usize> = tiles.values().map(|&(count, _)| count).collect();
161    feature_counts.sort_unstable();
162    let total_uncompressed: usize = tiles.values().map(|&(_, bytes)| bytes).sum();
163
164    let tile_count = feature_counts.len();
165    let avg_features_per_tile = if tile_count > 0 {
166        feature_counts.iter().sum::<usize>() as f64 / tile_count as f64
167    } else {
168        0.0
169    };
170    let median_features_per_tile = feature_counts.get(tile_count / 2).copied().unwrap_or(0);
171    let max_features_per_tile = feature_counts.last().copied().unwrap_or(0);
172    // 10,000-feature "oversized" threshold is a rough rule of thumb for a tile
173    // that will be slow to decode/render; it is not a hard format limit.
174    let oversized_tiles = feature_counts.iter().filter(|&&c| c > 10_000).count();
175    let undersized_tiles = feature_counts.iter().filter(|&&c| c < 10).count();
176
177    // Size estimates: calibrated by the measured sample encoding when present
178    // (real encoder + zstd bytes/feature). Without a measurement, fall back to
179    // the summed per-feature formula estimates with an assumed 3x zstd ratio
180    // (a rough guess; real ratios vary by dataset).
181    let (estimated_size_uncompressed, estimated_size_compressed) = match measured {
182        Some(m) => {
183            let bucketed_features: usize = feature_counts.iter().sum();
184            let compressed = (bucketed_features as f64 * m.bytes_per_feature).round() as usize;
185            let uncompressed = (compressed as f64 * m.zstd_ratio).round() as usize;
186            (uncompressed, compressed)
187        }
188        None => (total_uncompressed, total_uncompressed / 3),
189    };
190
191    ZoomDensity {
192        zoom,
193        tile_count,
194        avg_features_per_tile,
195        median_features_per_tile,
196        max_features_per_tile,
197        oversized_tiles,
198        undersized_tiles,
199        estimated_size_uncompressed,
200        estimated_size_compressed,
201    }
202}
203
204/// Identify potential issues from the per-zoom breakdown. Every suggestion
205/// names real `stt-build` flags; per-tile budgets are always described as
206/// opt-in with a data-loss tradeoff.
207fn identify_issues(
208    data: &LoadedData,
209    spatial: &SpatialAnalysis,
210    per_zoom: &[ZoomDensity],
211    total_tile_count: usize,
212    estimated_archive_size: usize,
213) -> Vec<DensityIssue> {
214    let mut issues = Vec::new();
215
216    let oversized: usize = per_zoom.iter().map(|z| z.oversized_tiles).sum();
217    let undersized: usize = per_zoom.iter().map(|z| z.undersized_tiles).sum();
218    let max_features = per_zoom
219        .iter()
220        .map(|z| z.max_features_per_tile)
221        .max()
222        .unwrap_or(0);
223
224    // Check for oversized tiles
225    if oversized > 0 {
226        issues.push(DensityIssue {
227            severity: IssueSeverity::Warning,
228            description: format!(
229                "{} tiles exceed 10,000 features (max: {})",
230                oversized, max_features
231            ),
232            suggestion: "Use a finer --temporal-bucket to spread features over more time \
233                         buckets, or opt into per-tile budgets (--maximum-tile-bytes / \
234                         --maximum-tile-features, optionally --drop-densest-as-needed) — \
235                         budgets drop features to fit, trading data loss for tile size. For \
236                         very dense point sets, --summary-tier bakes aggregate overview tiles"
237                .to_string(),
238        });
239    }
240
241    // Check for many undersized tiles
242    let undersized_pct = if total_tile_count > 0 {
243        undersized as f64 / total_tile_count as f64 * 100.0
244    } else {
245        0.0
246    };
247    if undersized_pct > 20.0 {
248        issues.push(DensityIssue {
249            severity: IssueSeverity::Info,
250            description: format!(
251                "{:.1}% of tiles have fewer than 10 features",
252                undersized_pct
253            ),
254            suggestion: "Lower --max-zoom or use a coarser --temporal-bucket so tiles \
255                         aggregate more features"
256                .to_string(),
257        });
258    }
259
260    // Check for very high tile count
261    if total_tile_count > 50_000 {
262        issues.push(DensityIssue {
263            severity: IssueSeverity::Warning,
264            description: format!(
265                "High tile count ({}) may impact loading performance",
266                total_tile_count
267            ),
268            suggestion: "Narrow the zoom range (--min-zoom / --max-zoom) or use a coarser \
269                         --temporal-bucket"
270                .to_string(),
271        });
272    }
273
274    // Check for sparse data at high zooms
275    if let Some(z_max) = spatial
276        .zoom_coverage
277        .iter()
278        .find(|z| z.zoom == spatial.recommended_max_zoom)
279    {
280        if z_max.coverage_percent < 0.1 {
281            issues.push(DensityIssue {
282                severity: IssueSeverity::Info,
283                description: format!(
284                    "Only {:.2}% coverage at zoom {}",
285                    z_max.coverage_percent, spatial.recommended_max_zoom
286                ),
287                suggestion: "Data is sparse at this zoom level; lower --max-zoom".to_string(),
288            });
289        }
290    }
291
292    // Check estimated archive size
293    let size_mb = estimated_archive_size as f64 / 1_048_576.0;
294    if size_mb > 500.0 {
295        issues.push(DensityIssue {
296            severity: IssueSeverity::Warning,
297            description: format!("Large estimated archive size ({:.1} MB)", size_mb),
298            suggestion: "Lower --max-zoom, or opt into per-tile budgets \
299                         (--maximum-tile-bytes / --maximum-tile-features, optionally \
300                         --drop-densest-as-needed) which drop features to fit (data loss). \
301                         For very dense point sets, --summary-tier bakes aggregate overview \
302                         tiles instead of full-resolution features"
303                .to_string(),
304        });
305    }
306
307    // Check for hotspot concentration
308    if !spatial.hotspots.is_empty() {
309        let top_hotspot = &spatial.hotspots[0];
310        let hotspot_pct = top_hotspot.feature_count as f64 / data.features.len() as f64 * 100.0;
311        if hotspot_pct > 50.0 {
312            issues.push(DensityIssue {
313                severity: IssueSeverity::Info,
314                description: format!(
315                    "{:.1}% of features concentrated in {}",
316                    hotspot_pct,
317                    top_hotspot.name.as_deref().unwrap_or("one region")
318                ),
319                suggestion: "Hotspot tiles will be large; opt-in per-tile budgets \
320                             (--maximum-tile-bytes / --maximum-tile-features, which drop \
321                             features to fit — data loss) cap them, or a per-feature \
322                             --min-zoom-field keeps coarse zooms light by holding minor \
323                             features back to deeper zooms"
324                    .to_string(),
325            });
326        }
327    }
328
329    issues
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::loader::{AnalyzableFeature, GeometryType};
336    use stt_core::types::{BoundingBox, TimeRange};
337
338    fn feature(lon: f64, lat: f64, timestamp: u64) -> AnalyzableFeature {
339        AnalyzableFeature {
340            lon,
341            lat,
342            timestamp,
343            geometry_type: GeometryType::Point,
344            vertex_count: 1,
345            estimated_size: 150,
346            property_count: 2,
347        }
348    }
349
350    /// Build synthetic data spread over a small region with timestamps spread
351    /// over ~n² seconds.
352    fn make_grid_data(n_side: usize) -> LoadedData {
353        let mut features = Vec::new();
354        let mut min_lon = f64::MAX;
355        let mut max_lon = f64::MIN;
356        let mut min_lat = f64::MAX;
357        let mut max_lat = f64::MIN;
358        for i in 0..n_side {
359            for j in 0..n_side {
360                let lon = -100.0 + (i as f64) * 0.05;
361                let lat = 40.0 + (j as f64) * 0.05;
362                min_lon = min_lon.min(lon);
363                max_lon = max_lon.max(lon);
364                min_lat = min_lat.min(lat);
365                max_lat = max_lat.max(lat);
366                features.push(feature(lon, lat, (i * n_side + j) as u64 * 1000));
367            }
368        }
369        LoadedData {
370            features,
371            bounds: BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
372            time_range: TimeRange::new(0, 1_000_000),
373            sample: Vec::new(),
374        }
375    }
376
377    #[test]
378    fn test_bucket_zoom_splits_by_temporal_bucket() {
379        // 100 features at ONE location spread over 100s: with a 10s bucket the
380        // single spatial tile must split into 10 (x, y, t) tiles; with no
381        // bucketing (bucket_ms = 0) it stays a single tile.
382        let features: Vec<_> = (0..100u64).map(|i| feature(-100.0, 40.0, i * 1_000)).collect();
383        let data = LoadedData {
384            features,
385            bounds: BoundingBox::new(-100.0, 40.0, -100.0, 40.0),
386            time_range: TimeRange::new(0, 100_000),
387            sample: Vec::new(),
388        };
389
390        let bucketed = bucket_zoom(&data, 10, 10_000, None);
391        assert_eq!(bucketed.tile_count, 10);
392        assert_eq!(bucketed.max_features_per_tile, 10);
393        assert_eq!(bucketed.estimated_size_uncompressed, 100 * 150);
394
395        let unbucketed = bucket_zoom(&data, 10, 0, None);
396        assert_eq!(unbucketed.tile_count, 1);
397        assert_eq!(unbucketed.max_features_per_tile, 100);
398    }
399
400    #[test]
401    fn test_measured_calibration_replaces_formula() {
402        // A measured sample encoding must drive both size estimates (real
403        // bytes/feature and zstd ratio), replacing the formula + /3 fallback.
404        let features: Vec<_> = (0..100u64).map(|i| feature(-100.0, 40.0, i * 1_000)).collect();
405        let data = LoadedData {
406            features,
407            bounds: BoundingBox::new(-100.0, 40.0, -100.0, 40.0),
408            time_range: TimeRange::new(0, 100_000),
409            sample: Vec::new(),
410        };
411        let measured = MeasuredEncoding {
412            features: 100,
413            geometry_kind: "point".to_string(),
414            bytes_total: 4_200,
415            bytes_per_feature: 42.0,
416            zstd_ratio: 2.0,
417            per_column: Vec::new(),
418        };
419
420        let calibrated = bucket_zoom(&data, 10, 0, Some(&measured));
421        assert_eq!(calibrated.estimated_size_compressed, 100 * 42);
422        assert_eq!(calibrated.estimated_size_uncompressed, 100 * 42 * 2);
423
424        // The no-measurement fallback keeps the formula estimates.
425        let fallback = bucket_zoom(&data, 10, 0, None);
426        assert_eq!(fallback.estimated_size_uncompressed, 100 * 150);
427        assert_eq!(fallback.estimated_size_compressed, 100 * 150 / 3);
428    }
429
430    #[test]
431    fn test_analyze_aggregates_across_zoom_range() {
432        // End-to-end: analyze() must produce one ZoomDensity per zoom in the
433        // recommended range, and the aggregates must sum the per-zoom stats.
434        let data = make_grid_data(20); // 400 points
435        let spatial = crate::analysis::spatial::analyze(&data).unwrap();
436        let temporal = crate::analysis::temporal::analyze(&data).unwrap();
437        let density = analyze(&data, &spatial, &temporal, None).unwrap();
438
439        let expected_zooms =
440            (spatial.recommended_min_zoom..=spatial.recommended_max_zoom).count();
441        assert_eq!(density.per_zoom.len(), expected_zooms);
442        assert_eq!(
443            density.estimated_tile_count,
444            density.per_zoom.iter().map(|z| z.tile_count).sum::<usize>()
445        );
446        assert!(density.estimated_archive_size > 0);
447        assert!(density.per_zoom.iter().all(|z| z.tile_count > 0));
448        // Point data: a deeper zoom can only split (x, y, t) tiles, never merge
449        // them, so tile counts are non-decreasing across the range.
450        for pair in density.per_zoom.windows(2) {
451            assert!(
452                pair[1].tile_count >= pair[0].tile_count,
453                "z{} tile_count {} < z{} tile_count {}",
454                pair[1].zoom,
455                pair[1].tile_count,
456                pair[0].zoom,
457                pair[0].tile_count
458            );
459        }
460    }
461
462    #[test]
463    fn test_oversized_issue_names_real_build_flags() {
464        // >10k features in one (x, y, t) tile must yield an oversized warning
465        // whose suggestion names real stt-build flags.
466        let features: Vec<_> = (0..10_001).map(|_| feature(-100.0, 40.0, 0)).collect();
467        let data = LoadedData {
468            features,
469            bounds: BoundingBox::new(-100.0, 40.0, -100.0, 40.0),
470            time_range: TimeRange::new(0, 0),
471            sample: Vec::new(),
472        };
473        let spatial = crate::analysis::spatial::analyze(&data).unwrap();
474        let temporal = crate::analysis::temporal::analyze(&data).unwrap();
475        let density = analyze(&data, &spatial, &temporal, None).unwrap();
476
477        let oversized: usize = density.per_zoom.iter().map(|z| z.oversized_tiles).sum();
478        assert!(oversized > 0, "expected oversized tiles");
479        let issue = density
480            .issues
481            .iter()
482            .find(|i| i.description.contains("10,000"))
483            .expect("oversized issue present");
484        assert!(issue.suggestion.contains("--maximum-tile-bytes"));
485        assert!(issue.suggestion.contains("--maximum-tile-features"));
486        assert!(issue.suggestion.contains("--temporal-bucket"));
487    }
488}