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}