Skip to main content

ridal_lib/
lib.rs

1//! # ridal --- Speeding up Ground Penetrating Radar (GPR) processing
2//! A Ground Penetrating Radar (GPR) processing tool written in rust.
3
4mod cli;
5mod coords;
6mod dem;
7mod export;
8mod filters;
9mod formats;
10mod gpr;
11mod io;
12mod tools;
13mod user_metadata;
14
15#[allow(dead_code)]
16const PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
17#[allow(dead_code)]
18const PROGRAM_NAME: &str = env!("CARGO_PKG_NAME");
19#[allow(dead_code)]
20const PROGRAM_AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
21
22/// Python interface for ridal.
23///
24/// ridal provides fast, Rust-backed tools for reading, inspecting, and
25/// processing ground-penetrating radar (GPR) data from Python.
26///
27/// Main entry points
28/// -----------------
29/// read(...)
30///     Read one or more GPR files into memory without applying a processing
31///     workflow.
32/// info(...)
33///     Inspect one or more GPR files and return metadata summaries.
34/// process(...)
35///     Process one or more GPR files into a single output or an in-memory
36///     dataset.
37/// batch_process(...)
38///     Batch-process one or more GPR files into multiple outputs.
39///
40/// Discovery helpers
41/// -----------------
42/// all_steps, all_step_descriptions
43///     Available processing steps and their descriptions.
44/// all_formats, all_format_descriptions
45///     Supported file formats and their capabilities.
46/// version, __version__
47///     Installed ridal version.
48///
49/// Notes
50/// -----
51/// `xarray` is an optional dependency. If it is installed, some functions can
52/// return `xarray.Dataset` objects. Otherwise, use the plain Python dataset
53/// representations such as `"xarray_dict"`.
54#[cfg(feature = "python")]
55#[pyo3::pymodule]
56pub mod ridal {
57    use crate::{formats, gpr};
58    use pyo3::exceptions::{PyNotImplementedError, PyRuntimeError};
59    use pyo3::ffi::c_str;
60    use pyo3::prelude::*;
61    use pyo3::types::{PyAny, PyDict, PyList, PyTuple};
62
63    use std::collections::BTreeMap;
64    use std::path::PathBuf;
65
66    fn optional_metadata(
67        py: Python<'_>,
68        value: Option<Py<PyAny>>,
69    ) -> PyResult<crate::user_metadata::UserMetadata> {
70        match value {
71            None => Ok(crate::user_metadata::UserMetadata::new()),
72            Some(obj) => {
73                let bound = obj.bind(py);
74                let json = py.import("json")?;
75                let text: String = json.getattr("dumps")?.call1((bound,))?.extract()?;
76                let value: serde_json::Value = serde_json::from_str(&text)
77                    .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{e:?}")))?;
78                crate::user_metadata::value_to_metadata(value)
79                    .map_err(pyo3::exceptions::PyValueError::new_err)
80            }
81        }
82    }
83
84    fn json_to_py(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
85        let json = py.import("json")?;
86        Ok(json.getattr("loads")?.call1((text,))?.unbind())
87    }
88
89    fn xarray_dict_to_ds(py: Python<'_>, dict: Py<PyAny>) -> PyResult<Py<PyAny>> {
90        let xarray = py.import("xarray")?;
91
92        Ok(xarray
93            .getattr("Dataset")?
94            .getattr("from_dict")?
95            .call1((&dict,))?
96            .unbind())
97    }
98
99    fn fspath(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult<PathBuf> {
100        let os = py.import("os")?;
101        let path = os.getattr("fspath")?.call1((value,))?;
102        let path_str: String = path.extract()?;
103
104        let os_path = os.getattr("path")?;
105        let expanded = os_path.getattr("expanduser")?.call1((path_str,))?;
106        let expanded_str: String = expanded.extract()?;
107
108        Ok(PathBuf::from(expanded_str))
109    }
110
111    fn inputs_to_paths(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult<Vec<PathBuf>> {
112        if let Ok(list) = value.cast::<PyList>() {
113            return list.iter().map(|item| fspath(py, &item)).collect();
114        }
115        if let Ok(tuple) = value.cast::<PyTuple>() {
116            return tuple.iter().map(|item| fspath(py, &item)).collect();
117        }
118        Ok(vec![fspath(py, value)?])
119    }
120
121    fn optional_path(py: Python<'_>, value: Option<Py<PyAny>>) -> PyResult<Option<PathBuf>> {
122        match value {
123            Some(obj) => {
124                let bound = obj.bind(py);
125                Ok(Some(fspath(py, bound)?))
126            }
127            None => Ok(None),
128        }
129    }
130
131    #[pymodule_init]
132    fn init(m: &Bound<'_, PyModule>) -> PyResult<()> {
133        let py = m.py();
134        m.add("version", crate::PROGRAM_VERSION)?;
135        m.add("__version__", crate::PROGRAM_VERSION)?;
136
137        let all_steps = gpr::all_available_steps();
138        let step_names = all_steps
139            .iter()
140            .map(|(name, _)| name.clone())
141            .collect::<Vec<String>>();
142        let step_descriptions = all_steps
143            .iter()
144            .map(|(name, description)| (name.clone(), description.clone()))
145            .collect::<BTreeMap<String, String>>();
146
147        m.add("all_steps", step_names)?;
148        m.add(
149            "all_step_descriptions",
150            json_to_py(py, &serde_json::to_string(&step_descriptions).unwrap())?,
151        )?;
152
153        let all_formats = formats::all_formats();
154        let format_names = all_formats
155            .iter()
156            .map(|fmt| fmt.name.to_string())
157            .collect::<Vec<String>>();
158
159        let format_descriptions = all_formats
160            .iter()
161            .map(|fmt| {
162                (
163                    fmt.name.to_string(),
164                    serde_json::json!({
165                        "description": fmt.description,
166                        "capabilities": {
167                            "read": fmt.capabilities.read,
168                            "write": fmt.capabilities.write,
169                        },
170                        "files": {
171                            "header": fmt.files.header,
172                            "data": fmt.files.data,
173                            "coordinates": fmt.files.coordinates,
174                        }
175                    }),
176                )
177            })
178            .collect::<BTreeMap<String, serde_json::Value>>();
179
180        m.add("all_formats", format_names)?;
181        m.add(
182            "all_format_descriptions",
183            json_to_py(py, &serde_json::to_string(&format_descriptions).unwrap())?,
184        )?;
185        Ok(())
186    }
187
188    /// Process one or more GPR files into a single output or an in-memory dataset.
189    ///
190    /// Use this function when you want to modify or process radar data. One or more
191    /// input files are read and optionally corrected or transformed. By default,
192    /// the result is written to a single output dataset and the output path is
193    /// returned. If `return_dataset=True`, the processed result is returned in
194    /// memory instead of being written to disk.
195    ///
196    /// Parameters
197    /// ----------
198    /// inputs : path-like or sequence of path-like
199    ///     One or more input files to read. A single path, list, or tuple of
200    ///     path-like objects is accepted.
201    /// output : path-like, optional
202    ///     Output file path or output directory. If omitted, a default output path
203    ///     is derived from the first input. Ignored when `return_dataset=True`.
204    /// steps : str or sequence of str, optional
205    ///     Processing steps to apply. This may be given either as a comma-separated
206    ///     string or as a sequence of step names.
207    ///
208    ///     Available steps are exposed as `ridal.all_steps`, and descriptions are
209    ///     available in `ridal.all_step_descriptions`.
210    ///
211    ///     Exactly one of `steps`, `default`, and `default_with_topo` may be given.
212    /// return_dataset : bool, default False
213    ///     If True, return the processed data as an in-memory dataset object instead
214    ///     of writing the dataset to disk. In this mode, `output`, `render`, and
215    ///     `track` must not be provided.
216    /// default : bool, default False
217    ///     Use the default processing profile.
218    ///
219    ///     Exactly one of `steps`, `default`, and `default_with_topo` may be given.
220    /// default_with_topo : bool, default False
221    ///     Use the default processing profile and include topographic correction.
222    ///
223    ///     Exactly one of `steps`, `default`, and `default_with_topo` may be given.
224    /// velocity : float, default 0.168
225    ///     Propagation velocity in meters per nanosecond.
226    /// cor : path-like, optional
227    ///     Coordinate file to use instead of any coordinate information implied by
228    ///     the input format.
229    /// dem : path-like, optional
230    ///     Digital elevation model to sample for topographic information.
231    /// crs : str, optional
232    ///     Coordinate reference system for interpreting or transforming coordinates.
233    ///     If omitted, the most appropriate WGS84 UTM zone is used.
234    /// track : path-like, optional
235    ///     Output path for exported track data. Not allowed when
236    ///     `return_dataset=True`.
237    /// quiet : bool, default False
238    ///     Reduce logging and progress output.
239    /// render : path-like, optional
240    ///     Output path for a rendered figure. Not allowed when
241    ///     `return_dataset=True`.
242    /// no_export : bool, default False
243    ///     Run processing without writing the main dataset output. Side outputs such
244    ///     as rendered figures or exported tracks may still be produced.
245    /// override_antenna_mhz : float, optional
246    ///     Override the antenna center frequency inferred from the input data.
247    /// metadata : mapping, optional
248    ///     Additional user metadata to attach to the result. This should be a
249    ///     JSON-serializable mapping. Root keys are interpreted as strings.
250    /// return_dataset_format : str, default "xarray_dict"
251    ///     Format used when `return_dataset=True`.
252    ///
253    ///     Supported values currently include:
254    ///
255    ///     - ``"xarray_dict"`` for a plain Python representation that does not
256    ///       require importing `xarray`.
257    ///     - ``"xarray"`` for an `xarray.Dataset`, which requires `xarray` to be
258    ///       installed.
259    ///
260    ///     More return formats may be added in the future.
261    ///
262    /// Returns
263    /// -------
264    /// str or object
265    ///     The output dataset path as a string in normal export mode, or an
266    ///     in-memory dataset object when `return_dataset=True`.
267    ///
268    /// Raises
269    /// ------
270    /// ValueError
271    ///     If incompatible arguments are provided, including:
272    ///
273    ///     - more than one of `steps`, `default`, and `default_with_topo`
274    ///     - `return_dataset=True` together with `output`, `render`, or `track`
275    ///     - an invalid `steps` value
276    /// RuntimeError
277    ///     If processing fails.
278    /// NotImplementedError
279    ///     If `return_dataset_format` is not supported.
280    ///
281    /// Notes
282    /// -----
283    /// `process()` is the main processing entry point and is intended for workflows
284    /// that modify the data. For lightweight loading of raw data without heavy
285    /// processing, use `read()`.
286    #[pyfunction]
287    #[allow(clippy::too_many_arguments)]
288    #[pyo3(signature = (
289        inputs,
290        output=None,
291        *,
292        steps=None,
293        return_dataset=false,
294        default=false,
295        default_with_topo=false,
296        velocity=0.168,
297        cor=None,
298        dem=None,
299        crs=None,
300        track=None,
301        quiet=false,
302        render=None,
303        no_export=false,
304        override_antenna_mhz=None,
305        metadata=None,
306        return_dataset_format="xarray_dict".to_string()
307    ))]
308    fn process(
309        py: Python<'_>,
310        inputs: Py<PyAny>,
311        output: Option<Py<PyAny>>,
312        steps: Option<Py<PyAny>>,
313        return_dataset: bool,
314        default: bool,
315        default_with_topo: bool,
316        velocity: f32,
317        cor: Option<Py<PyAny>>,
318        dem: Option<Py<PyAny>>,
319        crs: Option<String>,
320        track: Option<Py<PyAny>>,
321        quiet: bool,
322        render: Option<Py<PyAny>>,
323        no_export: bool,
324        override_antenna_mhz: Option<f32>,
325        metadata: Option<Py<PyAny>>,
326        return_dataset_format: String,
327    ) -> PyResult<Py<PyAny>> {
328        use pyo3::exceptions::PyValueError;
329
330        if !["xarray", "xarray_dict"]
331            .iter()
332            .any(|s| s == &return_dataset_format)
333        {
334            return Err(PyNotImplementedError::new_err(
335                "Only 'xarray_dict' and 'xarray' return formats are supported for now",
336            ));
337        };
338
339        if return_dataset {
340            if output.is_some() {
341                return Err(PyValueError::new_err(
342                    "return_dataset=True requires output=None",
343                ));
344            }
345            if render.is_some() {
346                return Err(PyValueError::new_err(
347                    "return_dataset=True is incompatible with render=...",
348                ));
349            }
350            if track.is_some() {
351                return Err(PyValueError::new_err(
352                    "return_dataset=True is incompatible with track=...",
353                ));
354            }
355        }
356        let profile_flags =
357            usize::from(steps.is_some()) + usize::from(default) + usize::from(default_with_topo);
358
359        if profile_flags > 1 {
360            return Err(PyValueError::new_err(
361                "Only one of steps=..., default=True, and default_with_topo=True may be provided",
362            ));
363        }
364        let input_paths = inputs_to_paths(py, inputs.bind(py))?;
365        let output_path = optional_path(py, output)?;
366        let cor_path = optional_path(py, cor)?;
367        let dem_path = optional_path(py, dem)?;
368        let track_path = match track {
369            Some(obj) => Some(Some(fspath(py, obj.bind(py))?)),
370            None => None,
371        };
372        let render_path = match render {
373            Some(obj) => Some(Some(fspath(py, obj.bind(py))?)),
374            None => None,
375        };
376
377        let steps_text = match steps {
378            Some(step_obj) => {
379                let bound = step_obj.bind(py);
380                if let Ok(step_text) = bound.extract::<String>() {
381                    Some(step_text)
382                } else if let Ok(step_list) = bound.cast::<PyList>() {
383                    let parts = step_list
384                        .iter()
385                        .map(|item| item.extract::<String>())
386                        .collect::<PyResult<Vec<String>>>()?;
387                    Some(parts.join(","))
388                } else if let Ok(step_tuple) = bound.cast::<PyTuple>() {
389                    let parts = step_tuple
390                        .iter()
391                        .map(|item| item.extract::<String>())
392                        .collect::<PyResult<Vec<String>>>()?;
393                    Some(parts.join(","))
394                } else {
395                    return Err(PyValueError::new_err(
396                        "steps must be a string or a list/tuple of strings",
397                    ));
398                }
399            }
400            None => None,
401        };
402
403        let resolved_steps = if default_with_topo {
404            let mut profile = gpr::default_processing_profile();
405            profile.push("correct_topography".to_string());
406            profile
407        } else if default {
408            gpr::default_processing_profile()
409        } else if let Some(step_text) = steps_text.as_deref() {
410            crate::tools::parse_step_list(step_text).map_err(PyValueError::new_err)?
411        } else {
412            vec![]
413        };
414
415        gpr::validate_steps(&resolved_steps).map_err(PyValueError::new_err)?;
416
417        let user_metadata = optional_metadata(py, metadata)?;
418
419        if return_dataset {
420            // Build but do not export
421            let params2 = gpr::RunParams {
422                filepaths: input_paths,
423                output_path: None,
424                dem_path,
425                cor_path,
426                medium_velocity: velocity,
427                crs,
428                quiet,
429                track_path: None,
430                steps: resolved_steps,
431                no_export: true,
432                render_path: None,
433                override_antenna_mhz,
434                user_metadata,
435            };
436            let (gpr_obj, _default_path) = gpr::build_processed_gpr(params2)
437                .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e:?}")))?;
438            let ds = gpr_obj
439                .export_dataset()
440                .map_err(pyo3::exceptions::PyRuntimeError::new_err)?;
441            let ds_py = ds.to_python(py);
442            if return_dataset_format == "xarray_dict" {
443                return ds_py;
444            } else if return_dataset_format == "xarray" {
445                return xarray_dict_to_ds(py, ds_py?);
446            } else {
447                unreachable!()
448            }
449        }
450
451        // file/export mode (unchanged)
452        let params = gpr::RunParams {
453            filepaths: input_paths,
454            output_path,
455            dem_path,
456            cor_path,
457            medium_velocity: velocity,
458            crs,
459            quiet,
460            track_path,
461            steps: resolved_steps,
462            no_export,
463            render_path,
464            override_antenna_mhz,
465            user_metadata,
466        };
467        let result = gpr::run(params)
468            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e:?}")))?;
469        // Ok(result.output_path.to_string_lossy().to_string())
470        Ok(py
471            .eval(c_str!("str"), None, None)?
472            .call1((result.output_path.to_string_lossy().to_string(),))?
473            .unbind())
474    }
475    /// Read one or more GPR files into memory without applying a processing workflow.
476    ///
477    /// Use this function to load radar data in a lightweight form for inspection,
478    /// exploration, or downstream processing in Python. Unlike `process()`,
479    /// `read()` is intended to return the data essentially as read from disk rather
480    /// than applying a full processing workflow.
481    ///
482    /// Parameters
483    /// ----------
484    /// inputs : path-like or sequence of path-like
485    ///     One or more input files to read. A single path, list, or tuple of
486    ///     path-like objects is accepted.
487    /// velocity : float, default 0.168
488    ///     Propagation velocity in meters per nanosecond.
489    /// cor : path-like, optional
490    ///     Coordinate file to use instead of any coordinate information implied by
491    ///     the input format.
492    /// dem : path-like, optional
493    ///     Digital elevation model to sample for topographic information.
494    /// crs : str, optional
495    ///     Coordinate reference system for interpreting or transforming coordinates.
496    ///     If omitted, the most appropriate WGS84 UTM zone is used.
497    /// override_antenna_mhz : float, optional
498    ///     Override the antenna center frequency inferred from the input data.
499    /// metadata : mapping, optional
500    ///     Additional user metadata to attach to the returned dataset. This should
501    ///     be a JSON-serializable mapping. Root keys are interpreted as strings.
502    /// return_dataset_format : str, default "xarray_dict"
503    ///     Format of the returned in-memory dataset.
504    ///
505    ///     Supported values currently include:
506    ///
507    ///     - ``"xarray_dict"`` for a plain Python representation that does not
508    ///       require importing `xarray`.
509    ///     - ``"xarray"`` for an `xarray.Dataset`, which requires `xarray` to be
510    ///       installed.
511    ///
512    ///     More return formats may be added in the future.
513    ///
514    /// Returns
515    /// -------
516    /// object
517    ///     An in-memory dataset representation of the input data.
518    ///
519    /// Raises
520    /// ------
521    /// RuntimeError
522    ///     If reading fails.
523    /// NotImplementedError
524    ///     If `return_dataset_format` is not supported.
525    ///
526    /// Notes
527    /// -----
528    /// `read()` is intended as a lightweight loader. If you want to apply filtering,
529    /// corrections, or export a processed dataset, use `process()` instead.
530    #[pyfunction]
531    #[allow(clippy::too_many_arguments)]
532    #[pyo3(signature = (
533        inputs,
534        *,
535        velocity=0.168,
536        cor=None,
537        dem=None,
538        crs=None,
539        override_antenna_mhz=None,
540        metadata=None,
541        return_dataset_format="xarray_dict".to_string()
542    ))]
543    fn read(
544        py: Python<'_>,
545        inputs: Py<PyAny>,
546        velocity: f32,
547        cor: Option<Py<PyAny>>,
548        dem: Option<Py<PyAny>>,
549        crs: Option<String>,
550        override_antenna_mhz: Option<f32>,
551        metadata: Option<Py<PyAny>>,
552        return_dataset_format: String,
553    ) -> PyResult<Py<PyAny>> {
554        process(
555            py,
556            inputs,
557            None,
558            None,
559            true,
560            false,
561            false,
562            velocity,
563            cor,
564            dem,
565            crs,
566            None,
567            true,
568            None,
569            false,
570            override_antenna_mhz,
571            metadata,
572            return_dataset_format,
573        )
574    }
575    /// Batch-process one or more GPR files into multiple outputs.
576    ///
577    /// Use this function when many input files should be processed in one call and
578    /// written as separate outputs in an existing output directory.
579    ///
580    /// Parameters
581    /// ----------
582    /// inputs : path-like or sequence of path-like
583    ///     One or more input files to process. A single path, list, or tuple of
584    ///     path-like objects is accepted.
585    /// output : path-like
586    ///     Existing output directory where processed datasets will be written.
587    /// steps : str or sequence of str, optional
588    ///     Processing steps to apply. This may be given either as a comma-separated
589    ///     string or as a sequence of step names.
590    ///
591    ///     Available steps are exposed as `ridal.all_steps`, and descriptions are
592    ///     available in `ridal.all_step_descriptions`.
593    ///
594    ///     Exactly one of `steps`, `default`, and `default_with_topo` may be given.
595    /// default : bool, default False
596    ///     Use the default processing profile.
597    ///
598    ///     Exactly one of `steps`, `default`, and `default_with_topo` may be given.
599    /// default_with_topo : bool, default False
600    ///     Use the default processing profile and include topographic correction.
601    ///
602    ///     Exactly one of `steps`, `default`, and `default_with_topo` may be given.
603    /// velocity : float, default 0.168
604    ///     Propagation velocity in meters per nanosecond.
605    /// cor : path-like, optional
606    ///     Coordinate file to use instead of any coordinate information implied by
607    ///     the input format.
608    /// dem : path-like, optional
609    ///     Digital elevation model to sample for topographic information.
610    /// crs : str, optional
611    ///     Coordinate reference system for interpreting or transforming coordinates.
612    ///     If omitted, the most appropriate WGS84 UTM zone is used.
613    /// track : path-like, optional
614    ///     Existing directory where exported track files should be written.
615    /// quiet : bool, default False
616    ///     Reduce logging and progress output.
617    /// render : path-like, optional
618    ///     Existing directory where rendered figures should be written.
619    /// no_export : bool, default False
620    ///     Run processing without writing the main dataset outputs. Side outputs
621    ///     such as rendered figures or exported tracks may still be produced.
622    /// merge : str, optional
623    ///     Merge chronologically neighboring profiles when they are close enough in
624    ///     time and otherwise compatible.
625    ///
626    ///     For example, ``"10 min"`` will merge neighboring profiles separated by
627    ///     less than ten minutes.
628    ///
629    ///     The value is parsed using the `parse_duration` syntax. Briefly, it
630    ///     accepts sequences of ``[value] [unit]`` pairs such as
631    ///     ``"15 days 20 seconds 100 milliseconds"``; spaces are optional, and
632    ///     unit order does not matter. See the full syntax and accepted
633    ///     abbreviations at:
634    ///     https://docs.rs/parse_duration/latest/parse_duration/#syntax
635    /// override_antenna_mhz : float, optional
636    ///     Override the antenna center frequency inferred from the input data.
637    /// metadata : mapping, optional
638    ///     Additional user metadata to attach independently to each produced output.
639    ///     This should be a JSON-serializable mapping. Root keys are interpreted as
640    ///     strings.
641    ///
642    /// Returns
643    /// -------
644    /// list of str
645    ///     Output dataset paths as strings, in the order produced.
646    ///
647    /// Raises
648    /// ------
649    /// ValueError
650    ///     If incompatible arguments are provided, including:
651    ///
652    ///     - more than one of `steps`, `default`, and `default_with_topo`
653    ///     - `output` is not an existing directory
654    ///     - `track` is provided but is not an existing directory
655    ///     - `render` is provided but is not an existing directory
656    ///     - an invalid `steps` value
657    /// RuntimeError
658    ///     If batch processing fails.
659    ///
660    /// Notes
661    /// -----
662    /// Unlike `process()`, `batch_process()` always targets an existing output
663    /// directory and produces multiple outputs.
664    #[pyfunction]
665    #[allow(clippy::too_many_arguments)]
666    #[pyo3(signature = (
667    inputs,
668    output,
669    *,
670    steps=None,
671    default=false,
672    default_with_topo=false,
673    velocity=0.168,
674    cor=None,
675    dem=None,
676    crs=None,
677    track=None,
678    quiet=false,
679    render=None,
680    no_export=false,
681    merge=None,
682    override_antenna_mhz=None,
683    metadata=None,
684 ))]
685    fn batch_process(
686        py: Python<'_>,
687        inputs: Py<PyAny>,
688        output: Py<PyAny>,
689        steps: Option<Py<PyAny>>,
690        default: bool,
691        default_with_topo: bool,
692        velocity: f32,
693        cor: Option<Py<PyAny>>,
694        dem: Option<Py<PyAny>>,
695        crs: Option<String>,
696        track: Option<Py<PyAny>>,
697        quiet: bool,
698        render: Option<Py<PyAny>>,
699        no_export: bool,
700        merge: Option<String>,
701        override_antenna_mhz: Option<f32>,
702        metadata: Option<Py<PyAny>>,
703    ) -> PyResult<Vec<String>> {
704        use pyo3::exceptions::{PyRuntimeError, PyValueError};
705
706        let input_paths = inputs_to_paths(py, inputs.bind(py))?;
707        let output_dir = fspath(py, output.bind(py))?;
708        if !output_dir.is_dir() {
709            return Err(PyValueError::new_err(format!(
710                "output must be an existing directory in batch_process(): {}",
711                output_dir.display()
712            )));
713        }
714
715        let cor_path = optional_path(py, cor)?;
716        let dem_path = optional_path(py, dem)?;
717        let track_dir = match track {
718            Some(obj) => {
719                let p = fspath(py, obj.bind(py))?;
720                if !p.is_dir() {
721                    return Err(PyValueError::new_err(format!(
722                        "track must be an existing directory in batch_process(): {}",
723                        p.display()
724                    )));
725                }
726                Some(p)
727            }
728            None => None,
729        };
730        let render_dir = match render {
731            Some(obj) => {
732                let p = fspath(py, obj.bind(py))?;
733                if !p.is_dir() {
734                    return Err(PyValueError::new_err(format!(
735                        "render must be an existing directory in batch_process(): {}",
736                        p.display()
737                    )));
738                }
739                Some(p)
740            }
741            None => None,
742        };
743
744        let profile_flags =
745            usize::from(steps.is_some()) + usize::from(default) + usize::from(default_with_topo);
746
747        if profile_flags > 1 {
748            return Err(PyValueError::new_err(
749                "Only one of steps=..., default=True, and default_with_topo=True may be provided",
750            ));
751        }
752        let steps_text = match steps {
753            Some(step_obj) => {
754                let bound = step_obj.bind(py);
755                if let Ok(step_text) = bound.extract::<String>() {
756                    Some(step_text)
757                } else if let Ok(step_list) = bound.cast::<PyList>() {
758                    let parts = step_list
759                        .iter()
760                        .map(|item| item.extract::<String>())
761                        .collect::<PyResult<Vec<String>>>()?;
762                    Some(parts.join(","))
763                } else if let Ok(step_tuple) = bound.cast::<PyTuple>() {
764                    let parts = step_tuple
765                        .iter()
766                        .map(|item| item.extract::<String>())
767                        .collect::<PyResult<Vec<String>>>()?;
768                    Some(parts.join(","))
769                } else {
770                    return Err(PyValueError::new_err(
771                        "steps must be a string or a list/tuple of strings",
772                    ));
773                }
774            }
775            None => None,
776        };
777
778        let resolved_steps = if default_with_topo {
779            let mut profile = gpr::default_processing_profile();
780            profile.push("correct_topography".to_string());
781            profile
782        } else if default {
783            gpr::default_processing_profile()
784        } else if let Some(step_text) = steps_text.as_deref() {
785            crate::tools::parse_step_list(step_text).map_err(PyValueError::new_err)?
786        } else {
787            vec![]
788        };
789
790        gpr::validate_steps(&resolved_steps).map_err(PyValueError::new_err)?;
791        let user_metadata = optional_metadata(py, metadata)?;
792
793        let params = gpr::BatchRunParams {
794            filepaths: input_paths,
795            output_dir,
796            dem_path,
797            cor_path,
798            medium_velocity: velocity,
799            crs,
800            quiet,
801            track_dir,
802            steps: resolved_steps,
803            no_export,
804            render_dir,
805            merge,
806            override_antenna_mhz,
807            user_metadata,
808        };
809
810        let result =
811            gpr::run_batch(params).map_err(|e| PyRuntimeError::new_err(format!("{e:?}")))?;
812
813        Ok(result
814            .output_paths
815            .iter()
816            .map(|p| p.to_string_lossy().to_string())
817            .collect())
818    }
819
820    /// Inspect one or more GPR files and return metadata summaries.
821    ///
822    /// This function reads metadata and summary information without performing a
823    /// full processing workflow.
824    ///
825    /// Parameters
826    /// ----------
827    /// inputs : path-like or sequence of path-like
828    ///     One or more input files to inspect. A single path, list, or tuple of
829    ///     path-like objects is accepted.
830    /// velocity : float, default 0.168
831    ///     Propagation velocity in meters per nanosecond.
832    /// cor : path-like, optional
833    ///     Coordinate file to use instead of any coordinate information implied by
834    ///     the input format.
835    /// dem : path-like, optional
836    ///     Digital elevation model to sample for topographic information.
837    /// crs : str, optional
838    ///     Coordinate reference system for interpreting or transforming coordinates.
839    ///     If omitted, the most appropriate WGS84 UTM zone is used.
840    /// override_antenna_mhz : float, optional
841    ///     Override the antenna center frequency inferred from the input data.
842    ///
843    /// Returns
844    /// -------
845    /// list of dict
846    ///     One metadata summary dictionary per input file.
847    ///
848    /// Raises
849    /// ------
850    /// RuntimeError
851    ///     If inspection fails.
852    ///
853    /// Notes
854    /// -----
855    /// `info()` is intended for lightweight inspection. For loading in-memory data,
856    /// use `read()`. For modifying or exporting processed data, use `process()` or
857    /// `batch_process()`.
858    #[pyfunction]
859    #[allow(clippy::too_many_arguments)]
860    #[pyo3(signature = (
861        inputs,
862        *,
863        velocity=0.168,
864        cor=None,
865        dem=None,
866        crs=None,
867        override_antenna_mhz=None,
868    ))]
869    fn info(
870        py: Python<'_>,
871        inputs: Py<PyAny>,
872        velocity: f32,
873        cor: Option<Py<PyAny>>,
874        dem: Option<Py<PyAny>>,
875        crs: Option<String>,
876        override_antenna_mhz: Option<f32>,
877    ) -> PyResult<Vec<Py<PyAny>>> {
878        let input_paths = inputs_to_paths(py, inputs.bind(py))?;
879        let cor_path = optional_path(py, cor)?;
880        let dem_path = optional_path(py, dem)?;
881
882        let params = gpr::InfoParams {
883            filepaths: input_paths,
884            dem_path,
885            cor_path,
886            medium_velocity: velocity,
887            crs,
888            override_antenna_mhz,
889        };
890        let records =
891            gpr::inspect(params).map_err(|e| PyRuntimeError::new_err(format!("{e:?}")))?;
892
893        records
894            .iter()
895            .map(|r| {
896                let text = serde_json::to_string(r).map_err(|e| {
897                    PyRuntimeError::new_err(format!("Failed to serialize info record to JSON: {e}"))
898                })?;
899                json_to_py(py, &text)
900            })
901            .collect::<PyResult<Vec<Py<PyAny>>>>()
902    }
903
904    /// Removed legacy entry point for the old Python CLI wrapper.
905    ///
906    /// `ridal.run_cli()` is no longer supported. Use `ridal.process()` for
907    /// processing workflows and `ridal.info()` for metadata inspection.
908    ///
909    /// Raises
910    /// ------
911    /// NotImplementedError
912    ///     Always raised.
913    #[pyfunction(signature = (*_args, **_kwargs))]
914    fn run_cli(_args: &Bound<'_, PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> {
915        Err(PyNotImplementedError::new_err(
916            "ridal.run_cli() has been removed. Use ridal.process(...) for processing and ridal.info(...) for metadata inspection.",
917        ))
918    }
919}