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    /// override_antenna_separation : float, optional
248    ///     Override the antenna separation inferred from the input data.
249    /// metadata : mapping, optional
250    ///     Additional user metadata to attach to the result. This should be a
251    ///     JSON-serializable mapping. Root keys are interpreted as strings.
252    /// return_dataset_format : str, default "xarray_dict"
253    ///     Format used when `return_dataset=True`.
254    ///
255    ///     Supported values currently include:
256    ///
257    ///     - ``"xarray_dict"`` for a plain Python representation that does not
258    ///       require importing `xarray`.
259    ///     - ``"xarray"`` for an `xarray.Dataset`, which requires `xarray` to be
260    ///       installed.
261    ///
262    ///     More return formats may be added in the future.
263    ///
264    /// Returns
265    /// -------
266    /// str or object
267    ///     The output dataset path as a string in normal export mode, or an
268    ///     in-memory dataset object when `return_dataset=True`.
269    ///
270    /// Raises
271    /// ------
272    /// ValueError
273    ///     If incompatible arguments are provided, including:
274    ///
275    ///     - more than one of `steps`, `default`, and `default_with_topo`
276    ///     - `return_dataset=True` together with `output`, `render`, or `track`
277    ///     - an invalid `steps` value
278    /// RuntimeError
279    ///     If processing fails.
280    /// NotImplementedError
281    ///     If `return_dataset_format` is not supported.
282    ///
283    /// Notes
284    /// -----
285    /// `process()` is the main processing entry point and is intended for workflows
286    /// that modify the data. For lightweight loading of raw data without heavy
287    /// processing, use `read()`.
288    #[pyfunction]
289    #[allow(clippy::too_many_arguments)]
290    #[pyo3(signature = (
291        inputs,
292        output=None,
293        *,
294        steps=None,
295        return_dataset=false,
296        default=false,
297        default_with_topo=false,
298        velocity=0.168,
299        cor=None,
300        dem=None,
301        crs=None,
302        track=None,
303        quiet=false,
304        render=None,
305        no_export=false,
306        override_antenna_mhz=None,
307        override_antenna_separation=None,
308        metadata=None,
309        return_dataset_format="xarray_dict".to_string()
310    ))]
311    fn process(
312        py: Python<'_>,
313        inputs: Py<PyAny>,
314        output: Option<Py<PyAny>>,
315        steps: Option<Py<PyAny>>,
316        return_dataset: bool,
317        default: bool,
318        default_with_topo: bool,
319        velocity: f32,
320        cor: Option<Py<PyAny>>,
321        dem: Option<Py<PyAny>>,
322        crs: Option<String>,
323        track: Option<Py<PyAny>>,
324        quiet: bool,
325        render: Option<Py<PyAny>>,
326        no_export: bool,
327        override_antenna_mhz: Option<f32>,
328        override_antenna_separation: Option<f32>,
329        metadata: Option<Py<PyAny>>,
330        return_dataset_format: String,
331    ) -> PyResult<Py<PyAny>> {
332        use pyo3::exceptions::PyValueError;
333
334        if !["xarray", "xarray_dict"]
335            .iter()
336            .any(|s| s == &return_dataset_format)
337        {
338            return Err(PyNotImplementedError::new_err(
339                "Only 'xarray_dict' and 'xarray' return formats are supported for now",
340            ));
341        };
342
343        if return_dataset {
344            if output.is_some() {
345                return Err(PyValueError::new_err(
346                    "return_dataset=True requires output=None",
347                ));
348            }
349            if render.is_some() {
350                return Err(PyValueError::new_err(
351                    "return_dataset=True is incompatible with render=...",
352                ));
353            }
354            if track.is_some() {
355                return Err(PyValueError::new_err(
356                    "return_dataset=True is incompatible with track=...",
357                ));
358            }
359        }
360        let profile_flags =
361            usize::from(steps.is_some()) + usize::from(default) + usize::from(default_with_topo);
362
363        if profile_flags > 1 {
364            return Err(PyValueError::new_err(
365                "Only one of steps=..., default=True, and default_with_topo=True may be provided",
366            ));
367        }
368        let input_paths = inputs_to_paths(py, inputs.bind(py))?;
369        let output_path = optional_path(py, output)?;
370        let cor_path = optional_path(py, cor)?;
371        let dem_path = optional_path(py, dem)?;
372        let track_path = match track {
373            Some(obj) => Some(Some(fspath(py, obj.bind(py))?)),
374            None => None,
375        };
376        let render_path = match render {
377            Some(obj) => Some(Some(fspath(py, obj.bind(py))?)),
378            None => None,
379        };
380
381        let steps_text = match steps {
382            Some(step_obj) => {
383                let bound = step_obj.bind(py);
384                if let Ok(step_text) = bound.extract::<String>() {
385                    Some(step_text)
386                } else if let Ok(step_list) = bound.cast::<PyList>() {
387                    let parts = step_list
388                        .iter()
389                        .map(|item| item.extract::<String>())
390                        .collect::<PyResult<Vec<String>>>()?;
391                    Some(parts.join(","))
392                } else if let Ok(step_tuple) = bound.cast::<PyTuple>() {
393                    let parts = step_tuple
394                        .iter()
395                        .map(|item| item.extract::<String>())
396                        .collect::<PyResult<Vec<String>>>()?;
397                    Some(parts.join(","))
398                } else {
399                    return Err(PyValueError::new_err(
400                        "steps must be a string or a list/tuple of strings",
401                    ));
402                }
403            }
404            None => None,
405        };
406
407        let resolved_steps = if default_with_topo {
408            let mut profile = gpr::default_processing_profile();
409            profile.push("correct_topography".to_string());
410            profile
411        } else if default {
412            gpr::default_processing_profile()
413        } else if let Some(step_text) = steps_text.as_deref() {
414            crate::tools::parse_step_list(step_text).map_err(PyValueError::new_err)?
415        } else {
416            vec![]
417        };
418
419        gpr::validate_steps(&resolved_steps).map_err(PyValueError::new_err)?;
420
421        let user_metadata = optional_metadata(py, metadata)?;
422
423        if return_dataset {
424            // Build but do not export
425            let params2 = gpr::RunParams {
426                filepaths: input_paths,
427                output_path: None,
428                dem_path,
429                cor_path,
430                medium_velocity: velocity,
431                crs,
432                quiet,
433                track_path: None,
434                steps: resolved_steps,
435                no_export: true,
436                render_path: None,
437                override_antenna_mhz,
438                override_antenna_separation,
439                user_metadata,
440            };
441            let (gpr_obj, _default_path) = gpr::build_processed_gpr(params2)
442                .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e:?}")))?;
443            let ds = gpr_obj
444                .export_dataset()
445                .map_err(pyo3::exceptions::PyRuntimeError::new_err)?;
446            let ds_py = ds.to_python(py);
447            if return_dataset_format == "xarray_dict" {
448                return ds_py;
449            } else if return_dataset_format == "xarray" {
450                return xarray_dict_to_ds(py, ds_py?);
451            } else {
452                unreachable!()
453            }
454        }
455
456        // file/export mode (unchanged)
457        let params = gpr::RunParams {
458            filepaths: input_paths,
459            output_path,
460            dem_path,
461            cor_path,
462            medium_velocity: velocity,
463            crs,
464            quiet,
465            track_path,
466            steps: resolved_steps,
467            no_export,
468            render_path,
469            override_antenna_mhz,
470            override_antenna_separation,
471            user_metadata,
472        };
473        let result = gpr::run(params)
474            .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{e:?}")))?;
475        // Ok(result.output_path.to_string_lossy().to_string())
476        Ok(py
477            .eval(c_str!("str"), None, None)?
478            .call1((result.output_path.to_string_lossy().to_string(),))?
479            .unbind())
480    }
481    /// Read one or more GPR files into memory without applying a processing workflow.
482    ///
483    /// Use this function to load radar data in a lightweight form for inspection,
484    /// exploration, or downstream processing in Python. Unlike `process()`,
485    /// `read()` is intended to return the data essentially as read from disk rather
486    /// than applying a full processing workflow.
487    ///
488    /// Parameters
489    /// ----------
490    /// inputs : path-like or sequence of path-like
491    ///     One or more input files to read. A single path, list, or tuple of
492    ///     path-like objects is accepted.
493    /// velocity : float, default 0.168
494    ///     Propagation velocity in meters per nanosecond.
495    /// cor : path-like, optional
496    ///     Coordinate file to use instead of any coordinate information implied by
497    ///     the input format.
498    /// dem : path-like, optional
499    ///     Digital elevation model to sample for topographic information.
500    /// crs : str, optional
501    ///     Coordinate reference system for interpreting or transforming coordinates.
502    ///     If omitted, the most appropriate WGS84 UTM zone is used.
503    /// override_antenna_mhz : float, optional
504    ///     Override the antenna center frequency inferred from the input data.
505    /// metadata : mapping, optional
506    ///     Additional user metadata to attach to the returned dataset. This should
507    ///     be a JSON-serializable mapping. Root keys are interpreted as strings.
508    /// return_dataset_format : str, default "xarray_dict"
509    ///     Format of the returned in-memory dataset.
510    ///
511    ///     Supported values currently include:
512    ///
513    ///     - ``"xarray_dict"`` for a plain Python representation that does not
514    ///       require importing `xarray`.
515    ///     - ``"xarray"`` for an `xarray.Dataset`, which requires `xarray` to be
516    ///       installed.
517    ///
518    ///     More return formats may be added in the future.
519    ///
520    /// Returns
521    /// -------
522    /// object
523    ///     An in-memory dataset representation of the input data.
524    ///
525    /// Raises
526    /// ------
527    /// RuntimeError
528    ///     If reading fails.
529    /// NotImplementedError
530    ///     If `return_dataset_format` is not supported.
531    ///
532    /// Notes
533    /// -----
534    /// `read()` is intended as a lightweight loader. If you want to apply filtering,
535    /// corrections, or export a processed dataset, use `process()` instead.
536    #[pyfunction]
537    #[allow(clippy::too_many_arguments)]
538    #[pyo3(signature = (
539        inputs,
540        *,
541        velocity=0.168,
542        cor=None,
543        dem=None,
544        crs=None,
545        override_antenna_mhz=None,
546        override_antenna_separation=None,
547        metadata=None,
548        return_dataset_format="xarray_dict".to_string()
549    ))]
550    fn read(
551        py: Python<'_>,
552        inputs: Py<PyAny>,
553        velocity: f32,
554        cor: Option<Py<PyAny>>,
555        dem: Option<Py<PyAny>>,
556        crs: Option<String>,
557        override_antenna_mhz: Option<f32>,
558        override_antenna_separation: Option<f32>,
559        metadata: Option<Py<PyAny>>,
560        return_dataset_format: String,
561    ) -> PyResult<Py<PyAny>> {
562        process(
563            py,
564            inputs,
565            None,
566            None,
567            true,
568            false,
569            false,
570            velocity,
571            cor,
572            dem,
573            crs,
574            None,
575            true,
576            None,
577            false,
578            override_antenna_mhz,
579            override_antenna_separation,
580            metadata,
581            return_dataset_format,
582        )
583    }
584    /// Batch-process one or more GPR files into multiple outputs.
585    ///
586    /// Use this function when many input files should be processed in one call and
587    /// written as separate outputs in an existing output directory.
588    ///
589    /// Parameters
590    /// ----------
591    /// inputs : path-like or sequence of path-like
592    ///     One or more input files to process. A single path, list, or tuple of
593    ///     path-like objects is accepted.
594    /// output : path-like
595    ///     Existing output directory where processed datasets will be written.
596    /// steps : str or sequence of str, optional
597    ///     Processing steps to apply. This may be given either as a comma-separated
598    ///     string or as a sequence of step names.
599    ///
600    ///     Available steps are exposed as `ridal.all_steps`, and descriptions are
601    ///     available in `ridal.all_step_descriptions`.
602    ///
603    ///     Exactly one of `steps`, `default`, and `default_with_topo` may be given.
604    /// default : bool, default False
605    ///     Use the default processing profile.
606    ///
607    ///     Exactly one of `steps`, `default`, and `default_with_topo` may be given.
608    /// default_with_topo : bool, default False
609    ///     Use the default processing profile and include topographic correction.
610    ///
611    ///     Exactly one of `steps`, `default`, and `default_with_topo` may be given.
612    /// velocity : float, default 0.168
613    ///     Propagation velocity in meters per nanosecond.
614    /// cor : path-like, optional
615    ///     Coordinate file to use instead of any coordinate information implied by
616    ///     the input format.
617    /// dem : path-like, optional
618    ///     Digital elevation model to sample for topographic information.
619    /// crs : str, optional
620    ///     Coordinate reference system for interpreting or transforming coordinates.
621    ///     If omitted, the most appropriate WGS84 UTM zone is used.
622    /// track : path-like, optional
623    ///     Existing directory where exported track files should be written.
624    /// quiet : bool, default False
625    ///     Reduce logging and progress output.
626    /// render : path-like, optional
627    ///     Existing directory where rendered figures should be written.
628    /// no_export : bool, default False
629    ///     Run processing without writing the main dataset outputs. Side outputs
630    ///     such as rendered figures or exported tracks may still be produced.
631    /// merge : str, optional
632    ///     Merge chronologically neighboring profiles when they are close enough in
633    ///     time and otherwise compatible.
634    ///
635    ///     For example, ``"10 min"`` will merge neighboring profiles separated by
636    ///     less than ten minutes.
637    ///
638    ///     The value is parsed using the `parse_duration` syntax. Briefly, it
639    ///     accepts sequences of ``[value] [unit]`` pairs such as
640    ///     ``"15 days 20 seconds 100 milliseconds"``; spaces are optional, and
641    ///     unit order does not matter. See the full syntax and accepted
642    ///     abbreviations at:
643    ///     https://docs.rs/parse_duration/latest/parse_duration/#syntax
644    /// override_antenna_mhz : float, optional
645    ///     Override the antenna center frequency inferred from the input data.
646    /// metadata : mapping, optional
647    ///     Additional user metadata to attach independently to each produced output.
648    ///     This should be a JSON-serializable mapping. Root keys are interpreted as
649    ///     strings.
650    ///
651    /// Returns
652    /// -------
653    /// list of str
654    ///     Output dataset paths as strings, in the order produced.
655    ///
656    /// Raises
657    /// ------
658    /// ValueError
659    ///     If incompatible arguments are provided, including:
660    ///
661    ///     - more than one of `steps`, `default`, and `default_with_topo`
662    ///     - `output` is not an existing directory
663    ///     - `track` is provided but is not an existing directory
664    ///     - `render` is provided but is not an existing directory
665    ///     - an invalid `steps` value
666    /// RuntimeError
667    ///     If batch processing fails.
668    ///
669    /// Notes
670    /// -----
671    /// Unlike `process()`, `batch_process()` always targets an existing output
672    /// directory and produces multiple outputs.
673    #[pyfunction]
674    #[allow(clippy::too_many_arguments)]
675    #[pyo3(signature = (
676    inputs,
677    output,
678    *,
679    steps=None,
680    default=false,
681    default_with_topo=false,
682    velocity=0.168,
683    cor=None,
684    dem=None,
685    crs=None,
686    track=None,
687    quiet=false,
688    render=None,
689        no_export=false,
690        merge=None,
691        override_antenna_mhz=None,
692        override_antenna_separation=None,
693        metadata=None,
694 ))]
695    fn batch_process(
696        py: Python<'_>,
697        inputs: Py<PyAny>,
698        output: Py<PyAny>,
699        steps: Option<Py<PyAny>>,
700        default: bool,
701        default_with_topo: bool,
702        velocity: f32,
703        cor: Option<Py<PyAny>>,
704        dem: Option<Py<PyAny>>,
705        crs: Option<String>,
706        track: Option<Py<PyAny>>,
707        quiet: bool,
708        render: Option<Py<PyAny>>,
709        no_export: bool,
710        merge: Option<String>,
711        override_antenna_mhz: Option<f32>,
712        override_antenna_separation: Option<f32>,
713        metadata: Option<Py<PyAny>>,
714    ) -> PyResult<Vec<String>> {
715        use pyo3::exceptions::{PyRuntimeError, PyValueError};
716
717        let input_paths = inputs_to_paths(py, inputs.bind(py))?;
718        let output_dir = fspath(py, output.bind(py))?;
719        if !output_dir.is_dir() {
720            return Err(PyValueError::new_err(format!(
721                "output must be an existing directory in batch_process(): {}",
722                output_dir.display()
723            )));
724        }
725
726        let cor_path = optional_path(py, cor)?;
727        let dem_path = optional_path(py, dem)?;
728        let track_dir = match track {
729            Some(obj) => {
730                let p = fspath(py, obj.bind(py))?;
731                if !p.is_dir() {
732                    return Err(PyValueError::new_err(format!(
733                        "track must be an existing directory in batch_process(): {}",
734                        p.display()
735                    )));
736                }
737                Some(p)
738            }
739            None => None,
740        };
741        let render_dir = match render {
742            Some(obj) => {
743                let p = fspath(py, obj.bind(py))?;
744                if !p.is_dir() {
745                    return Err(PyValueError::new_err(format!(
746                        "render must be an existing directory in batch_process(): {}",
747                        p.display()
748                    )));
749                }
750                Some(p)
751            }
752            None => None,
753        };
754
755        let profile_flags =
756            usize::from(steps.is_some()) + usize::from(default) + usize::from(default_with_topo);
757
758        if profile_flags > 1 {
759            return Err(PyValueError::new_err(
760                "Only one of steps=..., default=True, and default_with_topo=True may be provided",
761            ));
762        }
763        let steps_text = match steps {
764            Some(step_obj) => {
765                let bound = step_obj.bind(py);
766                if let Ok(step_text) = bound.extract::<String>() {
767                    Some(step_text)
768                } else if let Ok(step_list) = bound.cast::<PyList>() {
769                    let parts = step_list
770                        .iter()
771                        .map(|item| item.extract::<String>())
772                        .collect::<PyResult<Vec<String>>>()?;
773                    Some(parts.join(","))
774                } else if let Ok(step_tuple) = bound.cast::<PyTuple>() {
775                    let parts = step_tuple
776                        .iter()
777                        .map(|item| item.extract::<String>())
778                        .collect::<PyResult<Vec<String>>>()?;
779                    Some(parts.join(","))
780                } else {
781                    return Err(PyValueError::new_err(
782                        "steps must be a string or a list/tuple of strings",
783                    ));
784                }
785            }
786            None => None,
787        };
788
789        let resolved_steps = if default_with_topo {
790            let mut profile = gpr::default_processing_profile();
791            profile.push("correct_topography".to_string());
792            profile
793        } else if default {
794            gpr::default_processing_profile()
795        } else if let Some(step_text) = steps_text.as_deref() {
796            crate::tools::parse_step_list(step_text).map_err(PyValueError::new_err)?
797        } else {
798            vec![]
799        };
800
801        gpr::validate_steps(&resolved_steps).map_err(PyValueError::new_err)?;
802        let user_metadata = optional_metadata(py, metadata)?;
803
804        let params = gpr::BatchRunParams {
805            filepaths: input_paths,
806            output_dir,
807            dem_path,
808            cor_path,
809            medium_velocity: velocity,
810            crs,
811            quiet,
812            track_dir,
813            steps: resolved_steps,
814            no_export,
815            render_dir,
816            merge,
817            override_antenna_mhz,
818            override_antenna_separation,
819            user_metadata,
820        };
821
822        let result =
823            gpr::run_batch(params).map_err(|e| PyRuntimeError::new_err(format!("{e:?}")))?;
824
825        Ok(result
826            .output_paths
827            .iter()
828            .map(|p| p.to_string_lossy().to_string())
829            .collect())
830    }
831
832    /// Inspect one or more GPR files and return metadata summaries.
833    ///
834    /// This function reads metadata and summary information without performing a
835    /// full processing workflow.
836    ///
837    /// Parameters
838    /// ----------
839    /// inputs : path-like or sequence of path-like
840    ///     One or more input files to inspect. A single path, list, or tuple of
841    ///     path-like objects is accepted.
842    /// velocity : float, default 0.168
843    ///     Propagation velocity in meters per nanosecond.
844    /// cor : path-like, optional
845    ///     Coordinate file to use instead of any coordinate information implied by
846    ///     the input format.
847    /// dem : path-like, optional
848    ///     Digital elevation model to sample for topographic information.
849    /// crs : str, optional
850    ///     Coordinate reference system for interpreting or transforming coordinates.
851    ///     If omitted, the most appropriate WGS84 UTM zone is used.
852    /// override_antenna_mhz : float, optional
853    ///     Override the antenna center frequency inferred from the input data.
854    ///
855    /// Returns
856    /// -------
857    /// list of dict
858    ///     One metadata summary dictionary per input file.
859    ///
860    /// Raises
861    /// ------
862    /// RuntimeError
863    ///     If inspection fails.
864    ///
865    /// Notes
866    /// -----
867    /// `info()` is intended for lightweight inspection. For loading in-memory data,
868    /// use `read()`. For modifying or exporting processed data, use `process()` or
869    /// `batch_process()`.
870    #[pyfunction]
871    #[allow(clippy::too_many_arguments)]
872    #[pyo3(signature = (
873        inputs,
874        *,
875        velocity=0.168,
876        cor=None,
877        dem=None,
878        crs=None,
879        override_antenna_mhz=None,
880        override_antenna_separation=None,
881    ))]
882    fn info(
883        py: Python<'_>,
884        inputs: Py<PyAny>,
885        velocity: f32,
886        cor: Option<Py<PyAny>>,
887        dem: Option<Py<PyAny>>,
888        crs: Option<String>,
889        override_antenna_mhz: Option<f32>,
890        override_antenna_separation: Option<f32>,
891    ) -> PyResult<Vec<Py<PyAny>>> {
892        let input_paths = inputs_to_paths(py, inputs.bind(py))?;
893        let cor_path = optional_path(py, cor)?;
894        let dem_path = optional_path(py, dem)?;
895
896        let params = gpr::InfoParams {
897            filepaths: input_paths,
898            dem_path,
899            cor_path,
900            medium_velocity: velocity,
901            crs,
902            override_antenna_mhz,
903            override_antenna_separation,
904        };
905        let records =
906            gpr::inspect(params).map_err(|e| PyRuntimeError::new_err(format!("{e:?}")))?;
907
908        records
909            .iter()
910            .map(|r| {
911                let text = serde_json::to_string(r).map_err(|e| {
912                    PyRuntimeError::new_err(format!("Failed to serialize info record to JSON: {e}"))
913                })?;
914                json_to_py(py, &text)
915            })
916            .collect::<PyResult<Vec<Py<PyAny>>>>()
917    }
918
919    /// Removed legacy entry point for the old Python CLI wrapper.
920    ///
921    /// `ridal.run_cli()` is no longer supported. Use `ridal.process()` for
922    /// processing workflows and `ridal.info()` for metadata inspection.
923    ///
924    /// Raises
925    /// ------
926    /// NotImplementedError
927    ///     Always raised.
928    #[pyfunction(signature = (*_args, **_kwargs))]
929    fn run_cli(_args: &Bound<'_, PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> {
930        Err(PyNotImplementedError::new_err(
931            "ridal.run_cli() has been removed. Use ridal.process(...) for processing and ridal.info(...) for metadata inspection.",
932        ))
933    }
934}