1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DensityAnalysis {
19 pub per_zoom: Vec<ZoomDensity>,
21 pub estimated_tile_count: usize,
23 pub estimated_archive_size: usize,
25 pub issues: Vec<DensityIssue>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ZoomDensity {
33 pub zoom: u8,
35 pub tile_count: usize,
37 pub avg_features_per_tile: f64,
39 pub median_features_per_tile: usize,
41 pub max_features_per_tile: usize,
43 pub oversized_tiles: usize,
45 pub undersized_tiles: usize,
47 pub estimated_size_uncompressed: usize,
51 pub estimated_size_compressed: usize,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct DensityIssue {
59 pub severity: IssueSeverity,
61 pub description: String,
63 pub suggestion: String,
65}
66
67#[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
85pub 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
135fn bucket_zoom(
139 data: &LoadedData,
140 zoom: u8,
141 bucket_ms: u64,
142 measured: Option<&MeasuredEncoding>,
143) -> ZoomDensity {
144 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 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 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
204fn 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 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 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 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 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 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 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 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 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 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 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 let data = make_grid_data(20); 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 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 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}