1use 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#[derive(Debug, Clone, Default)]
25pub struct ResolvedOpticalBundle {
26 pub sensor_name: String,
28 pub red_path: Option<String>,
30 pub nir_path: Option<String>,
32 pub green_path: Option<String>,
34 pub blue_path: Option<String>,
36 pub qa_scl_path: Option<String>,
38 pub qa_qa60_path: Option<String>,
40 pub acquisition_datetime_utc: Option<String>,
42 pub mean_solar_zenith_deg: Option<f64>,
44 pub mean_solar_azimuth_deg: Option<f64>,
46}
47
48pub trait SensorBundleProvider: Send + Sync {
57 fn sensor_name(&self) -> &'static str;
59
60 fn can_handle(&self, bundle_root: &Path) -> bool;
62
63 fn resolve_optical_bundle(&self, bundle_root: &Path) -> Result<ResolvedOpticalBundle>;
65}
66
67#[derive(Default)]
76pub struct SensorBundleRegistry {
77 providers: Vec<Box<dyn SensorBundleProvider>>,
78}
79
80impl SensorBundleRegistry {
81 pub fn new() -> Self {
83 Self {
84 providers: Vec::new(),
85 }
86 }
87
88 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 pub fn register(&mut self, provider: Box<dyn SensorBundleProvider>) {
99 self.providers.push(provider);
100 }
101
102 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
117pub 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 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
210pub 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
312fn 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
350pub 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#[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 #[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 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}