Skip to main content

stt_optimize/analysis/
spatial.rs

1//! Spatial density and coverage analysis
2//!
3//! Analyzes the spatial distribution of features to recommend zoom levels
4//! and identify hotspots.
5
6use crate::loader::LoadedData;
7use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use stt_core::projection;
11
12/// Spatial analysis results
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SpatialAnalysis {
15    /// Coverage statistics per zoom level
16    pub zoom_coverage: Vec<ZoomCoverage>,
17    /// Identified hotspots (regions with high density)
18    pub hotspots: Vec<Hotspot>,
19    /// Recommended minimum zoom level
20    pub recommended_min_zoom: u8,
21    /// Recommended maximum zoom level
22    pub recommended_max_zoom: u8,
23    /// Spatial distribution classification
24    pub distribution: SpatialDistribution,
25}
26
27/// Coverage statistics for a zoom level
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ZoomCoverage {
30    /// Zoom level
31    pub zoom: u8,
32    /// Total possible tiles at this zoom
33    pub total_tiles: u64,
34    /// Tiles containing at least one feature
35    pub occupied_tiles: u64,
36    /// Coverage percentage (0-100)
37    pub coverage_percent: f64,
38    /// Average features per occupied tile
39    pub avg_features_per_tile: f64,
40    /// Maximum features in any single tile
41    pub max_features_in_tile: usize,
42    /// Median features per tile
43    pub median_features_per_tile: usize,
44}
45
46/// A spatial hotspot (region with high feature density)
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Hotspot {
49    /// Center longitude
50    pub lon: f64,
51    /// Center latitude
52    pub lat: f64,
53    /// Approximate radius in degrees
54    pub radius: f64,
55    /// Feature count in this hotspot
56    pub feature_count: usize,
57    /// Descriptive name (if determinable)
58    pub name: Option<String>,
59}
60
61/// Classification of spatial distribution
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub enum SpatialDistribution {
64    /// Features are spread evenly across the globe
65    Global,
66    /// Features clustered in specific regions
67    Regional,
68    /// Features concentrated in one or few small areas
69    Localized,
70    /// Very sparse data
71    Sparse,
72}
73
74impl std::fmt::Display for SpatialDistribution {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            SpatialDistribution::Global => write!(f, "Global (spread worldwide)"),
78            SpatialDistribution::Regional => write!(f, "Regional (clustered in regions)"),
79            SpatialDistribution::Localized => write!(f, "Localized (concentrated areas)"),
80            SpatialDistribution::Sparse => write!(f, "Sparse (very low density)"),
81        }
82    }
83}
84
85/// Analyze spatial characteristics of the dataset
86pub fn analyze(data: &LoadedData) -> Result<SpatialAnalysis> {
87    use indicatif::{ProgressBar, ProgressStyle};
88
89    let pb = ProgressBar::new(MAX_SUPPORTED_ZOOM as u64 + 1); // We analyze z0-z18
90    pb.set_style(
91        ProgressStyle::default_bar()
92            .template("{msg} [{bar:30.cyan/blue}] {pos}/{len}")
93            .unwrap()
94            .progress_chars("##-"),
95    );
96    pb.set_message("Analyzing spatial coverage");
97
98    let mut zoom_coverage = Vec::new();
99
100    // Analyze each zoom level
101    for zoom in 0..=MAX_SUPPORTED_ZOOM {
102        let coverage = analyze_zoom_level(data, zoom);
103        zoom_coverage.push(coverage);
104        pb.inc(1);
105    }
106
107    pb.finish_with_message("Spatial analysis complete");
108
109    // Determine recommended zoom levels. The max-zoom is derived from the
110    // typical inter-feature spacing (Tippecanoe `-zg` style), then clamped by
111    // the coarse occupancy heuristic as a sanity guard.
112    let (min_zoom, max_zoom) = recommend_zoom_levels(&zoom_coverage, data, data.features.len());
113
114    // Detect hotspots using a grid-based approach
115    let hotspots = detect_hotspots(data);
116
117    // Classify spatial distribution
118    let distribution = classify_distribution(&zoom_coverage, &hotspots, &data.bounds);
119
120    Ok(SpatialAnalysis {
121        zoom_coverage,
122        hotspots,
123        recommended_min_zoom: min_zoom,
124        recommended_max_zoom: max_zoom,
125        distribution,
126    })
127}
128
129/// Analyze coverage at a specific zoom level
130fn analyze_zoom_level(data: &LoadedData, zoom: u8) -> ZoomCoverage {
131    let mut tile_counts: HashMap<(u32, u32), usize> = HashMap::new();
132
133    for feature in &data.features {
134        if let Ok((x, y)) = projection::lonlat_to_tile(feature.lon, feature.lat, zoom) {
135            *tile_counts.entry((x, y)).or_insert(0) += 1;
136        }
137    }
138
139    let total_tiles = 1u64 << (2 * zoom as u64);
140    let occupied_tiles = tile_counts.len() as u64;
141    let coverage_percent = if total_tiles > 0 {
142        (occupied_tiles as f64 / total_tiles as f64) * 100.0
143    } else {
144        0.0
145    };
146
147    let counts: Vec<usize> = tile_counts.values().copied().collect();
148    let avg_features_per_tile = if !counts.is_empty() {
149        counts.iter().sum::<usize>() as f64 / counts.len() as f64
150    } else {
151        0.0
152    };
153
154    let max_features_in_tile = counts.iter().copied().max().unwrap_or(0);
155
156    let median_features_per_tile = if !counts.is_empty() {
157        let mut sorted = counts.clone();
158        sorted.sort();
159        sorted[sorted.len() / 2]
160    } else {
161        0
162    };
163
164    ZoomCoverage {
165        zoom,
166        total_tiles,
167        occupied_tiles,
168        coverage_percent,
169        avg_features_per_tile,
170        max_features_in_tile,
171        median_features_per_tile,
172    }
173}
174
175/// Hard cap on the recommended max zoom. The shipped fleet builds tiles up to
176/// z18 (e.g. AV LiDAR), so the analysis scans and the recommendation is
177/// bounded by the same z0-z18 window.
178const MAX_SUPPORTED_ZOOM: u8 = 18;
179
180/// Web Mercator world circumference in meters at the equator (EPSG:3857 extent
181/// width = 2·π·a where a = 6_378_137 m). One full map at zoom 0 spans this many
182/// meters across, so the ground size of a single tile at zoom `z` is
183/// `WORLD_CIRCUMFERENCE_M / 2^z`.
184const WORLD_CIRCUMFERENCE_M: f64 = 40_075_016.686;
185
186/// Recommend min and max zoom levels based on coverage and feature density.
187fn recommend_zoom_levels(
188    coverage: &[ZoomCoverage],
189    data: &LoadedData,
190    total_features: usize,
191) -> (u8, u8) {
192    // ---- Min zoom -------------------------------------------------------
193    // Find min zoom: the coarsest level that still aggregates meaningfully.
194    // Start from zoom 0 and go up until average features per tile drops too low.
195    let mut min_zoom = 0u8;
196    for cov in coverage.iter() {
197        // If average features per tile drops below 2, we are too zoomed in to
198        // be a useful *minimum* (overview) level; back off one.
199        if cov.avg_features_per_tile < 2.0 && cov.zoom > 0 {
200            min_zoom = cov.zoom.saturating_sub(1);
201            break;
202        }
203        min_zoom = cov.zoom;
204    }
205
206    // ---- Max zoom (density-derived, Tippecanoe `-zg` style) -------------
207    // Pick the zoom at which features sit roughly one tile apart, so detail is
208    // preserved without generating wastefully deep, near-empty tiles.
209    let density_max_zoom = density_based_max_zoom(data, total_features);
210
211    // ---- Occupancy sanity clamp ----------------------------------------
212    // The density estimate is a global average; guard it with the empirical
213    // per-zoom occupancy so we never recommend a zoom where the data has
214    // already become uselessly sparse. We walk down from the density estimate
215    // to the first zoom that still has occupied tiles averaging >= 1 feature.
216    let mut max_zoom = density_max_zoom;
217    for cov in coverage.iter().rev() {
218        if cov.zoom <= density_max_zoom
219            && cov.occupied_tiles > 0
220            && cov.avg_features_per_tile >= 1.0
221        {
222            max_zoom = cov.zoom;
223            break;
224        }
225    }
226
227    // Ensure min <= max
228    if min_zoom > max_zoom {
229        min_zoom = 0;
230    }
231
232    // Cap at the analyzed window.
233    max_zoom = max_zoom.min(MAX_SUPPORTED_ZOOM);
234
235    (min_zoom, max_zoom)
236}
237
238/// Estimate the max zoom from the typical inter-feature spacing.
239///
240/// # Derivation (Tippecanoe `-zg` analogue)
241///
242/// We want the deepest zoom `z` at which neighbouring features are about one
243/// tile apart. Two quantities drive this:
244///
245/// * **Typical inter-feature spacing** `s` (meters). As a cheap, allocation-free
246///   proxy for the median nearest-neighbour distance we use
247///   `s = sqrt(area / n)`, the side length of the square each feature would
248///   occupy if `n` features were spread uniformly over the dataset's projected
249///   area `area`. (Uniform-packing spacing; for clustered data this slightly
250///   under-estimates local spacing, which the occupancy clamp then corrects.)
251///
252/// * **Tile ground size at zoom `z`** `t(z) = WORLD_CIRCUMFERENCE_M / 2^z`
253///   meters (Web Mercator, measured at the data's mean latitude so the
254///   longitudinal shrink `cos(lat)` is accounted for).
255///
256/// Setting `t(z) == s` and solving for `z`:
257///
258/// ```text
259///   WORLD_CIRCUMFERENCE_M * cos(lat) / 2^z  ==  s
260///   2^z  ==  WORLD_CIRCUMFERENCE_M * cos(lat) / s
261///   z    ==  log2( WORLD_CIRCUMFERENCE_M * cos(lat) / s )
262/// ```
263///
264/// We round to the nearest integer zoom and clamp to `[0, MAX_SUPPORTED_ZOOM]`.
265fn density_based_max_zoom(data: &LoadedData, total_features: usize) -> u8 {
266    // Degenerate inputs: nothing to derive from.
267    if total_features < 2 {
268        return 0;
269    }
270
271    let b = &data.bounds;
272    let lon_extent = (b.max_lon - b.min_lon).abs();
273    let lat_extent = (b.max_lat - b.min_lat).abs();
274
275    // Mean latitude drives the east-west meter-per-degree shrink.
276    let mean_lat = ((b.min_lat + b.max_lat) / 2.0).clamp(-85.0511, 85.0511);
277    let cos_lat = mean_lat.to_radians().cos().max(1e-6);
278
279    // Meters per degree of latitude (constant) and longitude (shrinks with lat).
280    let m_per_deg_lat = WORLD_CIRCUMFERENCE_M / 360.0;
281    let m_per_deg_lon = m_per_deg_lat * cos_lat;
282
283    // Projected area covered by the data, in square meters. A zero-extent axis
284    // (all features share a lon or lat) would zero the area, so floor each axis
285    // at a single-tile-at-MAX_SUPPORTED_ZOOM span to keep the estimate finite
286    // and push such degenerate-but-real datasets to the deepest zoom.
287    let min_span_m = WORLD_CIRCUMFERENCE_M / (1u64 << MAX_SUPPORTED_ZOOM) as f64;
288    let width_m = (lon_extent * m_per_deg_lon).max(min_span_m);
289    let height_m = (lat_extent * m_per_deg_lat).max(min_span_m);
290    let area_m2 = width_m * height_m;
291
292    // Uniform-packing inter-feature spacing.
293    let spacing_m = (area_m2 / total_features as f64).sqrt();
294    if spacing_m <= 0.0 {
295        return MAX_SUPPORTED_ZOOM;
296    }
297
298    // z = log2( tile-meters-at-z0 / spacing ), using the latitude-adjusted
299    // world width so the tile size matches the spacing's projection.
300    let world_width_m = WORLD_CIRCUMFERENCE_M * cos_lat;
301    let z = (world_width_m / spacing_m).log2();
302
303    // Round to nearest integer zoom, clamp to the supported window.
304    let z = z.round();
305    if z.is_nan() || z < 0.0 {
306        0
307    } else if z > MAX_SUPPORTED_ZOOM as f64 {
308        MAX_SUPPORTED_ZOOM
309    } else {
310        z as u8
311    }
312}
313
314/// Detect hotspots using a grid-based density approach
315fn detect_hotspots(data: &LoadedData) -> Vec<Hotspot> {
316    // Use a coarse grid (about 10 degrees cells)
317    let grid_size = 10.0;
318    let mut grid_counts: HashMap<(i32, i32), (f64, f64, usize)> = HashMap::new();
319
320    for feature in &data.features {
321        let grid_x = (feature.lon / grid_size).floor() as i32;
322        let grid_y = (feature.lat / grid_size).floor() as i32;
323
324        let entry = grid_counts.entry((grid_x, grid_y)).or_insert((0.0, 0.0, 0));
325        entry.0 += feature.lon;
326        entry.1 += feature.lat;
327        entry.2 += 1;
328    }
329
330    // Find cells with above-average density
331    let total_features = data.features.len();
332    let avg_per_cell = if !grid_counts.is_empty() {
333        total_features as f64 / grid_counts.len() as f64
334    } else {
335        0.0
336    };
337
338    let threshold = avg_per_cell * 2.0; // Hotspot if 2x average
339
340    let mut hotspots: Vec<Hotspot> = grid_counts
341        .iter()
342        .filter(|(_, (_, _, count))| *count as f64 > threshold && *count > 100)
343        .map(|((_gx, _gy), (sum_lon, sum_lat, count))| {
344            let center_lon = sum_lon / *count as f64;
345            let center_lat = sum_lat / *count as f64;
346            Hotspot {
347                lon: center_lon,
348                lat: center_lat,
349                radius: grid_size / 2.0,
350                feature_count: *count,
351                name: get_region_name(center_lon, center_lat),
352            }
353        })
354        .collect();
355
356    // Sort by feature count (descending)
357    hotspots.sort_by(|a, b| b.feature_count.cmp(&a.feature_count));
358
359    // Keep top 10 hotspots
360    hotspots.truncate(10);
361
362    hotspots
363}
364
365/// Get a rough region name based on coordinates
366fn get_region_name(lon: f64, lat: f64) -> Option<String> {
367    // Simple heuristic based on major regions
368    let name = if lon >= -180.0 && lon <= -100.0 {
369        if lat >= 25.0 && lat <= 50.0 {
370            Some("Western North America")
371        } else if lat >= -60.0 && lat <= 15.0 {
372            Some("South America (West)")
373        } else {
374            None
375        }
376    } else if lon > -100.0 && lon <= -30.0 {
377        if lat >= 25.0 && lat <= 50.0 {
378            Some("Eastern North America")
379        } else if lat >= -60.0 && lat <= 15.0 {
380            Some("South America (East)")
381        } else {
382            None
383        }
384    } else if lon > -30.0 && lon <= 60.0 {
385        if lat >= 35.0 && lat <= 70.0 {
386            Some("Europe")
387        } else if lat >= -35.0 && lat <= 35.0 {
388            Some("Africa")
389        } else {
390            None
391        }
392    } else if lon > 60.0 && lon <= 150.0 {
393        if lat >= 20.0 && lat <= 55.0 {
394            Some("Asia (Central/East)")
395        } else if lat >= -10.0 && lat <= 30.0 {
396            Some("South/Southeast Asia")
397        } else {
398            None
399        }
400    } else if lon > 100.0 || lon <= -150.0 {
401        if lat >= -50.0 && lat <= 0.0 {
402            Some("Oceania/Australia")
403        } else if lat >= 30.0 && lat <= 45.0 {
404            Some("Pacific Ring (Japan/Korea)")
405        } else {
406            None
407        }
408    } else {
409        None
410    };
411
412    name.map(|s| s.to_string())
413}
414
415/// Classify the overall spatial distribution
416fn classify_distribution(
417    coverage: &[ZoomCoverage],
418    hotspots: &[Hotspot],
419    bounds: &stt_core::types::BoundingBox,
420) -> SpatialDistribution {
421    // Check bounds extent
422    let lon_extent = bounds.max_lon - bounds.min_lon;
423    let lat_extent = bounds.max_lat - bounds.min_lat;
424
425    // Very localized if bounds are small
426    if lon_extent < 10.0 && lat_extent < 10.0 {
427        return SpatialDistribution::Localized;
428    }
429
430    // Check coverage at mid-zoom (z6)
431    let z6_coverage = coverage.iter().find(|c| c.zoom == 6);
432
433    if let Some(cov) = z6_coverage {
434        if cov.coverage_percent < 0.5 {
435            return SpatialDistribution::Sparse;
436        }
437
438        // If many hotspots with high concentration
439        if hotspots.len() >= 3 {
440            let hotspot_features: usize = hotspots.iter().take(5).map(|h| h.feature_count).sum();
441            let z6_features: usize = coverage
442                .iter()
443                .find(|c| c.zoom == 6)
444                .map(|c| (c.avg_features_per_tile * c.occupied_tiles as f64) as usize)
445                .unwrap_or(0);
446
447            if hotspot_features > z6_features / 2 {
448                return SpatialDistribution::Regional;
449            }
450        }
451
452        // Wide coverage
453        if cov.coverage_percent > 5.0 && lon_extent > 100.0 {
454            return SpatialDistribution::Global;
455        }
456    }
457
458    SpatialDistribution::Regional
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::loader::{AnalyzableFeature, GeometryType, LoadedData};
465    use stt_core::types::{BoundingBox, TimeRange};
466
467    /// Build a synthetic `LoadedData` from (lon, lat) points. Bounds are derived
468    /// from the points so the density estimate sees a realistic extent.
469    fn make_data(points: &[(f64, f64)]) -> LoadedData {
470        let features: Vec<AnalyzableFeature> = points
471            .iter()
472            .map(|&(lon, lat)| AnalyzableFeature {
473                lon,
474                lat,
475                timestamp: 0,
476                geometry_type: GeometryType::Point,
477                vertex_count: 1,
478                estimated_size: 120,
479                property_count: 1,
480            })
481            .collect();
482
483        let mut min_lon = f64::MAX;
484        let mut max_lon = f64::MIN;
485        let mut min_lat = f64::MAX;
486        let mut max_lat = f64::MIN;
487        for &(lon, lat) in points {
488            min_lon = min_lon.min(lon);
489            max_lon = max_lon.max(lon);
490            min_lat = min_lat.min(lat);
491            max_lat = max_lat.max(lat);
492        }
493
494        LoadedData {
495            features,
496            bounds: BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
497            time_range: TimeRange::new(0, 0),
498            sample: Vec::new(),
499        }
500    }
501
502    #[test]
503    fn test_region_name() {
504        assert_eq!(get_region_name(-122.0, 37.0), Some("Western North America".to_string()));
505        assert_eq!(get_region_name(2.0, 48.0), Some("Europe".to_string()));
506        // 139.0 lon, 35.0 lat is in Asia (Central/East) range
507        assert_eq!(get_region_name(139.0, 35.0), Some("Asia (Central/East)".to_string()));
508    }
509
510    #[test]
511    fn test_density_max_zoom_dense_cluster_is_high() {
512        // 2500 points packed into a ~0.01° x 0.01° box near San Francisco
513        // (~1.1 km across). Inter-feature spacing is a few tens of meters, so the
514        // formula should land near the deepest supported zoom — past the old z14
515        // cap now that the window extends to z18.
516        let mut pts = Vec::new();
517        let n = 50;
518        for i in 0..n {
519            for j in 0..n {
520                let lon = -122.4 + (i as f64) * 0.0002;
521                let lat = 37.77 + (j as f64) * 0.0002;
522                pts.push((lon, lat));
523            }
524        }
525        let data = make_data(&pts);
526        let z = density_based_max_zoom(&data, data.features.len());
527        assert!(z >= 15, "dense cluster should yield a high zoom, got {}", z);
528        assert!(z <= MAX_SUPPORTED_ZOOM, "zoom must be clamped, got {}", z);
529    }
530
531    #[test]
532    fn test_density_max_zoom_sparse_global_is_low() {
533        // 200 points scattered across the whole globe. Spacing is hundreds of km,
534        // so the formula should pick a low (overview) zoom.
535        let mut pts = Vec::new();
536        let n = 200;
537        for i in 0..n {
538            // Spread roughly uniformly over lon/lat.
539            let lon = -180.0 + (i as f64) * (360.0 / n as f64);
540            let lat = -80.0 + ((i * 7) % 160) as f64; // pseudo-spread in lat
541            pts.push((lon, lat));
542        }
543        let data = make_data(&pts);
544        let z = density_based_max_zoom(&data, data.features.len());
545        assert!(z <= 6, "sparse global scatter should yield a low zoom, got {}", z);
546    }
547
548    #[test]
549    fn test_density_max_zoom_degenerate_inputs() {
550        // Fewer than 2 features cannot define spacing -> zoom 0.
551        let data = make_data(&[(0.0, 0.0)]);
552        assert_eq!(density_based_max_zoom(&data, 1), 0);
553        let empty = make_data(&[]);
554        assert_eq!(density_based_max_zoom(&empty, 0), 0);
555    }
556
557    #[test]
558    fn test_recommend_zoom_dense_cluster_high_max() {
559        // End-to-end through analyze(): a dense cluster should recommend a high
560        // max zoom that survives the occupancy clamp.
561        let mut pts = Vec::new();
562        for i in 0..40 {
563            for j in 0..40 {
564                pts.push((-122.4 + (i as f64) * 0.0003, 37.77 + (j as f64) * 0.0003));
565            }
566        }
567        let data = make_data(&pts);
568        let analysis = analyze(&data).unwrap();
569        assert!(
570            analysis.recommended_max_zoom >= 11,
571            "dense cluster recommended_max_zoom too low: {}",
572            analysis.recommended_max_zoom
573        );
574        assert!(analysis.recommended_min_zoom <= analysis.recommended_max_zoom);
575    }
576}
577