Skip to main content

wbraster/packages/
optical.rs

1//! Optical sensor-bundle provider abstraction.
2//!
3//! Provides a common trait and registry for resolving raw sensor packages (DIMAP, Landsat,
4//! Sentinel-2 SAFE, …) into a canonical [`ResolvedOpticalBundle`] that upstream crates can
5//! consume without depending on individual sensor-format internals.
6
7use std::path::Path;
8
9use super::dimap_bundle::DimapBundle;
10use super::landsat_bundle::{LandsatBundle, LandsatMission};
11use super::sensor_bundle::{detect_sensor_bundle_family, SensorBundleFamily};
12use super::sentinel2_safe::Sentinel2SafePackage;
13use crate::error::{RasterError, Result};
14
15// ---------------------------------------------------------------------------
16// Canonical output type
17// ---------------------------------------------------------------------------
18
19/// Normalised view of an optical remote-sensing scene bundle.
20///
21/// Paths are stored as `String` so callers do not need to hold a reference to the
22/// originating bundle. All fields are optional to accommodate sensors that omit
23/// particular bands or metadata.
24#[derive(Debug, Clone, Default)]
25pub struct ResolvedOpticalBundle {
26    /// Short sensor family identifier (e.g. `"dimap"`, `"landsat"`, `"sentinel2_safe"`).
27    pub sensor_name: String,
28    /// Absolute path to the red band file, if present.
29    pub red_path: Option<String>,
30    /// Absolute path to the NIR band file, if present.
31    pub nir_path: Option<String>,
32    /// Absolute path to the green band file, if present.
33    pub green_path: Option<String>,
34    /// Absolute path to the blue band file, if present.
35    pub blue_path: Option<String>,
36    /// Absolute path to the Scene Classification Layer (SCL) QA band, if present.
37    pub qa_scl_path: Option<String>,
38    /// Absolute path to the QA60 / QA_PIXEL mask band, if present.
39    pub qa_qa60_path: Option<String>,
40    /// Scene acquisition timestamp in UTC, ISO-8601 format where available.
41    pub acquisition_datetime_utc: Option<String>,
42    /// Mean solar zenith angle in degrees above ground (derived from sun elevation).
43    pub mean_solar_zenith_deg: Option<f64>,
44    /// Mean solar azimuth angle in degrees, clockwise from north.
45    pub mean_solar_azimuth_deg: Option<f64>,
46}
47
48// ---------------------------------------------------------------------------
49// Provider trait
50// ---------------------------------------------------------------------------
51
52/// Resolves a sensor bundle directory into a [`ResolvedOpticalBundle`].
53///
54/// Implementations are registered with a [`SensorBundleRegistry`] so callers
55/// can probe an arbitrary directory without knowing its sensor family in advance.
56pub trait SensorBundleProvider: Send + Sync {
57    /// Short identifier for this sensor family (e.g. `"dimap"`).
58    fn sensor_name(&self) -> &'static str;
59
60    /// Returns `true` when `bundle_root` looks like a bundle this provider can open.
61    fn can_handle(&self, bundle_root: &Path) -> bool;
62
63    /// Resolves the bundle and returns the canonical optical description.
64    fn resolve_optical_bundle(&self, bundle_root: &Path) -> Result<ResolvedOpticalBundle>;
65}
66
67// ---------------------------------------------------------------------------
68// Registry
69// ---------------------------------------------------------------------------
70
71/// Ordered collection of [`SensorBundleProvider`]s.
72///
73/// Call [`SensorBundleRegistry::with_defaults`] to get a registry pre-populated
74/// with DIMAP, Landsat, and Sentinel-2 providers.
75#[derive(Default)]
76pub struct SensorBundleRegistry {
77    providers: Vec<Box<dyn SensorBundleProvider>>,
78}
79
80impl SensorBundleRegistry {
81    /// Creates an empty registry.
82    pub fn new() -> Self {
83        Self {
84            providers: Vec::new(),
85        }
86    }
87
88    /// Creates a registry pre-populated with the built-in optical providers.
89    pub fn with_defaults() -> Self {
90        let mut registry = Self::new();
91        registry.register(Box::new(Sentinel2SafeBundleProvider));
92        registry.register(Box::new(LandsatBundleProvider));
93        registry.register(Box::new(DimapBundleProvider));
94        registry
95    }
96
97    /// Appends a provider to the end of the probe list.
98    pub fn register(&mut self, provider: Box<dyn SensorBundleProvider>) {
99        self.providers.push(provider);
100    }
101
102    /// Probes `bundle_root` against all registered providers and returns the
103    /// first successful resolution, or an error if no provider recognises it.
104    pub fn resolve_optical_bundle(&self, bundle_root: &Path) -> Result<ResolvedOpticalBundle> {
105        for provider in &self.providers {
106            if provider.can_handle(bundle_root) {
107                return provider.resolve_optical_bundle(bundle_root);
108            }
109        }
110        Err(RasterError::Other(format!(
111            "unsupported sensor bundle root: {}",
112            bundle_root.display()
113        )))
114    }
115}
116
117// ---------------------------------------------------------------------------
118// DIMAP provider (SPOT / Pléiades)
119// ---------------------------------------------------------------------------
120
121/// [`SensorBundleProvider`] for SPOT/Pléiades DIMAP optical packages.
122pub struct DimapBundleProvider;
123
124impl SensorBundleProvider for DimapBundleProvider {
125    fn sensor_name(&self) -> &'static str {
126        "dimap"
127    }
128
129    fn can_handle(&self, bundle_root: &Path) -> bool {
130        if !bundle_root.is_dir() {
131            return false;
132        }
133        std::fs::read_dir(bundle_root)
134            .ok()
135            .map(|entries| {
136                entries
137                    .flatten()
138                    .map(|e| e.path())
139                    .filter_map(|p| {
140                        p.file_name()
141                            .map(|n| n.to_string_lossy().to_ascii_uppercase())
142                    })
143                    .any(|name| name.starts_with("DIM_") && name.ends_with(".XML"))
144            })
145            .unwrap_or(false)
146    }
147
148    fn resolve_optical_bundle(&self, bundle_root: &Path) -> Result<ResolvedOpticalBundle> {
149        let family = detect_sensor_bundle_family(bundle_root).map_err(|e| {
150            RasterError::Other(format!(
151                "failed detecting sensor bundle family for '{}': {e}",
152                bundle_root.display()
153            ))
154        })?;
155        if family != SensorBundleFamily::Dimap {
156            return Err(RasterError::Other(format!(
157                "bundle '{}' is not a DIMAP optical package (detected: {:?})",
158                bundle_root.display(),
159                family
160            )));
161        }
162
163        let package = DimapBundle::open(bundle_root).map_err(|e| {
164            RasterError::Other(format!(
165                "failed opening DIMAP bundle '{}': {e}",
166                bundle_root.display()
167            ))
168        })?;
169
170        // DIMAP multispectral order: B0/B1=blue, B2=green, B3=red, B4=nir.
171        let red_path = package
172            .band_path("B3")
173            .map(|p| p.to_string_lossy().to_string());
174        let nir_path = package
175            .band_path("B4")
176            .map(|p| p.to_string_lossy().to_string());
177
178        if red_path.is_none() || nir_path.is_none() {
179            return Err(RasterError::Other(format!(
180                "DIMAP bundle '{}' does not contain required multispectral bands \
181                 (expected red='B3', nir='B4')",
182                bundle_root.display()
183            )));
184        }
185
186        let mean_solar_zenith_deg = package
187            .sun_elevation_deg
188            .map(|e| (90.0 - e).clamp(0.0, 90.0));
189
190        Ok(ResolvedOpticalBundle {
191            sensor_name: self.sensor_name().to_string(),
192            red_path,
193            nir_path,
194            green_path: package
195                .band_path("B2")
196                .map(|p| p.to_string_lossy().to_string()),
197            blue_path: package
198                .band_path("B1")
199                .or_else(|| package.band_path("B0"))
200                .map(|p| p.to_string_lossy().to_string()),
201            qa_scl_path: None,
202            qa_qa60_path: None,
203            acquisition_datetime_utc: package.acquisition_datetime_utc.clone(),
204            mean_solar_zenith_deg,
205            mean_solar_azimuth_deg: package.sun_azimuth_deg,
206        })
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Landsat provider (Landsat 4-9)
212// ---------------------------------------------------------------------------
213
214/// [`SensorBundleProvider`] for Landsat Collection scene bundles (Landsat 4–9).
215pub struct LandsatBundleProvider;
216
217impl SensorBundleProvider for LandsatBundleProvider {
218    fn sensor_name(&self) -> &'static str {
219        "landsat"
220    }
221
222    fn can_handle(&self, bundle_root: &Path) -> bool {
223        if !bundle_root.is_dir() {
224            return false;
225        }
226        std::fs::read_dir(bundle_root)
227            .ok()
228            .map(|entries| {
229                entries
230                    .flatten()
231                    .map(|e| e.path())
232                    .filter_map(|p| {
233                        p.file_name()
234                            .map(|n| n.to_string_lossy().to_ascii_uppercase())
235                    })
236                    .any(|name| name.ends_with("_MTL.TXT"))
237            })
238            .unwrap_or(false)
239    }
240
241    fn resolve_optical_bundle(&self, bundle_root: &Path) -> Result<ResolvedOpticalBundle> {
242        let family = detect_sensor_bundle_family(bundle_root).map_err(|e| {
243            RasterError::Other(format!(
244                "failed detecting sensor bundle family for '{}': {e}",
245                bundle_root.display()
246            ))
247        })?;
248        if family != SensorBundleFamily::Landsat {
249            return Err(RasterError::Other(format!(
250                "bundle '{}' is not a Landsat optical package (detected: {:?})",
251                bundle_root.display(),
252                family
253            )));
254        }
255
256        let package = LandsatBundle::open(bundle_root).map_err(|e| {
257            RasterError::Other(format!(
258                "failed opening Landsat bundle '{}': {e}",
259                bundle_root.display()
260            ))
261        })?;
262
263        let (red_key, nir_key, green_key, blue_key) = mission_band_mapping(&package);
264        let red_path = package
265            .band_path(red_key)
266            .map(|p| p.to_string_lossy().to_string());
267        let nir_path = package
268            .band_path(nir_key)
269            .map(|p| p.to_string_lossy().to_string());
270
271        if red_path.is_none() || nir_path.is_none() {
272            return Err(RasterError::Other(format!(
273                "Landsat bundle '{}' does not contain required optical bands \
274                 (expected red='{}', nir='{}')",
275                bundle_root.display(),
276                red_key,
277                nir_key
278            )));
279        }
280
281        let acquisition_datetime_utc = combine_landsat_datetime(
282            package.acquisition_date_utc.as_deref(),
283            package.scene_center_time_utc.as_deref(),
284        );
285
286        let mean_solar_zenith_deg = package
287            .sun_elevation_deg
288            .map(|e| (90.0 - e).clamp(0.0, 90.0));
289
290        Ok(ResolvedOpticalBundle {
291            sensor_name: self.sensor_name().to_string(),
292            red_path,
293            nir_path,
294            green_path: package
295                .band_path(green_key)
296                .map(|p| p.to_string_lossy().to_string()),
297            blue_path: package
298                .band_path(blue_key)
299                .map(|p| p.to_string_lossy().to_string()),
300            qa_scl_path: None,
301            qa_qa60_path: package
302                .qa_path("QA_PIXEL")
303                .or_else(|| package.qa_path("BQA"))
304                .map(|p| p.to_string_lossy().to_string()),
305            acquisition_datetime_utc,
306            mean_solar_zenith_deg,
307            mean_solar_azimuth_deg: package.sun_azimuth_deg,
308        })
309    }
310}
311
312/// Band-key mapping for Landsat OLI (8/9) vs TM/ETM+ (4/5/7).
313fn mission_band_mapping(
314    bundle: &LandsatBundle,
315) -> (&'static str, &'static str, &'static str, &'static str) {
316    match bundle.mission {
317        LandsatMission::Landsat8 | LandsatMission::Landsat9 => ("B4", "B5", "B3", "B2"),
318        LandsatMission::Landsat4 | LandsatMission::Landsat5 | LandsatMission::Landsat7 => {
319            ("B3", "B4", "B2", "B1")
320        }
321        LandsatMission::Unknown => {
322            if bundle.band_path("B5").is_some() && bundle.band_path("B4").is_some() {
323                ("B4", "B5", "B3", "B2")
324            } else {
325                ("B3", "B4", "B2", "B1")
326            }
327        }
328    }
329}
330
331fn combine_landsat_datetime(
332    date_utc: Option<&str>,
333    scene_time_utc: Option<&str>,
334) -> Option<String> {
335    match (date_utc, scene_time_utc) {
336        (Some(date), Some(time)) => {
337            let t = time.trim();
338            if t.contains('T') {
339                Some(t.to_string())
340            } else {
341                Some(format!("{date}T{t}"))
342            }
343        }
344        (Some(date), None) => Some(format!("{date}T00:00:00Z")),
345        (None, Some(time)) => Some(time.trim().to_string()),
346        (None, None) => None,
347    }
348}
349
350// ---------------------------------------------------------------------------
351// Sentinel-2 SAFE provider
352// ---------------------------------------------------------------------------
353
354/// [`SensorBundleProvider`] for Sentinel-2 Level-1C / Level-2A SAFE packages.
355pub struct Sentinel2SafeBundleProvider;
356
357impl SensorBundleProvider for Sentinel2SafeBundleProvider {
358    fn sensor_name(&self) -> &'static str {
359        "sentinel2_safe"
360    }
361
362    fn can_handle(&self, bundle_root: &Path) -> bool {
363        if !bundle_root.is_dir() {
364            return false;
365        }
366        bundle_root
367            .file_name()
368            .and_then(|n| n.to_str())
369            .map(|n| n.to_ascii_uppercase().ends_with(".SAFE"))
370            .unwrap_or(false)
371    }
372
373    fn resolve_optical_bundle(&self, bundle_root: &Path) -> Result<ResolvedOpticalBundle> {
374        let family = detect_sensor_bundle_family(bundle_root).map_err(|e| {
375            RasterError::Other(format!(
376                "failed detecting sensor bundle family for '{}': {e}",
377                bundle_root.display()
378            ))
379        })?;
380        if family != SensorBundleFamily::Sentinel2Safe {
381            return Err(RasterError::Other(format!(
382                "bundle '{}' is not a Sentinel-2 optical SAFE package (detected: {:?})",
383                bundle_root.display(),
384                family
385            )));
386        }
387
388        let package = Sentinel2SafePackage::open(bundle_root).map_err(|e| {
389            RasterError::Other(format!(
390                "failed opening Sentinel-2 SAFE bundle '{}': {e}",
391                bundle_root.display()
392            ))
393        })?;
394
395        Ok(ResolvedOpticalBundle {
396            sensor_name: self.sensor_name().to_string(),
397            red_path: package
398                .band_path("B04")
399                .map(|p| p.to_string_lossy().to_string()),
400            nir_path: package
401                .band_path("B08")
402                .map(|p| p.to_string_lossy().to_string()),
403            green_path: package
404                .band_path("B03")
405                .map(|p| p.to_string_lossy().to_string()),
406            blue_path: package
407                .band_path("B02")
408                .map(|p| p.to_string_lossy().to_string()),
409            qa_scl_path: package
410                .qa_path("SCL")
411                .map(|p| p.to_string_lossy().to_string()),
412            qa_qa60_path: package
413                .qa_path("QA60")
414                .map(|p| p.to_string_lossy().to_string()),
415            acquisition_datetime_utc: package.acquisition_datetime_utc.clone(),
416            mean_solar_zenith_deg: package.mean_solar_zenith_deg,
417            mean_solar_azimuth_deg: package.mean_solar_azimuth_deg,
418        })
419    }
420}
421
422// ---------------------------------------------------------------------------
423// Tests
424// ---------------------------------------------------------------------------
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use std::env;
430    use std::fs;
431    use std::path::PathBuf;
432    use std::time::{SystemTime, UNIX_EPOCH};
433
434    fn unique_temp_dir(prefix: &str) -> PathBuf {
435        let ts = SystemTime::now()
436            .duration_since(UNIX_EPOCH)
437            .expect("clock")
438            .as_nanos();
439        env::temp_dir().join(format!("{}_{}_{}", prefix, std::process::id(), ts))
440    }
441
442    // --- DIMAP ---
443
444    #[test]
445    fn dimap_resolves_multispectral_core_bands() {
446        let root = unique_temp_dir("wbraster_dimap");
447        fs::create_dir_all(&root).expect("create root");
448        fs::write(
449            root.join("DIM_SPOT7_MS_001.XML"),
450            "<Dimap_Document><MISSION>SPOT</MISSION>\
451             <IMAGING_DATE>2026-04-01</IMAGING_DATE>\
452             <IMAGING_TIME>10:11:12.000</IMAGING_TIME>\
453             <SUN_AZIMUTH>145.0</SUN_AZIMUTH>\
454             <SUN_ELEVATION>41.2</SUN_ELEVATION></Dimap_Document>",
455        )
456        .expect("write xml");
457        fs::write(root.join("IMG_XS1.JP2"), b"").expect("b1");
458        fs::write(root.join("IMG_XS2.JP2"), b"").expect("b2");
459        fs::write(root.join("IMG_XS3.JP2"), b"").expect("b3");
460        fs::write(root.join("IMG_XS4.JP2"), b"").expect("b4");
461
462        let provider = DimapBundleProvider;
463        let resolved = provider
464            .resolve_optical_bundle(&root)
465            .expect("resolve dimap bundle");
466
467        assert_eq!(resolved.sensor_name, "dimap");
468        assert!(resolved
469            .red_path
470            .as_deref()
471            .unwrap_or_default()
472            .contains("XS3"));
473        assert!(resolved
474            .nir_path
475            .as_deref()
476            .unwrap_or_default()
477            .contains("XS4"));
478        assert!(resolved
479            .green_path
480            .as_deref()
481            .unwrap_or_default()
482            .contains("XS2"));
483        assert!(resolved
484            .blue_path
485            .as_deref()
486            .unwrap_or_default()
487            .contains("XS1"));
488        assert_eq!(resolved.mean_solar_azimuth_deg, Some(145.0));
489        assert_eq!(resolved.mean_solar_zenith_deg, Some(48.8));
490
491        let _ = fs::remove_dir_all(&root);
492    }
493
494    // --- Landsat ---
495
496    fn write_landsat_fixture(root: &Path, mission: &str, band_stems: &[&str]) {
497        fs::create_dir_all(root).expect("create fixture root");
498        let mtl = format!(
499            "SPACECRAFT_ID = \"{mission}\"\n\
500             DATE_ACQUIRED = 2024-02-02\n\
501             SCENE_CENTER_TIME = \"16:42:31.1234560Z\"\n\
502             SUN_AZIMUTH = 145.2\n\
503             SUN_ELEVATION = 38.6\n"
504        );
505        fs::write(root.join("SCENE_MTL.txt"), mtl).expect("write mtl");
506        for stem in band_stems {
507            fs::write(root.join(format!("{stem}.TIF")), b"").expect("write band");
508        }
509        fs::write(root.join("SCENE_QA_PIXEL.TIF"), b"").expect("write qa");
510    }
511
512    #[test]
513    fn landsat_maps_oli_band_set() {
514        let root = unique_temp_dir("wbraster_landsat9");
515        write_landsat_fixture(
516            &root,
517            "LANDSAT_9",
518            &["SCENE_SR_B2", "SCENE_SR_B3", "SCENE_SR_B4", "SCENE_SR_B5"],
519        );
520
521        let provider = LandsatBundleProvider;
522        let resolved = provider
523            .resolve_optical_bundle(&root)
524            .expect("resolve landsat9 bundle");
525
526        assert_eq!(resolved.sensor_name, "landsat");
527        assert!(resolved
528            .red_path
529            .as_deref()
530            .unwrap_or_default()
531            .contains("_B4.TIF"));
532        assert!(resolved
533            .nir_path
534            .as_deref()
535            .unwrap_or_default()
536            .contains("_B5.TIF"));
537        assert!(resolved
538            .green_path
539            .as_deref()
540            .unwrap_or_default()
541            .contains("_B3.TIF"));
542        assert!(resolved
543            .blue_path
544            .as_deref()
545            .unwrap_or_default()
546            .contains("_B2.TIF"));
547        assert!(resolved
548            .qa_qa60_path
549            .as_deref()
550            .unwrap_or_default()
551            .contains("QA_PIXEL"));
552        assert_eq!(resolved.mean_solar_azimuth_deg, Some(145.2));
553        assert_eq!(resolved.mean_solar_zenith_deg, Some(51.4));
554        assert_eq!(
555            resolved.acquisition_datetime_utc.as_deref(),
556            Some("2024-02-02T16:42:31.1234560Z")
557        );
558
559        let _ = fs::remove_dir_all(&root);
560    }
561
562    #[test]
563    fn landsat_maps_tm_style_band_set() {
564        let root = unique_temp_dir("wbraster_landsat7");
565        write_landsat_fixture(
566            &root,
567            "LANDSAT_7",
568            &["SCENE_SR_B1", "SCENE_SR_B2", "SCENE_SR_B3", "SCENE_SR_B4"],
569        );
570
571        let provider = LandsatBundleProvider;
572        let resolved = provider
573            .resolve_optical_bundle(&root)
574            .expect("resolve landsat7 bundle");
575
576        assert!(resolved
577            .red_path
578            .as_deref()
579            .unwrap_or_default()
580            .contains("_B3.TIF"));
581        assert!(resolved
582            .nir_path
583            .as_deref()
584            .unwrap_or_default()
585            .contains("_B4.TIF"));
586        assert!(resolved
587            .green_path
588            .as_deref()
589            .unwrap_or_default()
590            .contains("_B2.TIF"));
591        assert!(resolved
592            .blue_path
593            .as_deref()
594            .unwrap_or_default()
595            .contains("_B1.TIF"));
596
597        let _ = fs::remove_dir_all(&root);
598    }
599}