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}