Skip to main content

stt_optimize/analysis/
density.rs

1//! Tile density simulation and chunk size analysis
2//!
3//! Simulates tile generation at various chunk sizes to predict
4//! optimal parameters and identify potential issues.
5
6use crate::analysis::spatial::SpatialAnalysis;
7use crate::loader::LoadedData;
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use stt_core::projection;
12
13/// Density analysis results
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct DensityAnalysis {
16    /// Simulated results at different chunk sizes
17    pub chunk_simulations: Vec<ChunkSimulation>,
18    /// Recommended chunk size in bytes
19    pub recommended_chunk_size: usize,
20    /// Estimated total tile count at recommended settings
21    pub estimated_tile_count: usize,
22    /// Estimated archive size in bytes (compressed)
23    pub estimated_archive_size: usize,
24    /// Potential issues identified
25    pub issues: Vec<DensityIssue>,
26}
27
28/// Simulation results for a specific chunk size
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ChunkSimulation {
31    /// Target chunk size in bytes
32    pub chunk_size: usize,
33    /// Estimated number of tiles
34    pub tile_count: usize,
35    /// Average features per tile
36    pub avg_features_per_tile: f64,
37    /// Median features per tile
38    pub median_features_per_tile: usize,
39    /// Maximum features in any tile
40    pub max_features_per_tile: usize,
41    /// Number of oversized tiles (> max recommended features)
42    pub oversized_tiles: usize,
43    /// Number of undersized tiles (< 10 features)
44    pub undersized_tiles: usize,
45    /// Estimated total size (uncompressed)
46    pub estimated_size_uncompressed: usize,
47    /// Estimated total size (compressed, assuming 3x ratio)
48    pub estimated_size_compressed: usize,
49}
50
51/// A potential density issue
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DensityIssue {
54    /// Issue severity
55    pub severity: IssueSeverity,
56    /// Issue description
57    pub description: String,
58    /// Suggested fix
59    pub suggestion: String,
60}
61
62/// Issue severity level
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub enum IssueSeverity {
65    Info,
66    Warning,
67    Error,
68}
69
70impl std::fmt::Display for IssueSeverity {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            IssueSeverity::Info => write!(f, "INFO"),
74            IssueSeverity::Warning => write!(f, "WARNING"),
75            IssueSeverity::Error => write!(f, "ERROR"),
76        }
77    }
78}
79
80/// Analyze density characteristics and simulate chunk sizing
81pub fn analyze(data: &LoadedData, spatial: &SpatialAnalysis) -> Result<DensityAnalysis> {
82    // Chunk sizes to simulate (in bytes)
83    let chunk_sizes = [
84        64_000,   // 64 KB
85        128_000,  // 128 KB
86        256_000,  // 256 KB
87        500_000,  // 500 KB (default)
88        1_000_000, // 1 MB
89        2_000_000, // 2 MB
90    ];
91
92    // A real build emits tiles across the whole recommended [min_zoom, max_zoom]
93    // range, not just at max_zoom. Simulate every zoom in that range and
94    // aggregate, so predicted tile counts / archive size reflect a multi-zoom
95    // build rather than a single deepest level.
96    let zooms: Vec<u8> = (spatial.recommended_min_zoom..=spatial.recommended_max_zoom).collect();
97    tracing::debug!(
98        "density sim: simulating zooms {:?} over {} features (no per-zoom sampling cap)",
99        zooms,
100        data.features.len()
101    );
102
103    let mut simulations = Vec::new();
104    for &chunk_size in &chunk_sizes {
105        let sim = simulate_chunk_size_multizoom(data, chunk_size, &zooms);
106        simulations.push(sim);
107    }
108
109    // Find optimal chunk size (minimize oversized tiles while keeping tile count reasonable)
110    let (recommended_chunk_size, best_sim_idx) = find_optimal_chunk_size(&simulations);
111
112    let estimated_tile_count = simulations[best_sim_idx].tile_count;
113    let estimated_archive_size = simulations[best_sim_idx].estimated_size_compressed;
114
115    // Identify issues
116    let issues = identify_issues(data, spatial, &simulations[best_sim_idx]);
117
118    Ok(DensityAnalysis {
119        chunk_simulations: simulations,
120        recommended_chunk_size,
121        estimated_tile_count,
122        estimated_archive_size,
123        issues,
124    })
125}
126
127/// Simulate a build across multiple zoom levels for a fixed chunk size, then
128/// aggregate into one [`ChunkSimulation`]. This mirrors a real multi-zoom build:
129/// the same features are re-tiled at each zoom and the resulting tile/chunk
130/// counts and byte totals are summed across all zooms. The per-tile feature
131/// distribution (avg/median/max, oversized/undersized counts) pools every
132/// chunk from every zoom.
133fn simulate_chunk_size_multizoom(
134    data: &LoadedData,
135    chunk_size: usize,
136    zooms: &[u8],
137) -> ChunkSimulation {
138    let mut tile_count = 0usize;
139    let mut feature_counts: Vec<usize> = Vec::new();
140    let mut total_uncompressed = 0usize;
141
142    for &zoom in zooms {
143        let per_zoom = simulate_zoom_chunks(data, chunk_size, zoom);
144        tile_count += per_zoom.tile_count;
145        total_uncompressed += per_zoom.total_uncompressed;
146        feature_counts.extend(per_zoom.feature_counts);
147    }
148
149    finalize_simulation(chunk_size, tile_count, feature_counts, total_uncompressed)
150}
151
152/// Raw per-zoom chunking result, before stats aggregation.
153struct ZoomChunkResult {
154    tile_count: usize,
155    feature_counts: Vec<usize>,
156    total_uncompressed: usize,
157}
158
159/// Chunk the dataset at a single zoom into size-bounded chunks. Returns the raw
160/// counts so callers can aggregate across zooms before computing statistics.
161fn simulate_zoom_chunks(data: &LoadedData, chunk_size: usize, zoom: u8) -> ZoomChunkResult {
162    // Group features by spatial tile at the target zoom
163    let mut tile_features: HashMap<(u32, u32), Vec<usize>> = HashMap::new();
164
165    for (idx, feature) in data.features.iter().enumerate() {
166        if let Ok((x, y)) = projection::lonlat_to_tile(feature.lon, feature.lat, zoom) {
167            tile_features.entry((x, y)).or_insert_with(Vec::new).push(idx);
168        }
169    }
170
171    // Simulate chunking based on size
172    let mut tile_count = 0;
173    let mut feature_counts = Vec::new();
174    let mut total_uncompressed = 0;
175
176    for (_, feature_indices) in &tile_features {
177        // Sort by timestamp
178        let mut indices = feature_indices.clone();
179        indices.sort_by_key(|&i| data.features[i].timestamp);
180
181        // Chunk by size
182        let mut current_chunk_size = 0;
183        let mut current_chunk_features = 0;
184
185        for &idx in &indices {
186            let feature_size = data.features[idx].estimated_size;
187
188            if current_chunk_size > 0 && current_chunk_size + feature_size > chunk_size {
189                // Start new chunk
190                tile_count += 1;
191                feature_counts.push(current_chunk_features);
192                total_uncompressed += current_chunk_size;
193                current_chunk_size = 0;
194                current_chunk_features = 0;
195            }
196
197            current_chunk_size += feature_size;
198            current_chunk_features += 1;
199        }
200
201        // Last chunk
202        if current_chunk_features > 0 {
203            tile_count += 1;
204            feature_counts.push(current_chunk_features);
205            total_uncompressed += current_chunk_size;
206        }
207    }
208
209    ZoomChunkResult {
210        tile_count,
211        feature_counts,
212        total_uncompressed,
213    }
214}
215
216/// Compute the aggregate statistics for a (possibly multi-zoom) chunk run.
217fn finalize_simulation(
218    chunk_size: usize,
219    tile_count: usize,
220    mut feature_counts: Vec<usize>,
221    total_uncompressed: usize,
222) -> ChunkSimulation {
223    // Calculate statistics
224    feature_counts.sort();
225    let avg_features = if tile_count > 0 {
226        feature_counts.iter().sum::<usize>() as f64 / tile_count as f64
227    } else {
228        0.0
229    };
230
231    let median_features = if !feature_counts.is_empty() {
232        feature_counts[feature_counts.len() / 2]
233    } else {
234        0
235    };
236
237    let max_features = feature_counts.iter().copied().max().unwrap_or(0);
238    // 10,000-feature "oversized" threshold is a rough rule of thumb for a tile
239    // that will be slow to decode/render; it is not a hard format limit.
240    let oversized = feature_counts.iter().filter(|&&c| c > 10_000).count();
241    let undersized = feature_counts.iter().filter(|&&c| c < 10).count();
242
243    // Estimate compressed size. The 3x ratio is a rough estimate (zstd on
244    // mixed coordinate/property payloads); real ratios vary by dataset.
245    let estimated_compressed = total_uncompressed / 3;
246
247    ChunkSimulation {
248        chunk_size,
249        tile_count,
250        avg_features_per_tile: avg_features,
251        median_features_per_tile: median_features,
252        max_features_per_tile: max_features,
253        oversized_tiles: oversized,
254        undersized_tiles: undersized,
255        estimated_size_uncompressed: total_uncompressed,
256        estimated_size_compressed: estimated_compressed,
257    }
258}
259
260/// Find the optimal chunk size from simulations
261fn find_optimal_chunk_size(simulations: &[ChunkSimulation]) -> (usize, usize) {
262    // Score each simulation
263    // Prefer: low oversized, reasonable tile count, good compression potential
264    let scores: Vec<(usize, f64)> = simulations
265        .iter()
266        .enumerate()
267        .map(|(idx, sim)| {
268            let mut score = 100.0;
269
270            // Penalize oversized tiles heavily
271            score -= sim.oversized_tiles as f64 * 10.0;
272
273            // Penalize too many undersized tiles
274            score -= (sim.undersized_tiles as f64 / 10.0).min(20.0);
275
276            // Prefer moderate tile counts (1000-5000)
277            if sim.tile_count < 100 {
278                score -= 20.0;
279            } else if sim.tile_count > 10000 {
280                score -= (sim.tile_count as f64 - 10000.0) / 1000.0;
281            }
282
283            // Prefer chunk sizes around 256-500 KB
284            let chunk_kb = sim.chunk_size / 1000;
285            if chunk_kb < 100 {
286                score -= 5.0;
287            } else if chunk_kb > 1000 {
288                score -= 5.0;
289            }
290
291            (idx, score)
292        })
293        .collect();
294
295    // Find best score
296    let (best_idx, _) = scores
297        .iter()
298        .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
299        .cloned()
300        .unwrap_or((2, 0.0)); // Default to 256 KB
301
302    (simulations[best_idx].chunk_size, best_idx)
303}
304
305/// Identify potential issues
306fn identify_issues(
307    data: &LoadedData,
308    spatial: &SpatialAnalysis,
309    sim: &ChunkSimulation,
310) -> Vec<DensityIssue> {
311    let mut issues = Vec::new();
312
313    // Check for oversized tiles
314    if sim.oversized_tiles > 0 {
315        issues.push(DensityIssue {
316            severity: IssueSeverity::Warning,
317            description: format!(
318                "{} tiles exceed 10,000 features (max: {})",
319                sim.oversized_tiles, sim.max_features_per_tile
320            ),
321            suggestion: "Consider enabling tile budgets or reducing max zoom level".to_string(),
322        });
323    }
324
325    // Check for many undersized tiles
326    let undersized_pct = if sim.tile_count > 0 {
327        sim.undersized_tiles as f64 / sim.tile_count as f64 * 100.0
328    } else {
329        0.0
330    };
331    if undersized_pct > 20.0 {
332        issues.push(DensityIssue {
333            severity: IssueSeverity::Info,
334            description: format!(
335                "{:.1}% of tiles have fewer than 10 features",
336                undersized_pct
337            ),
338            suggestion: "Consider reducing max zoom or increasing temporal bucketing".to_string(),
339        });
340    }
341
342    // Check for very high tile count
343    if sim.tile_count > 50_000 {
344        issues.push(DensityIssue {
345            severity: IssueSeverity::Warning,
346            description: format!(
347                "High tile count ({}) may impact loading performance",
348                sim.tile_count
349            ),
350            suggestion: "Consider reducing zoom range or increasing chunk size".to_string(),
351        });
352    }
353
354    // Check for sparse data at high zooms
355    if let Some(z_max) = spatial.zoom_coverage.iter().find(|z| z.zoom == spatial.recommended_max_zoom) {
356        if z_max.coverage_percent < 0.1 {
357            issues.push(DensityIssue {
358                severity: IssueSeverity::Info,
359                description: format!(
360                    "Only {:.2}% coverage at zoom {}",
361                    z_max.coverage_percent, spatial.recommended_max_zoom
362                ),
363                suggestion: "Data is sparse at this zoom level, consider reducing max zoom".to_string(),
364            });
365        }
366    }
367
368    // Check estimated archive size
369    let size_mb = sim.estimated_size_compressed as f64 / 1_048_576.0;
370    if size_mb > 500.0 {
371        issues.push(DensityIssue {
372            severity: IssueSeverity::Warning,
373            description: format!("Large estimated archive size ({:.1} MB)", size_mb),
374            suggestion: "Consider splitting into multiple archives or reducing data scope".to_string(),
375        });
376    }
377
378    // Check for hotspot concentration
379    if !spatial.hotspots.is_empty() {
380        let top_hotspot = &spatial.hotspots[0];
381        let hotspot_pct = top_hotspot.feature_count as f64 / data.features.len() as f64 * 100.0;
382        if hotspot_pct > 50.0 {
383            issues.push(DensityIssue {
384                severity: IssueSeverity::Info,
385                description: format!(
386                    "{:.1}% of features concentrated in {}",
387                    hotspot_pct,
388                    top_hotspot.name.as_deref().unwrap_or("one region")
389                ),
390                suggestion: "Hotspot areas may have larger tiles; budgets recommended".to_string(),
391            });
392        }
393    }
394
395    issues
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::loader::{AnalyzableFeature, GeometryType};
402    use stt_core::types::{BoundingBox, TimeRange};
403
404    /// Build synthetic data spread over a small region for density simulation.
405    fn make_grid_data(n_side: usize) -> LoadedData {
406        let mut features = Vec::new();
407        let mut min_lon = f64::MAX;
408        let mut max_lon = f64::MIN;
409        let mut min_lat = f64::MAX;
410        let mut max_lat = f64::MIN;
411        for i in 0..n_side {
412            for j in 0..n_side {
413                let lon = -100.0 + (i as f64) * 0.05;
414                let lat = 40.0 + (j as f64) * 0.05;
415                min_lon = min_lon.min(lon);
416                max_lon = max_lon.max(lon);
417                min_lat = min_lat.min(lat);
418                max_lat = max_lat.max(lat);
419                features.push(AnalyzableFeature {
420                    lon,
421                    lat,
422                    timestamp: (i * n_side + j) as u64 * 1000,
423                    geometry_type: GeometryType::Point,
424                    vertex_count: 1,
425                    estimated_size: 150,
426                    property_count: 2,
427                });
428            }
429        }
430        LoadedData {
431            features,
432            bounds: BoundingBox::new(min_lon, min_lat, max_lon, max_lat),
433            time_range: TimeRange::new(0, 1_000_000),
434        }
435    }
436
437    #[test]
438    fn test_multizoom_sim_more_tiles_than_single_zoom() {
439        // Simulating across a zoom *range* must produce at least as many tiles as
440        // any single zoom in that range (it sums tiles from every level), and the
441        // aggregate must be strictly positive.
442        let data = make_grid_data(20); // 400 points
443        let chunk_size = 256_000;
444
445        let single = simulate_zoom_chunks(&data, chunk_size, 8);
446        let multi = simulate_chunk_size_multizoom(&data, chunk_size, &[4, 6, 8, 10, 12]);
447
448        assert!(multi.tile_count > 0, "multi-zoom sim produced zero tiles");
449        assert!(
450            multi.tile_count >= single.tile_count,
451            "multi-zoom tile_count {} should be >= single-zoom z8 {}",
452            multi.tile_count,
453            single.tile_count
454        );
455        assert!(multi.estimated_size_uncompressed > 0);
456        assert!(multi.estimated_size_compressed > 0);
457    }
458
459    #[test]
460    fn test_analyze_multizoom_returns_positive_tiles() {
461        // End-to-end: analyze() must report a positive tile count and archive
462        // size aggregated across the recommended zoom range.
463        let data = make_grid_data(15); // 225 points
464        let spatial = crate::analysis::spatial::analyze(&data).unwrap();
465        let density = analyze(&data, &spatial).unwrap();
466
467        assert!(
468            density.estimated_tile_count > 0,
469            "estimated_tile_count should be > 0"
470        );
471        assert!(
472            density.estimated_archive_size > 0,
473            "estimated_archive_size should be > 0"
474        );
475        // Every chunk-size simulation should have produced tiles across zooms.
476        assert!(density
477            .chunk_simulations
478            .iter()
479            .all(|s| s.tile_count > 0));
480    }
481
482    #[test]
483    fn test_find_optimal_chunk_size() {
484        let simulations = vec![
485            ChunkSimulation {
486                chunk_size: 128_000,
487                tile_count: 5000,
488                avg_features_per_tile: 50.0,
489                median_features_per_tile: 45,
490                max_features_per_tile: 500,
491                oversized_tiles: 0,
492                undersized_tiles: 100,
493                estimated_size_uncompressed: 10_000_000,
494                estimated_size_compressed: 3_000_000,
495            },
496            ChunkSimulation {
497                chunk_size: 256_000,
498                tile_count: 3000,
499                avg_features_per_tile: 80.0,
500                median_features_per_tile: 75,
501                max_features_per_tile: 800,
502                oversized_tiles: 0,
503                undersized_tiles: 50,
504                estimated_size_uncompressed: 10_000_000,
505                estimated_size_compressed: 3_000_000,
506            },
507        ];
508
509        let (chunk_size, _) = find_optimal_chunk_size(&simulations);
510        assert_eq!(chunk_size, 256_000);
511    }
512}
513