Skip to main content

stt_optimize/
report.rs

1//! Report generation (text and JSON formats)
2//!
3//! Generates human-readable and machine-readable reports from analysis results.
4
5use crate::analysis::density::IssueSeverity;
6use crate::analysis::AnalysisResult;
7use crate::recommend::Recommendations;
8use anyhow::Result;
9
10/// Generate a text report
11pub fn generate_text(result: &AnalysisResult, recommendations: &Recommendations) -> String {
12    let mut output = String::new();
13
14    // Header
15    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
16    output.push_str(&format!("         STT Optimization Report - {}\n", result.source));
17    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
18
19    // Dataset Summary
20    output.push_str("📊 Dataset Summary\n");
21    output.push_str(&format!("  Features:        {:>12}\n", format_number(result.feature_count)));
22    output.push_str(&format!("  Time Range:      {}\n", result.temporal.time_range_description()));
23    output.push_str(&format!(
24        "  Spatial Bounds:  [{:.2}, {:.2}] to [{:.2}, {:.2}]\n",
25        result.spatial.zoom_coverage.first().map(|_| -180.0).unwrap_or(0.0),
26        -90.0,
27        180.0,
28        90.0
29    ));
30    output.push_str(&format!("  Geometry Type:   {} ({})\n", 
31        result.geometry.dominant_type,
32        format_type_distribution(&result.geometry.type_distribution)
33    ));
34    output.push('\n');
35
36    // Spatial Analysis
37    output.push_str("🗺️  Spatial Analysis\n");
38    output.push_str(&format!("  Distribution:    {}\n", result.spatial.distribution));
39    
40    // Show coverage at key zoom levels
41    for z in [4, 6, 8, 10, 12].iter() {
42        if let Some(cov) = result.spatial.zoom_coverage.iter().find(|c| c.zoom == *z) {
43            output.push_str(&format!(
44                "  Coverage at z{}:  {:.2}% ({} tiles)\n",
45                z, cov.coverage_percent, cov.occupied_tiles
46            ));
47        }
48    }
49    
50    output.push_str(&format!(
51        "  Recommended Zoom: {}-{}\n",
52        result.spatial.recommended_min_zoom,
53        result.spatial.recommended_max_zoom
54    ));
55
56    if !result.spatial.hotspots.is_empty() {
57        output.push_str("  Hotspots:\n");
58        for (i, hotspot) in result.spatial.hotspots.iter().take(3).enumerate() {
59            let name = hotspot.name.as_deref().unwrap_or("Unknown region");
60            output.push_str(&format!(
61                "    {}. {} ({} features)\n",
62                i + 1, name, format_number(hotspot.feature_count)
63            ));
64        }
65    }
66    output.push('\n');
67
68    // Temporal Analysis
69    output.push_str("⏰ Temporal Analysis\n");
70    output.push_str(&format!("  Duration:        {}\n", result.temporal.duration_human));
71    output.push_str(&format!("  Distribution:    {}\n", result.temporal.distribution));
72    output.push_str(&format!(
73        "  Events/day avg:  {:.1}\n",
74        result.temporal.events_per_day.avg
75    ));
76    output.push_str(&format!(
77        "  Unique times:    {}\n",
78        format_number(result.temporal.unique_timestamps)
79    ));
80    output.push_str(&format!(
81        "  Suggested bucket: {}\n",
82        result.temporal.recommended_bucket_human
83    ));
84    output.push('\n');
85
86    // Geometry Analysis
87    output.push_str("📐 Geometry Analysis\n");
88    output.push_str(&format!("  Complexity:      {}\n", result.geometry.complexity));
89    output.push_str(&format!(
90        "  Vertices (avg):  {:.1}\n",
91        result.geometry.vertex_stats.avg
92    ));
93    output.push_str(&format!(
94        "  Vertices (p95):  {}\n",
95        result.geometry.vertex_stats.p95
96    ));
97    output.push_str(&format!(
98        "  Avg size/feat:   {} bytes\n",
99        result.geometry.size_stats.avg as usize
100    ));
101    output.push_str(&format!(
102        "  Total size:      {}\n",
103        format_bytes(result.geometry.size_stats.total)
104    ));
105    output.push('\n');
106
107    // Size Estimation
108    output.push_str("💾 Size Estimation\n");
109    output.push_str(&format!(
110        "  Est. tiles:      {} (at recommended settings)\n",
111        format_number(result.density.estimated_tile_count)
112    ));
113    output.push_str(&format!(
114        "  Est. archive:    {} compressed\n",
115        format_bytes(result.density.estimated_archive_size)
116    ));
117    output.push('\n');
118
119    // Issues
120    if !result.density.issues.is_empty() {
121        output.push_str("⚠️  Issues\n");
122        for issue in &result.density.issues {
123            let icon = match issue.severity {
124                IssueSeverity::Error => "❌",
125                IssueSeverity::Warning => "⚠️",
126                IssueSeverity::Info => "ℹ️",
127            };
128            output.push_str(&format!("  {} {}\n", icon, issue.description));
129            output.push_str(&format!("     → {}\n", issue.suggestion));
130        }
131        output.push('\n');
132    }
133
134    // Recommendations
135    output.push_str("💡 Recommendations\n");
136    output.push_str(&format!(
137        "  --min-zoom {}\n",
138        recommendations.min_zoom
139    ));
140    output.push_str(&format!(
141        "  --max-zoom {}\n",
142        recommendations.max_zoom
143    ));
144    output.push('\n');
145
146    // Confidence
147    output.push_str(&format!(
148        "  Confidence: {}%\n",
149        recommendations.confidence
150    ));
151    output.push('\n');
152
153    // Suggested Command
154    output.push_str("📋 Suggested Command:\n");
155    output.push_str(&format!(
156        "  stt-build --input {} --output {}.stt \\\n",
157        result.source,
158        result.source.trim_end_matches(".parquet").trim_end_matches(".geoparquet")
159    ));
160    output.push_str(&format!(
161        "    --time-field timestamp --min-zoom {} --max-zoom {}\n",
162        recommendations.min_zoom, recommendations.max_zoom
163    ));
164    output.push('\n');
165
166    // Footer
167    output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
168
169    output
170}
171
172/// Generate a JSON report
173pub fn generate_json(result: &AnalysisResult, recommendations: &Recommendations) -> Result<String> {
174    let report = serde_json::json!({
175        "source": result.source,
176        "feature_count": result.feature_count,
177        "spatial": {
178            "distribution": format!("{}", result.spatial.distribution),
179            "recommended_min_zoom": result.spatial.recommended_min_zoom,
180            "recommended_max_zoom": result.spatial.recommended_max_zoom,
181            "hotspots": result.spatial.hotspots.iter().map(|h| {
182                serde_json::json!({
183                    "lon": h.lon,
184                    "lat": h.lat,
185                    "feature_count": h.feature_count,
186                    "name": h.name,
187                })
188            }).collect::<Vec<_>>(),
189            "zoom_coverage": result.spatial.zoom_coverage.iter().map(|c| {
190                serde_json::json!({
191                    "zoom": c.zoom,
192                    "coverage_percent": c.coverage_percent,
193                    "occupied_tiles": c.occupied_tiles,
194                    "avg_features_per_tile": c.avg_features_per_tile,
195                })
196            }).collect::<Vec<_>>(),
197        },
198        "temporal": {
199            "time_start": result.temporal.time_start,
200            "time_end": result.temporal.time_end,
201            "duration_ms": result.temporal.duration_ms,
202            "duration_human": result.temporal.duration_human,
203            "distribution": format!("{}", result.temporal.distribution),
204            "unique_timestamps": result.temporal.unique_timestamps,
205            "recommended_bucket_ms": result.temporal.recommended_bucket_ms,
206            "recommended_bucket_human": result.temporal.recommended_bucket_human,
207            "events_per_day": {
208                "avg": result.temporal.events_per_day.avg,
209                "min": result.temporal.events_per_day.min,
210                "max": result.temporal.events_per_day.max,
211            },
212        },
213        "geometry": {
214            "dominant_type": result.geometry.dominant_type,
215            "type_distribution": result.geometry.type_distribution,
216            "complexity": format!("{}", result.geometry.complexity),
217            "vertex_stats": {
218                "min": result.geometry.vertex_stats.min,
219                "max": result.geometry.vertex_stats.max,
220                "avg": result.geometry.vertex_stats.avg,
221                "median": result.geometry.vertex_stats.median,
222                "p95": result.geometry.vertex_stats.p95,
223            },
224            "size_stats": {
225                "min": result.geometry.size_stats.min,
226                "max": result.geometry.size_stats.max,
227                "avg": result.geometry.size_stats.avg,
228                "total": result.geometry.size_stats.total,
229            },
230        },
231        "density": {
232            "recommended_chunk_size": result.density.recommended_chunk_size,
233            "estimated_tile_count": result.density.estimated_tile_count,
234            "estimated_archive_size": result.density.estimated_archive_size,
235            "issues": result.density.issues.iter().map(|i| {
236                serde_json::json!({
237                    "severity": format!("{}", i.severity),
238                    "description": i.description,
239                    "suggestion": i.suggestion,
240                })
241            }).collect::<Vec<_>>(),
242        },
243        "recommendations": {
244            "min_zoom": recommendations.min_zoom,
245            "max_zoom": recommendations.max_zoom,
246            "temporal_bucket_ms": recommendations.temporal_bucket_ms,
247            "confidence": recommendations.confidence,
248            "explanations": recommendations.explanations,
249        },
250    });
251
252    Ok(serde_json::to_string_pretty(&report)?)
253}
254
255/// Format a number with thousands separators
256fn format_number(n: usize) -> String {
257    let s = n.to_string();
258    let mut result = String::new();
259    for (i, c) in s.chars().rev().enumerate() {
260        if i > 0 && i % 3 == 0 {
261            result.insert(0, ',');
262        }
263        result.insert(0, c);
264    }
265    result
266}
267
268/// Format bytes as human-readable string
269fn format_bytes(bytes: usize) -> String {
270    const KB: usize = 1024;
271    const MB: usize = 1024 * KB;
272    const GB: usize = 1024 * MB;
273
274    if bytes >= GB {
275        format!("{:.2} GB", bytes as f64 / GB as f64)
276    } else if bytes >= MB {
277        format!("{:.2} MB", bytes as f64 / MB as f64)
278    } else if bytes >= KB {
279        format!("{:.2} KB", bytes as f64 / KB as f64)
280    } else {
281        format!("{} bytes", bytes)
282    }
283}
284
285/// Format type distribution as percentage string
286fn format_type_distribution(dist: &std::collections::HashMap<String, usize>) -> String {
287    let total: usize = dist.values().sum();
288    if total == 0 {
289        return "N/A".to_string();
290    }
291
292    let mut parts: Vec<String> = dist
293        .iter()
294        .map(|(t, c)| {
295            let pct = *c as f64 / total as f64 * 100.0;
296            if pct > 99.0 {
297                format!("100% {}", t)
298            } else if pct > 1.0 {
299                format!("{:.0}% {}", pct, t)
300            } else {
301                String::new()
302            }
303        })
304        .filter(|s| !s.is_empty())
305        .collect();
306
307    parts.sort();
308    parts.reverse();
309    parts.join(", ")
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_format_number() {
318        assert_eq!(format_number(1000), "1,000");
319        assert_eq!(format_number(1000000), "1,000,000");
320        assert_eq!(format_number(123), "123");
321    }
322
323    #[test]
324    fn test_format_bytes() {
325        assert_eq!(format_bytes(500), "500 bytes");
326        assert_eq!(format_bytes(1024), "1.00 KB");
327        assert_eq!(format_bytes(1024 * 1024), "1.00 MB");
328        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.00 GB");
329    }
330}
331
332