1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct DensityAnalysis {
16 pub chunk_simulations: Vec<ChunkSimulation>,
18 pub recommended_chunk_size: usize,
20 pub estimated_tile_count: usize,
22 pub estimated_archive_size: usize,
24 pub issues: Vec<DensityIssue>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ChunkSimulation {
31 pub chunk_size: usize,
33 pub tile_count: usize,
35 pub avg_features_per_tile: f64,
37 pub median_features_per_tile: usize,
39 pub max_features_per_tile: usize,
41 pub oversized_tiles: usize,
43 pub undersized_tiles: usize,
45 pub estimated_size_uncompressed: usize,
47 pub estimated_size_compressed: usize,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DensityIssue {
54 pub severity: IssueSeverity,
56 pub description: String,
58 pub suggestion: String,
60}
61
62#[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
80pub fn analyze(data: &LoadedData, spatial: &SpatialAnalysis) -> Result<DensityAnalysis> {
82 let chunk_sizes = [
84 64_000, 128_000, 256_000, 500_000, 1_000_000, 2_000_000, ];
91
92 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 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 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
127fn 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
152struct ZoomChunkResult {
154 tile_count: usize,
155 feature_counts: Vec<usize>,
156 total_uncompressed: usize,
157}
158
159fn simulate_zoom_chunks(data: &LoadedData, chunk_size: usize, zoom: u8) -> ZoomChunkResult {
162 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 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 let mut indices = feature_indices.clone();
179 indices.sort_by_key(|&i| data.features[i].timestamp);
180
181 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 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 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
216fn finalize_simulation(
218 chunk_size: usize,
219 tile_count: usize,
220 mut feature_counts: Vec<usize>,
221 total_uncompressed: usize,
222) -> ChunkSimulation {
223 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 let oversized = feature_counts.iter().filter(|&&c| c > 10_000).count();
241 let undersized = feature_counts.iter().filter(|&&c| c < 10).count();
242
243 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
260fn find_optimal_chunk_size(simulations: &[ChunkSimulation]) -> (usize, usize) {
262 let scores: Vec<(usize, f64)> = simulations
265 .iter()
266 .enumerate()
267 .map(|(idx, sim)| {
268 let mut score = 100.0;
269
270 score -= sim.oversized_tiles as f64 * 10.0;
272
273 score -= (sim.undersized_tiles as f64 / 10.0).min(20.0);
275
276 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 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 let (best_idx, _) = scores
297 .iter()
298 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
299 .cloned()
300 .unwrap_or((2, 0.0)); (simulations[best_idx].chunk_size, best_idx)
303}
304
305fn identify_issues(
307 data: &LoadedData,
308 spatial: &SpatialAnalysis,
309 sim: &ChunkSimulation,
310) -> Vec<DensityIssue> {
311 let mut issues = Vec::new();
312
313 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 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 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 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 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 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 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 let data = make_grid_data(20); 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 let data = make_grid_data(15); 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 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