Skip to main content

oxigeo_cli/util/
profiler.rs

1//! Performance profiler for geospatial operations.
2//!
3//! Provides `Profiler` (start/stop timing), `Operation` (file-dispatched actions),
4//! and `execute_operation` (run N iterations and return durations) for the
5//! `oxigeo profile` CLI subcommand.
6
7use anyhow::{Context, Result};
8use serde::Serialize;
9use std::str::FromStr;
10use std::time::{Duration, Instant};
11
12// ─── Profiler ─────────────────────────────────────────────────────────────────
13
14/// Accumulates wall-clock measurements for repeated operations.
15///
16/// Typical use:
17/// ```rust,ignore
18/// let mut p = Profiler::new("open");
19/// p.start();
20/// // ... do work ...
21/// p.stop();
22/// println!("{}", p.report());
23/// ```
24pub struct Profiler {
25    name: String,
26    measurements: Vec<Duration>,
27    current_start: Option<Instant>,
28}
29
30impl Profiler {
31    /// Create a new profiler with the given name.
32    #[must_use]
33    pub fn new(name: impl Into<String>) -> Self {
34        Self {
35            name: name.into(),
36            measurements: Vec::new(),
37            current_start: None,
38        }
39    }
40
41    /// Start a timing lap.
42    ///
43    /// Calling `start` when a lap is already running resets the start time.
44    pub fn start(&mut self) {
45        self.current_start = Some(Instant::now());
46    }
47
48    /// Stop the current timing lap and record the measurement.
49    ///
50    /// If `start` was never called, this is a no-op.
51    pub fn stop(&mut self) {
52        if let Some(start) = self.current_start.take() {
53            self.measurements.push(start.elapsed());
54        }
55    }
56
57    /// Returns a human-readable statistics table.
58    ///
59    /// Columns: count | min ms | mean ms | median ms | p95 ms | p99 ms | max ms
60    #[must_use]
61    pub fn report(&self) -> String {
62        let n = self.measurements.len();
63        if n == 0 {
64            return format!("=== {} ===\nNo measurements recorded.\n", self.name);
65        }
66
67        let stats = compute_stats(&self.measurements);
68
69        let header = format!("=== {} ({} iterations) ===", self.name, n);
70        let row = format!(
71            "count={n}  min={:.3}ms  mean={:.3}ms  median={:.3}ms  p95={:.3}ms  p99={:.3}ms  max={:.3}ms",
72            stats.min_ms, stats.mean_ms, stats.median_ms, stats.p95_ms, stats.p99_ms, stats.max_ms,
73        );
74        format!("{header}\n{row}\n")
75    }
76
77    /// Serialise the statistics + raw measurements to a pretty-printed JSON string.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if JSON serialisation fails (should not happen in practice).
82    pub fn export_json(&self) -> Result<String> {
83        let n = self.measurements.len();
84        let measurements_ms: Vec<f64> = self.measurements.iter().map(duration_to_ms).collect();
85
86        let payload = if n == 0 {
87            ProfilerJson {
88                name: self.name.clone(),
89                count: 0,
90                min_ms: 0.0,
91                mean_ms: 0.0,
92                median_ms: 0.0,
93                p95_ms: 0.0,
94                p99_ms: 0.0,
95                max_ms: 0.0,
96                measurements_ms,
97            }
98        } else {
99            let stats = compute_stats(&self.measurements);
100            ProfilerJson {
101                name: self.name.clone(),
102                count: n,
103                min_ms: stats.min_ms,
104                mean_ms: stats.mean_ms,
105                median_ms: stats.median_ms,
106                p95_ms: stats.p95_ms,
107                p99_ms: stats.p99_ms,
108                max_ms: stats.max_ms,
109                measurements_ms,
110            }
111        };
112
113        serde_json::to_string_pretty(&payload)
114            .context("Failed to serialise profiler report to JSON")
115    }
116}
117
118// ─── JSON export shape ─────────────────────────────────────────────────────────
119
120#[derive(Serialize)]
121struct ProfilerJson {
122    name: String,
123    count: usize,
124    min_ms: f64,
125    mean_ms: f64,
126    median_ms: f64,
127    p95_ms: f64,
128    p99_ms: f64,
129    max_ms: f64,
130    measurements_ms: Vec<f64>,
131}
132
133// ─── Internal statistics ───────────────────────────────────────────────────────
134
135struct Stats {
136    min_ms: f64,
137    mean_ms: f64,
138    median_ms: f64,
139    p95_ms: f64,
140    p99_ms: f64,
141    max_ms: f64,
142}
143
144fn duration_to_ms(d: &Duration) -> f64 {
145    d.as_secs_f64() * 1_000.0
146}
147
148fn compute_stats(measurements: &[Duration]) -> Stats {
149    let n = measurements.len();
150    debug_assert!(n > 0, "compute_stats called with empty slice");
151
152    let mut sorted: Vec<f64> = measurements.iter().map(duration_to_ms).collect();
153    sorted.sort_by(|a, b| a.total_cmp(b));
154
155    let min_ms = sorted[0];
156    let max_ms = sorted[n - 1];
157    let mean_ms = sorted.iter().sum::<f64>() / n as f64;
158    let median_ms = percentile_from_sorted(&sorted, 50.0);
159    let p95_ms = percentile_from_sorted(&sorted, 95.0);
160    let p99_ms = percentile_from_sorted(&sorted, 99.0);
161
162    Stats {
163        min_ms,
164        mean_ms,
165        median_ms,
166        p95_ms,
167        p99_ms,
168        max_ms,
169    }
170}
171
172/// Compute the p-th percentile from a pre-sorted slice using linear interpolation.
173fn percentile_from_sorted(sorted: &[f64], p: f64) -> f64 {
174    let n = sorted.len();
175    if n == 1 {
176        return sorted[0];
177    }
178    // rank in [0, n-1] via the "index = p/100 * (n-1)" formula
179    let rank = p / 100.0 * (n - 1) as f64;
180    let lower = rank.floor() as usize;
181    let upper = rank.ceil() as usize;
182    if lower == upper {
183        sorted[lower]
184    } else {
185        let frac = rank - lower as f64;
186        sorted[lower] * (1.0 - frac) + sorted[upper] * frac
187    }
188}
189
190// ─── Operation ────────────────────────────────────────────────────────────────
191
192/// A geospatial operation that the profiler can benchmark.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Operation {
195    /// Open the dataset (parse headers / verify magic bytes / read metadata).
196    Open,
197    /// Read all features from a vector dataset.
198    ReadFeatures,
199    /// Read raster band data (band 0 at overview level 0).
200    ReadBands,
201    /// Compute basic statistics by reading all data.
202    Stats,
203}
204
205impl Operation {
206    /// Execute the operation once against `input`.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the file cannot be opened or read.
211    pub fn execute(&self, input: &str) -> Result<()> {
212        let path = std::path::Path::new(input);
213        let ext = path
214            .extension()
215            .and_then(|e| e.to_str())
216            .map(|e| e.to_lowercase())
217            .unwrap_or_default();
218
219        match self {
220            Self::Open => execute_open(input, &ext),
221            Self::ReadFeatures => execute_read_features(input, &ext),
222            Self::ReadBands => execute_read_bands(input, &ext),
223            Self::Stats => execute_stats(input, &ext),
224        }
225    }
226}
227
228impl FromStr for Operation {
229    type Err = anyhow::Error;
230
231    fn from_str(s: &str) -> Result<Self, Self::Err> {
232        match s.to_lowercase().as_str() {
233            "open" | "open-dataset" | "opendataset" => Ok(Self::Open),
234            "read-features" | "readfeatures" | "features" => Ok(Self::ReadFeatures),
235            "read-bands" | "readbands" | "bands" => Ok(Self::ReadBands),
236            "stats" | "compute-stats" | "computestats" => Ok(Self::Stats),
237            other => anyhow::bail!(
238                "Unknown operation: '{other}'. Valid options: open, read-features, read-bands, stats"
239            ),
240        }
241    }
242}
243
244impl std::fmt::Display for Operation {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        let name = match self {
247            Self::Open => "open",
248            Self::ReadFeatures => "read-features",
249            Self::ReadBands => "read-bands",
250            Self::Stats => "stats",
251        };
252        write!(f, "{name}")
253    }
254}
255
256// ─── execute_operation ────────────────────────────────────────────────────────
257
258/// Run `op` `iterations` times against `input` and return the per-iteration durations.
259///
260/// Superseded by `commands::profile::run_profiler` (which additionally feeds a
261/// [`Profiler`] for report/JSON export), but kept as a lighter-weight public
262/// helper for callers that only need raw per-iteration [`Duration`]s.
263///
264/// # Errors
265///
266/// Propagates any error that occurs during the first failing iteration.
267#[allow(dead_code)]
268pub fn execute_operation(op: &Operation, input: &str, iterations: usize) -> Result<Vec<Duration>> {
269    let mut durations = Vec::with_capacity(iterations);
270    for i in 0..iterations {
271        let start = Instant::now();
272        op.execute(input)
273            .with_context(|| format!("Iteration {i} of operation '{op}' failed"))?;
274        durations.push(start.elapsed());
275    }
276    Ok(durations)
277}
278
279// ─── Dispatch helpers ─────────────────────────────────────────────────────────
280
281fn execute_open(input: &str, ext: &str) -> Result<()> {
282    match ext {
283        "tif" | "tiff" => {
284            use oxigeo_core::io::FileDataSource;
285            use oxigeo_geotiff::GeoTiffReader;
286            let source = FileDataSource::open(input)
287                .with_context(|| format!("Failed to open GeoTIFF: {input}"))?;
288            let _reader = GeoTiffReader::open(source)
289                .with_context(|| format!("Failed to parse GeoTIFF header: {input}"))?;
290            Ok(())
291        }
292        "geojson" | "json" => {
293            use oxigeo_geojson::GeoJsonReader;
294            use std::fs::File;
295            use std::io::BufReader;
296            let file =
297                File::open(input).with_context(|| format!("Failed to open GeoJSON: {input}"))?;
298            let _reader = GeoJsonReader::new(BufReader::new(file));
299            Ok(())
300        }
301        "fgb" => {
302            use oxigeo_flatgeobuf::FlatGeobufReader;
303            use std::fs::File;
304            let file =
305                File::open(input).with_context(|| format!("Failed to open FlatGeobuf: {input}"))?;
306            let _reader = FlatGeobufReader::new(file)
307                .with_context(|| format!("Failed to parse FlatGeobuf header: {input}"))?;
308            Ok(())
309        }
310        other => anyhow::bail!(
311            "Unsupported file extension for 'open' operation: '{other}'. \
312             Supported: tif, tiff, geojson, json, fgb"
313        ),
314    }
315}
316
317fn execute_read_features(input: &str, ext: &str) -> Result<()> {
318    match ext {
319        "geojson" | "json" => {
320            use oxigeo_geojson::GeoJsonReader;
321            use std::fs::File;
322            use std::io::BufReader;
323            let file =
324                File::open(input).with_context(|| format!("Failed to open GeoJSON: {input}"))?;
325            let mut reader = GeoJsonReader::new(BufReader::new(file));
326            let _fc = reader
327                .read_feature_collection()
328                .with_context(|| format!("Failed to read features from {input}"))?;
329            Ok(())
330        }
331        "fgb" => {
332            use oxigeo_flatgeobuf::FlatGeobufReader;
333            use std::fs::File;
334            let file =
335                File::open(input).with_context(|| format!("Failed to open FlatGeobuf: {input}"))?;
336            let mut reader = FlatGeobufReader::new(file)
337                .with_context(|| format!("Failed to parse FlatGeobuf: {input}"))?;
338            let mut iter = reader
339                .features()
340                .with_context(|| format!("Failed to iterate features from {input}"))?;
341            while iter.next().is_some() {}
342            Ok(())
343        }
344        other => anyhow::bail!(
345            "Unsupported file extension for 'read-features' operation: '{other}'. \
346             Supported: geojson, json, fgb"
347        ),
348    }
349}
350
351fn execute_read_bands(input: &str, ext: &str) -> Result<()> {
352    match ext {
353        "tif" | "tiff" => {
354            use oxigeo_core::io::FileDataSource;
355            use oxigeo_geotiff::GeoTiffReader;
356            let source = FileDataSource::open(input)
357                .with_context(|| format!("Failed to open GeoTIFF: {input}"))?;
358            let reader = GeoTiffReader::open(source)
359                .with_context(|| format!("Failed to parse GeoTIFF: {input}"))?;
360            let _data = reader
361                .read_band(0, 0)
362                .with_context(|| format!("Failed to read band from {input}"))?;
363            Ok(())
364        }
365        other => anyhow::bail!(
366            "Unsupported file extension for 'read-bands' operation: '{other}'. \
367             Supported: tif, tiff"
368        ),
369    }
370}
371
372fn execute_stats(input: &str, ext: &str) -> Result<()> {
373    match ext {
374        "tif" | "tiff" => execute_read_bands(input, ext),
375        "geojson" | "json" | "fgb" => execute_read_features(input, ext),
376        other => anyhow::bail!(
377            "Unsupported file extension for 'stats' operation: '{other}'. \
378             Supported: tif, tiff, geojson, json, fgb"
379        ),
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn test_profiler_no_measurements() {
389        let p = Profiler::new("test");
390        let report = p.report();
391        assert!(
392            report.contains("No measurements"),
393            "empty profiler should say no measurements"
394        );
395    }
396
397    #[test]
398    fn test_profiler_single_measurement() {
399        let mut p = Profiler::new("single");
400        p.start();
401        std::thread::sleep(Duration::from_millis(5));
402        p.stop();
403        let report = p.report();
404        assert!(report.contains("count=1"), "report should show count=1");
405        assert!(
406            report.contains("single"),
407            "report should include profiler name"
408        );
409    }
410
411    #[test]
412    fn test_profiler_export_json_empty() {
413        let p = Profiler::new("empty");
414        let json = p.export_json().expect("export_json should not fail");
415        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
416        assert_eq!(v["name"], "empty");
417        assert_eq!(v["count"], 0);
418        assert!(
419            v["measurements_ms"]
420                .as_array()
421                .map(|a| a.is_empty())
422                .unwrap_or(false),
423            "measurements_ms should be empty"
424        );
425    }
426
427    #[test]
428    fn test_profiler_export_json_with_data() {
429        let mut p = Profiler::new("json_test");
430        for _ in 0..3 {
431            p.start();
432            std::thread::sleep(Duration::from_millis(2));
433            p.stop();
434        }
435        let json = p.export_json().expect("export_json should not fail");
436        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
437        assert_eq!(v["count"], 3);
438        assert_eq!(
439            v["measurements_ms"]
440                .as_array()
441                .map(|a| a.len())
442                .unwrap_or(0),
443            3
444        );
445        assert!(v["min_ms"].as_f64().unwrap_or(0.0) > 0.0);
446        assert!(v["max_ms"].as_f64().unwrap_or(0.0) >= v["min_ms"].as_f64().unwrap_or(0.0));
447    }
448
449    #[test]
450    fn test_operation_from_str_valid() {
451        assert_eq!("open".parse::<Operation>().ok(), Some(Operation::Open));
452        assert_eq!(
453            "read-features".parse::<Operation>().ok(),
454            Some(Operation::ReadFeatures)
455        );
456        assert_eq!(
457            "read-bands".parse::<Operation>().ok(),
458            Some(Operation::ReadBands)
459        );
460        assert_eq!("stats".parse::<Operation>().ok(), Some(Operation::Stats));
461        // Aliases
462        assert_eq!("OPEN".parse::<Operation>().ok(), Some(Operation::Open));
463        assert_eq!(
464            "features".parse::<Operation>().ok(),
465            Some(Operation::ReadFeatures)
466        );
467    }
468
469    #[test]
470    fn test_operation_from_str_invalid() {
471        let result = "unknown_op".parse::<Operation>();
472        assert!(result.is_err(), "unknown operation should return Err");
473        let err_msg = result
474            .expect_err("parsing an unknown operation must fail")
475            .to_string();
476        assert!(
477            err_msg.contains("Unknown operation"),
478            "error should mention 'Unknown operation'"
479        );
480    }
481
482    #[test]
483    fn test_percentile_from_sorted_single() {
484        let data = vec![42.0f64];
485        assert!((percentile_from_sorted(&data, 50.0) - 42.0).abs() < 1e-10);
486        assert!((percentile_from_sorted(&data, 95.0) - 42.0).abs() < 1e-10);
487    }
488
489    #[test]
490    fn test_percentile_from_sorted_multiple() {
491        let data: Vec<f64> = (1..=10).map(|x| x as f64).collect();
492        let median = percentile_from_sorted(&data, 50.0);
493        // median of [1..10] should be 5.5
494        assert!(
495            (median - 5.5).abs() < 1e-10,
496            "median should be 5.5, got {median}"
497        );
498        let min = percentile_from_sorted(&data, 0.0);
499        assert!((min - 1.0).abs() < 1e-10);
500        let max = percentile_from_sorted(&data, 100.0);
501        assert!((max - 10.0).abs() < 1e-10);
502    }
503
504    #[test]
505    fn test_stop_without_start_is_noop() {
506        let mut p = Profiler::new("noop");
507        p.stop(); // should not panic
508        assert_eq!(p.measurements.len(), 0);
509    }
510}