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            // Read every band, not just band 0. `read_band` used to ignore its
361            // band argument and decode the whole interleaved image, so a single
362            // call happened to move all the bytes; it now returns one band
363            // plane, and profiling only band 0 would under-report a multi-band
364            // read by a factor of `band_count`.
365            // See <https://github.com/cool-japan/oxigeo/issues/14>.
366            for band in 0..reader.band_count().max(1) {
367                let _data = reader
368                    .read_band(0, band as usize)
369                    .with_context(|| format!("Failed to read band {band} from {input}"))?;
370            }
371            Ok(())
372        }
373        other => anyhow::bail!(
374            "Unsupported file extension for 'read-bands' operation: '{other}'. \
375             Supported: tif, tiff"
376        ),
377    }
378}
379
380fn execute_stats(input: &str, ext: &str) -> Result<()> {
381    match ext {
382        "tif" | "tiff" => execute_read_bands(input, ext),
383        "geojson" | "json" | "fgb" => execute_read_features(input, ext),
384        other => anyhow::bail!(
385            "Unsupported file extension for 'stats' operation: '{other}'. \
386             Supported: tif, tiff, geojson, json, fgb"
387        ),
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_profiler_no_measurements() {
397        let p = Profiler::new("test");
398        let report = p.report();
399        assert!(
400            report.contains("No measurements"),
401            "empty profiler should say no measurements"
402        );
403    }
404
405    #[test]
406    fn test_profiler_single_measurement() {
407        let mut p = Profiler::new("single");
408        p.start();
409        std::thread::sleep(Duration::from_millis(5));
410        p.stop();
411        let report = p.report();
412        assert!(report.contains("count=1"), "report should show count=1");
413        assert!(
414            report.contains("single"),
415            "report should include profiler name"
416        );
417    }
418
419    #[test]
420    fn test_profiler_export_json_empty() {
421        let p = Profiler::new("empty");
422        let json = p.export_json().expect("export_json should not fail");
423        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
424        assert_eq!(v["name"], "empty");
425        assert_eq!(v["count"], 0);
426        assert!(
427            v["measurements_ms"]
428                .as_array()
429                .map(|a| a.is_empty())
430                .unwrap_or(false),
431            "measurements_ms should be empty"
432        );
433    }
434
435    #[test]
436    fn test_profiler_export_json_with_data() {
437        let mut p = Profiler::new("json_test");
438        for _ in 0..3 {
439            p.start();
440            std::thread::sleep(Duration::from_millis(2));
441            p.stop();
442        }
443        let json = p.export_json().expect("export_json should not fail");
444        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
445        assert_eq!(v["count"], 3);
446        assert_eq!(
447            v["measurements_ms"]
448                .as_array()
449                .map(|a| a.len())
450                .unwrap_or(0),
451            3
452        );
453        assert!(v["min_ms"].as_f64().unwrap_or(0.0) > 0.0);
454        assert!(v["max_ms"].as_f64().unwrap_or(0.0) >= v["min_ms"].as_f64().unwrap_or(0.0));
455    }
456
457    #[test]
458    fn test_operation_from_str_valid() {
459        assert_eq!("open".parse::<Operation>().ok(), Some(Operation::Open));
460        assert_eq!(
461            "read-features".parse::<Operation>().ok(),
462            Some(Operation::ReadFeatures)
463        );
464        assert_eq!(
465            "read-bands".parse::<Operation>().ok(),
466            Some(Operation::ReadBands)
467        );
468        assert_eq!("stats".parse::<Operation>().ok(), Some(Operation::Stats));
469        // Aliases
470        assert_eq!("OPEN".parse::<Operation>().ok(), Some(Operation::Open));
471        assert_eq!(
472            "features".parse::<Operation>().ok(),
473            Some(Operation::ReadFeatures)
474        );
475    }
476
477    #[test]
478    fn test_operation_from_str_invalid() {
479        let result = "unknown_op".parse::<Operation>();
480        assert!(result.is_err(), "unknown operation should return Err");
481        let err_msg = result
482            .expect_err("parsing an unknown operation must fail")
483            .to_string();
484        assert!(
485            err_msg.contains("Unknown operation"),
486            "error should mention 'Unknown operation'"
487        );
488    }
489
490    #[test]
491    fn test_percentile_from_sorted_single() {
492        let data = vec![42.0f64];
493        assert!((percentile_from_sorted(&data, 50.0) - 42.0).abs() < 1e-10);
494        assert!((percentile_from_sorted(&data, 95.0) - 42.0).abs() < 1e-10);
495    }
496
497    #[test]
498    fn test_percentile_from_sorted_multiple() {
499        let data: Vec<f64> = (1..=10).map(|x| x as f64).collect();
500        let median = percentile_from_sorted(&data, 50.0);
501        // median of [1..10] should be 5.5
502        assert!(
503            (median - 5.5).abs() < 1e-10,
504            "median should be 5.5, got {median}"
505        );
506        let min = percentile_from_sorted(&data, 0.0);
507        assert!((min - 1.0).abs() < 1e-10);
508        let max = percentile_from_sorted(&data, 100.0);
509        assert!((max - 10.0).abs() < 1e-10);
510    }
511
512    #[test]
513    fn test_stop_without_start_is_noop() {
514        let mut p = Profiler::new("noop");
515        p.stop(); // should not panic
516        assert_eq!(p.measurements.len(), 0);
517    }
518}