• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geo-engine / geoengine / 30895366993

04 Aug 2026 09:13AM UTC coverage: 87.262% (-0.3%) from 87.594%
30895366993

Pull #1224

github

web-flow
Merge a0ff5e986 into 5748ef766
Pull Request #1224: feat: unify stac harvester and stac provider functionality

1530 of 2585 new or added lines in 15 files covered. (59.19%)

11 existing lines in 6 files now uncovered.

126524 of 144993 relevant lines covered (87.26%)

479495.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.71
/geoengine/operators/src/source/gdal_worker_process/process_impl.rs
1
use super::{
2
    GdalDatasetGeoTransform, GdalDatasetParameters, GdalMetadataMapping,
3
    process_common::{
4
        GdalDataGridVariant, GdalIpcPayload, GdalReadAdvise, GdalReadWindow, IpcProcessError,
5
    },
6
};
7
use crate::{
8
    source::gdal_worker_process::process_common::IpcProcessGdalErrorKind,
9
    util::{GdalConfigOptions, gdal::gdal_open_ex_gdal_error, retry::retry_sync},
10
};
11
use float_cmp::approx_eq;
12
use gdal::{
13
    Dataset as GdalDataset, DatasetOptions, GdalOpenFlags, Metadata as GdalMetadata,
14
    errors::GdalError,
15
    raster::{GdalType, RasterBand as GdalRasterBand},
16
};
17
use gdal_sys::VSICurlPartialClearCache;
18
use geoengine_datatypes::{
19
    primitives::Coordinate2D,
20
    raster::{GridSize, Pixel, RasterProperties, RasterPropertiesEntry, RasterPropertiesEntryType},
21
};
22
use ipc_channel::ipc::{self, IpcReceiver, IpcSender};
23
use num::FromPrimitive;
24
use serde::{Deserialize, Serialize};
25
use std::{
26
    env,
27
    ffi::CString,
28
    path::{Path, PathBuf},
29
    process::Command,
30
    sync::OnceLock,
31
    time::Instant,
32
};
33
use tracing::debug;
34

35
/// Global storage for the detected path, initialized on first access.
36
static GDALSOURCE_PROCESS_PATH: OnceLock<PathBuf> = OnceLock::new();
37

38
static GDAL_RETRY_INITIAL_BACKOFF_MS: u64 = 1000;
39
static GDAL_RETRY_MAX_BACKOFF_MS: u64 = 60 * 60 * 1000;
40
static GDAL_RETRY_EXPONENTIAL_BACKOFF_FACTOR: f64 = 2.;
41

42
/// Returns a reference to the cached `gdalsource-process` path.
43
/// The detection logic runs exactly once on the very first call.
44
fn get_gdalsource_path() -> &'static Path {
1,073✔
45
    GDALSOURCE_PROCESS_PATH.get_or_init(|| {
1,073✔
46
        // 1. Try the environment variable path first
47
        if let Ok(env_path) = env::var("GDALSOURCE_PROCESS_PATH")
2✔
48
            && !env_path.is_empty()
×
49
        {
50
            let path = PathBuf::from(env_path);
×
51
            if path.is_file() {
×
52
                tracing::debug!(
×
53
                    "Using gdalsource-process path from environment variable: {}",
54
                    path.display()
×
55
                );
56
                return path;
×
57
            }
×
58
        }
2✔
59

60
        // 2. Fallback detection logic
61
        let mut exe_path = env::current_exe()
2✔
62
            .unwrap_or_else(|_| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
2✔
63

64
        if exe_path.is_file() {
2✔
65
            exe_path.pop();
2✔
66
        }
2✔
67

68
        if exe_path.file_name().is_some_and(|name| name == "deps") {
2✔
69
            exe_path.pop();
2✔
70
        }
2✔
71

72
        let binary_name = format!("gdalsource-process{}", std::env::consts::EXE_SUFFIX);
2✔
73
        exe_path.push(binary_name);
2✔
74

75
        tracing::debug!("Detected gdalsource-process path: {}", exe_path.display());
2✔
76

77
        exe_path
2✔
78
    })
2✔
79
}
1,073✔
80

81
pub struct ChildProcessGuard {
82
    child: std::process::Child,
83
}
84

85
#[cfg(test)]
86
impl ChildProcessGuard {
87
    /// Wraps an already-running child in a guard. For unit tests only.
88
    pub(crate) fn from_child(child: std::process::Child) -> Self {
6✔
89
        Self { child }
6✔
90
    }
6✔
91
}
92

93
impl Drop for ChildProcessGuard {
94
    fn drop(&mut self) {
1,079✔
95
        // Forcefully kill the process when the guard is dropped
96
        let _ = self.child.kill();
1,079✔
97
        let _ = self.child.wait(); // Prevent zombie processes
1,079✔
98
    }
1,079✔
99
}
100

101
/// Configuration passed from the main process to each GDAL worker at spawn time via a JSON CLI argument.
102
/// The worker binary deserializes this instead of reading config files from disk.
103
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
104
pub struct WorkerConfig {
105
    #[serde(default)]
106
    pub gdal_config_options: Option<Vec<(String, String)>>,
107
    #[serde(default)]
108
    pub logging: WorkerLoggingConfig,
109
    #[serde(default)]
110
    pub open_telemetry: OpenTelemetryConfig,
111
}
112

113
#[derive(Debug, Clone, Serialize, Deserialize)]
114
pub struct WorkerLoggingConfig {
115
    pub log_spec: String,
116
    #[serde(default)]
117
    pub log_to_file: bool,
118
    #[serde(default = "default_worker_log_prefix")]
119
    pub filename_prefix: String,
120
    pub log_directory: Option<String>,
121
    /// Set by the pool at spawn time. Used in log filenames to separate per-worker output.
122
    pub worker_id: usize,
123
}
124

125
fn default_worker_log_prefix() -> String {
339✔
126
    "geo_engine_worker".to_string()
339✔
127
}
339✔
128

129
impl Default for WorkerLoggingConfig {
130
    fn default() -> Self {
339✔
131
        Self {
339✔
132
            log_spec: "info".to_string(),
339✔
133
            log_to_file: false,
339✔
134
            filename_prefix: default_worker_log_prefix(),
339✔
135
            log_directory: None,
339✔
136
            worker_id: 0,
339✔
137
        }
339✔
138
    }
339✔
139
}
140

141
#[derive(Debug, Clone, Serialize, Deserialize)]
142
pub struct OpenTelemetryConfig {
143
    pub enabled: bool,
144
    pub endpoint: String,
145
}
146

147
impl Default for OpenTelemetryConfig {
148
    fn default() -> Self {
339✔
149
        Self {
339✔
150
            enabled: false,
339✔
151
            endpoint: "http://127.0.0.1:4317".to_string(),
339✔
152
        }
339✔
153
    }
339✔
154
}
155

156
/// Spawns the IPC server process and establishes the communication channels.
157
///
158
/// Returns a guard for the child process (which will automatically clean up on drop),
159
/// as well as the sender and receiver for communication with the server.
160
/// Assumes that the server executable is located at the path specified by the `GDAL_SOURCE_PROCESS_PATH` environment variable,
161
/// or defaults to a sibling executable named `gdalsource-process` in the same directory as the current executable if the environment variable is not set.
162
///
163
/// # Errors
164
/// Returns an `IpcProcessError` if the server process fails to start, or if the IPC channels fail to establish communication.
165
///
166
/// # Panics
167
/// Panics if the current executable path cannot be determined, or if the executable has no parent directory, which should not happen under normal circumstances.
168
pub fn spawn_ipc_server_process<S, R>(
1,073✔
169
    worker_config: &WorkerConfig,
1,073✔
170
) -> Result<(ChildProcessGuard, IpcSender<S>, IpcReceiver<R>), IpcProcessError> {
1,073✔
171
    let (server, token) = ipc::IpcOneShotServer::<(IpcSender<S>, IpcReceiver<R>)>::new()
1,073✔
172
        .map_err(IpcProcessError::from)?;
1,073✔
173

174
    tracing::debug!("spawn_ipc_server token: {}", token);
1,073✔
175

176
    let exe_path = get_gdalsource_path();
1,073✔
177

178
    let config_json =
1,073✔
179
        serde_json::to_string(worker_config).expect("WorkerConfig should always serialize");
1,073✔
180

181
    let mut cmd = Command::new(exe_path);
1,073✔
182

183
    cmd.arg(token)
1,073✔
184
        .arg(&config_json)
1,073✔
185
        .stderr(std::process::Stdio::inherit());
1,073✔
186

187
    if std::cfg!(debug_assertions) {
1,073✔
188
        // llcov inserts these env params. We need to remove them from the gdal-processor processes. Otherwise the processes overwrite the main process data and the files become corrupt.
1,073✔
189
        cmd.env_remove("LLVM_PROFILE_FILE")
1,073✔
190
            .env_remove("LLVM_PROFILE_FILE_NAME");
1,073✔
191
    }
1,073✔
192

193
    let child = cmd.spawn().map_err(IpcProcessError::from)?;
1,073✔
194

195
    let (_rx, channels) = server.accept().map_err(IpcProcessError::from)?;
1,073✔
196
    Ok((ChildProcessGuard { child }, channels.0, channels.1))
1,073✔
197
}
1,073✔
198

199
/// Creates channels and connects to the IPC server with the given token,
200
/// sending the channels to the server, so that communication can be established.
201
///
202
/// Assumes that the server is already running and listening for connections.
203
pub fn setup_client<S, C>(
×
204
    token: String,
×
205
) -> crate::util::Result<(IpcSender<C>, IpcReceiver<S>), IpcProcessError>
×
206
where
×
207
    S: for<'de> serde::Deserialize<'de> + serde::Serialize,
×
208
    C: for<'de> serde::Deserialize<'de> + serde::Serialize,
×
209
{
210
    let (server_sender, client_reciever) = ipc::channel::<S>()?;
×
211
    let (client_sender, server_reciever) = ipc::channel::<C>()?;
×
212

213
    let sender = ipc::IpcSender::<(IpcSender<S>, IpcReceiver<C>)>::connect(token)?;
×
214

215
    sender.send((server_sender, server_reciever))?;
×
216
    Ok((client_sender, client_reciever))
×
217
}
×
218

219
/// A simple, single-entry cache for an open GDAL dataset tile.
220
#[derive(Default)]
221
pub struct GdalDatasetHolder {
222
    params: Option<GdalDatasetParameters>,
223
    dataset: Option<GdalDataset>,
224
    thread_local: Option<GdalConfigOptions>,
225
}
226

227
struct PrevDatasetOpt {
228
    _prev_dataset: Option<GdalDataset>,
229
}
230

231
impl GdalDatasetHolder {
232
    pub fn new() -> Self {
6✔
233
        Self {
6✔
234
            params: None,
6✔
235
            dataset: None,
6✔
236
            thread_local: None,
6✔
237
        }
6✔
238
    }
6✔
239

240
    fn open_ex(
8✔
241
        dataset_params: &GdalDatasetParameters,
8✔
242
    ) -> Result<(GdalDataset, Option<GdalConfigOptions>), GdalError> {
8✔
243
        let options = dataset_params
8✔
244
            .gdal_open_options
8✔
245
            .as_ref()
8✔
246
            .map(|o| o.iter().map(String::as_str).collect::<Vec<_>>());
8✔
247

248
        // reverts the process-global configs on drop
249
        let thread_local_configs: Option<GdalConfigOptions> = dataset_params
8✔
250
            .gdal_config_options_for_request()
8✔
251
            .as_ref()
8✔
252
            .map(|config_options| GdalConfigOptions::new(config_options))
8✔
253
            .transpose()
8✔
254
            .expect("GdalConfigOptions must not fail");
8✔
255

256
        let ds = gdal_open_ex_gdal_error(
8✔
257
            &dataset_params.file_path_for_open(),
8✔
258
            DatasetOptions {
8✔
259
                open_flags: GdalOpenFlags::GDAL_OF_RASTER,
8✔
260
                open_options: options.as_deref(),
8✔
261
                ..DatasetOptions::default()
8✔
262
            },
8✔
263
        )?;
3✔
264

265
        Ok((ds, thread_local_configs))
5✔
266
    }
8✔
267

268
    pub fn get(&mut self, params: &GdalDatasetParameters) -> Option<&mut GdalDataset> {
×
269
        if Self::is_hit(self.params.as_ref(), params) {
×
270
            self.dataset.as_mut()
×
271
        } else {
272
            None
×
273
        }
274
    }
×
275

276
    /// Retrieves the open dataset if the parameters match, otherwise opens a new dataset with the given parameters, updates the stored dataset, and returns it.
277
    /// The current open dataset is kept alive until the new one is successfully opened to maintain GDAL's internal caching benefits.
278
    /// This method ensures that only one dataset is open at a time, and that the state is updated atomically to prevent stale data.
279
    /// The caller is responsible for ensuring that the returned dataset is not used concurrently across threads, as GDAL datasets are generally not thread-safe.
280
    /// # Errors
281
    /// Returns a `GdalError` if opening the new dataset fails. In this case, the stored dataset remains unchanged and the previous dataset (if any) is still valid.
282
    ///
283
    /// # Panics
284
    /// Panics if the dataset is unexpectedly missing after a it was detected as open, which should never happen.
285
    /// Panics if the configuration options fail to apply, which should also not happen under normal circumstances.
286
    /// Panics if the caller attempts to use the returned dataset concurrently across threads, which is not allowed due to GDAL's thread safety constraints.
287
    pub fn get_or_open(
8✔
288
        &mut self,
8✔
289
        params: &GdalDatasetParameters,
8✔
290
    ) -> Result<&mut GdalDataset, GdalError> {
8✔
291
        let hit = Self::is_hit(self.params.as_ref(), params);
8✔
292

293
        if hit {
8✔
294
            let _span = tracing::debug_span!("gdal_dataset_cache_hit").entered();
×
295
            Ok(self.dataset.as_mut().expect("Dataset must be set"))
×
296
        } else {
297
            let _prev_dataset = self.clear(); // keep old dataset alive untill new one to keep Gdal internal cache alive
8✔
298

299
            let _span = tracing::debug_span!(
8✔
300
                "gdal_dataset_open",
301
                path = %params.file_path.display(),
8✔
302
                band = params.rasterband_channel,
303
            )
304
            .entered();
8✔
305

306
            let (ds, tlo) = Self::open_ex(params)?;
8✔
307

308
            self.params = Some(params.clone());
5✔
309
            self.dataset = Some(ds);
5✔
310
            self.thread_local = tlo;
5✔
311

312
            Ok(self.dataset.as_mut().expect("Dataset must be set"))
5✔
313
        }
314
    }
8✔
315

316
    fn is_hit(params: Option<&GdalDatasetParameters>, other: &GdalDatasetParameters) -> bool {
8✔
317
        // TODO: we could optimize this by hashing the parameters and comparing the hash for a quick check before doing the full equality check, if it turns out to be a bottleneck.
318
        if let Some(current_ds_params) = params {
8✔
319
            current_ds_params.file_path == other.file_path
×
320
                && current_ds_params.gdal_open_options == other.gdal_open_options
×
321
                && current_ds_params.gdal_config_options == other.gdal_config_options
×
322
        } else {
323
            false
8✔
324
        }
325
    }
8✔
326

327
    fn clear(&mut self) -> PrevDatasetOpt {
8✔
328
        let prev = PrevDatasetOpt {
8✔
329
            _prev_dataset: self.dataset.take(),
8✔
330
        };
8✔
331
        self.params = None;
8✔
332
        self.thread_local = None;
8✔
333
        prev
8✔
334
    }
8✔
335

336
    pub fn contains(&self, params: &GdalDatasetParameters) -> bool {
×
337
        Self::is_hit(self.params.as_ref(), params)
×
338
    }
×
339
}
340

341
pub struct GdalHandling;
342

343
impl GdalHandling {
344
    pub fn error_is_gdal_file_not_found(error: &GdalError) -> bool {
3✔
345
        matches!(
1✔
346
            error,
3✔
347
            GdalError::NullPointer {
348
                    method_name,
3✔
349
                    msg
3✔
350
                }
351
             if *method_name == "GDALOpenEx" && (*msg == "HTTP response code: 404" || msg.ends_with("No such file or directory"))
3✔
352
        )
353
    }
3✔
354

355
    fn clear_gdal_vsi_cache_for_path(file_path: &Path) {
3✔
356
        unsafe {
357
            if let Some(Some(c_string)) = file_path.to_str().map(|s| CString::new(s).ok()) {
3✔
358
                VSICurlPartialClearCache(c_string.as_ptr());
3✔
359
            }
3✔
360
        }
361
    }
3✔
362

363
    ///
364
    /// A method to load single tiles from a GDAL dataset.
365
    ///
366
    pub fn load_tile_data_with_dataset_retry<T: Pixel + GdalType + FromPrimitive>(
4✔
367
        cache: &mut GdalDatasetHolder,
4✔
368
        dataset_params: &GdalDatasetParameters,
4✔
369
        read_advise: GdalReadAdvise,
4✔
370
    ) -> Result<super::process_common::GdalIpcPayload<T>, IpcProcessError> {
4✔
371
        let is_remote = dataset_params.is_remote();
4✔
372
        let max_retries = dataset_params.max_retries().unwrap_or(0);
4✔
373
        let dp = &dataset_params;
4✔
374

375
        // Wrap both OPEN and READ actions inside the retry loop
376

377
        retry_sync(
4✔
378
            max_retries,
4✔
379
            GDAL_RETRY_INITIAL_BACKOFF_MS,
4✔
380
            GDAL_RETRY_EXPONENTIAL_BACKOFF_FACTOR,
4✔
381
            Some(GDAL_RETRY_MAX_BACKOFF_MS),
4✔
382
            || {
4✔
383
                let ds = match cache.get_or_open(dp) {
4✔
384
                    Ok(dataset) => dataset,
1✔
385
                    Err(gdal_error) => {
3✔
386
                        if is_remote {
3✔
387
                            Self::clear_gdal_vsi_cache_for_path(&dp.file_path_for_open());
2✔
388
                        }
2✔
389
                        return Err(IpcProcessError::from(gdal_error));
3✔
390
                    }
391
                };
392

393
                Self::load_tile_data(ds, dataset_params, read_advise).inspect_err(|_e| {
1✔
394
                    if is_remote {
1✔
395
                        Self::clear_gdal_vsi_cache_for_path(&dp.file_path_for_open());
×
396
                    }
1✔
397
                })
1✔
398
            },
4✔
399
            // If the file explicitly does not exist, do not waste time retrying
400
            |e| matches!(e, IpcProcessError::GdalError { kind, details: _ } if *kind == IpcProcessGdalErrorKind::FileNotFound),
×
401
        )
402
    }
4✔
403

404
    /// This method reads the data for a single grid with a specified size from the GDAL dataset.
405
    /// It fails if the tile is not within the dataset.
406
    #[allow(clippy::float_cmp)]
407
    pub fn read_grid_from_raster<T, D>(
4✔
408
        rasterband: &GdalRasterBand,
4✔
409
        read_window: &GdalReadWindow,
4✔
410
        out_shape: &D,
4✔
411
        dataset_params: &GdalDatasetParameters,
4✔
412
    ) -> Result<GdalDataGridVariant<T>, IpcProcessError>
4✔
413
    where
4✔
414
        T: Pixel + GdalType + Default + FromPrimitive,
4✔
415
        D: Clone + GridSize + PartialEq,
4✔
416
    {
417
        let _span = tracing::debug_span!(
4✔
418
            "gdal_rasterband_read",
419
            window = %format!("({},{})", read_window.size_x, read_window.size_y),
4✔
420
        )
421
        .entered();
4✔
422
        let gdal_out_shape = (out_shape.axis_size_x(), out_shape.axis_size_y());
4✔
423

424
        let read_start = Instant::now();
4✔
425
        let buffer = rasterband.read_as::<T>(
4✔
426
            read_window.gdal_window_start(), // pixelspace origin
4✔
427
            read_window.gdal_window_size(),  // pixelspace size
4✔
428
            gdal_out_shape,                  // requested raster size
4✔
429
            None,                            // sampling mode
4✔
430
        )?;
×
431
        let read_duration = read_start.elapsed();
4✔
432
        tracing::debug!(
4✔
433
            "GDAL rasterband read took {read_duration:?} for window size ({}, {})",
434
            read_window.size_x,
435
            read_window.size_y,
436
        );
437
        let (_, buffer_data) = buffer.into_shape_and_vec();
4✔
438

439
        let dataset_mask_flags = rasterband.mask_flags()?;
4✔
440

441
        if dataset_mask_flags.is_all_valid() {
4✔
442
            debug!("all pixels are valid --> skip no-data and mask handling.");
×
443
            return Ok(GdalDataGridVariant::AllValid { data: buffer_data });
×
444
        }
4✔
445

446
        if dataset_mask_flags.is_nodata() {
4✔
447
            debug!("raster uses a no-data value --> use no-data handling.");
4✔
448
            let no_data_value = dataset_params
4✔
449
                .no_data_value
4✔
450
                .or_else(|| rasterband.no_data_value());
4✔
451

452
            if let Some(no_data_value) = no_data_value {
4✔
453
                return Ok(GdalDataGridVariant::WithNoData {
4✔
454
                    data: buffer_data,
4✔
455
                    no_data_value,
4✔
456
                });
4✔
457
            }
×
458
            return Ok(GdalDataGridVariant::AllValid { data: buffer_data });
×
459
        }
×
460

461
        if dataset_mask_flags.is_alpha() {
×
462
            debug!("raster uses alpha band to mask pixels.");
×
463
            if !dataset_params.allow_alphaband_as_mask {
×
464
                return Err(IpcProcessError::AlphaBandAsMaskNotAllowed);
×
465
            }
×
466
        }
×
467

468
        debug!("use mask based no-data handling.");
×
469

470
        let mask_band = rasterband.open_mask_band()?;
×
471
        let mask_buffer = mask_band.read_as::<u8>(
×
472
            read_window.gdal_window_start(), // pixelspace origin
×
473
            read_window.gdal_window_size(),  // pixelspace size
×
474
            gdal_out_shape,                  // requested raster size
×
475
            None,                            // sampling mode
×
476
        )?;
×
477
        let (_, mask_buffer_data) = mask_buffer.into_shape_and_vec();
×
478
        Ok(GdalDataGridVariant::WithExplicitMask {
×
479
            data: buffer_data,
×
480
            validity_mask: mask_buffer_data,
×
481
        })
×
482
    }
4✔
483

484
    pub fn properties_from_gdal_metadata<'a, I, M>(
6✔
485
        properties: &mut RasterProperties,
6✔
486
        gdal_dataset: &M,
6✔
487
        properties_mapping: I,
6✔
488
    ) where
6✔
489
        I: IntoIterator<Item = &'a GdalMetadataMapping>,
6✔
490
        M: GdalMetadata,
6✔
491
    {
492
        let mapping_iter = properties_mapping.into_iter();
6✔
493

494
        for m in mapping_iter {
8✔
495
            let data = if let Some(domain) = &m.source_key.domain {
8✔
496
                gdal_dataset.metadata_item(&m.source_key.key, domain)
2✔
497
            } else {
498
                gdal_dataset.metadata_item(&m.source_key.key, "")
6✔
499
            };
500

501
            if let Some(d) = data {
8✔
502
                let entry = match m.target_type {
4✔
503
                    RasterPropertiesEntryType::Number => d.parse::<f64>().map_or_else(
×
504
                        |_| RasterPropertiesEntry::String(d),
×
505
                        RasterPropertiesEntry::Number,
506
                    ),
507
                    RasterPropertiesEntryType::String => RasterPropertiesEntry::String(d),
4✔
508
                };
509

510
                debug!(
4✔
511
                    "gdal properties key \"{:?}\" => target key \"{:?}\". Value: {:?} ",
512
                    &m.source_key, &m.target_key, &entry
×
513
                );
514

515
                properties.insert_property(m.target_key.clone(), entry);
4✔
516
            }
4✔
517
        }
518
    }
6✔
519

520
    pub fn properties_from_band(properties: &mut RasterProperties, gdal_dataset: &GdalRasterBand) {
4✔
521
        if let Some(scale) = gdal_dataset.scale() {
4✔
522
            properties.set_scale(scale);
3✔
523
        }
3✔
524
        if let Some(offset) = gdal_dataset.offset() {
4✔
525
            properties.set_offset(offset);
3✔
526
        }
3✔
527

528
        // ignore if there is no description
529
        if let Ok(description) = gdal_dataset.description() {
4✔
530
            properties.set_description(description);
4✔
531
        }
4✔
532
    }
4✔
533

534
    /// This method reads the data for a single tile with a specified size from the GDAL dataset and adds the requested metadata as properties to the tile.
535
    pub fn read_raster_properties(
4✔
536
        dataset: &GdalDataset,
4✔
537
        dataset_params: &GdalDatasetParameters,
4✔
538
        rasterband: &GdalRasterBand,
4✔
539
    ) -> RasterProperties {
4✔
540
        let mut properties = RasterProperties::default();
4✔
541

542
        // always read the scale and offset values from the rasterband
543
        Self::properties_from_band(&mut properties, rasterband);
4✔
544

545
        // read the properties from the dataset and rasterband metadata
546
        if let Some(properties_mapping) = dataset_params.properties_mapping.as_ref() {
4✔
547
            Self::properties_from_gdal_metadata(&mut properties, dataset, properties_mapping);
3✔
548
            Self::properties_from_gdal_metadata(&mut properties, rasterband, properties_mapping);
3✔
549
        }
3✔
550

551
        properties
4✔
552
    }
4✔
553

554
    ///
555
    /// A method to load single tiles from a GDAL dataset.
556
    ///
557
    fn load_tile_data<T: Pixel + GdalType + FromPrimitive>(
5✔
558
        dataset: &mut GdalDataset,
5✔
559
        dataset_params: &GdalDatasetParameters,
5✔
560
        read_advise: GdalReadAdvise,
5✔
561
    ) -> Result<GdalIpcPayload<T>, IpcProcessError> {
5✔
562
        let _span = tracing::debug_span!(
5✔
563
            "gdal_load_tile_data",
564
            data_type = ?T::TYPE,
565
            window = ?read_advise.bounds_of_target,
566
        )
567
        .entered();
5✔
568
        let start = Instant::now();
5✔
569

570
        debug!(
5✔
571
            "GridOrEmpty2D<{:?}> requested for {:?}.",
572
            T::TYPE,
573
            &read_advise.bounds_of_target,
×
574
        );
575

576
        let gdal_dataset_geotransform = GdalDatasetGeoTransform::from(dataset.geo_transform()?);
5✔
577
        // check that the dataset geo transform is the same as the one we get from GDAL
578
        debug_assert!(
5✔
579
            approx_eq!(
5✔
580
                Coordinate2D,
581
                gdal_dataset_geotransform.origin_coordinate,
5✔
582
                dataset_params.geo_transform.origin_coordinate
5✔
583
            ),
584
            "expected dataset geo transform origin coordinate {:?} to be approximately equal to GDAL dataset geo transform origin coordinate {:?}",
585
            dataset_params.geo_transform.origin_coordinate,
586
            gdal_dataset_geotransform.origin_coordinate
587
        );
588

589
        debug_assert!(
5✔
590
            approx_eq!(
5✔
591
                f64,
592
                gdal_dataset_geotransform.x_pixel_size,
5✔
593
                dataset_params.geo_transform.x_pixel_size
5✔
594
            ),
595
            "expected dataset geo transform x pixel size {:?} to be approximately equal to GDAL dataset geo transform x pixel size {:?}",
596
            dataset_params.geo_transform.x_pixel_size,
597
            gdal_dataset_geotransform.x_pixel_size
598
        );
599

600
        debug_assert!(
5✔
601
            approx_eq!(
5✔
602
                f64,
603
                gdal_dataset_geotransform.y_pixel_size,
5✔
604
                dataset_params.geo_transform.y_pixel_size
5✔
605
            ),
606
            "expected dataset geo transform y pixel size {:?} to be approximately equal to GDAL dataset geo transform y pixel size {:?}",
607
            dataset_params.geo_transform.y_pixel_size,
608
            gdal_dataset_geotransform.y_pixel_size
609
        );
610

611
        let (gdal_dataset_pixels_x, gdal_dataset_pixels_y) = dataset.raster_size();
5✔
612
        // check that the dataset pixel size is the same as the one we get from GDAL
613
        debug_assert_eq!(gdal_dataset_pixels_x, dataset_params.width);
5✔
614
        debug_assert_eq!(gdal_dataset_pixels_y, dataset_params.height);
5✔
615

616
        let rasterband = dataset.rasterband(dataset_params.rasterband_channel)?;
5✔
617

618
        let result_gdal_raster = Self::read_grid_from_raster(
4✔
619
            &rasterband,
4✔
620
            &read_advise.gdal_read_widow,
4✔
621
            &read_advise.read_window_bounds,
4✔
622
            dataset_params,
4✔
623
        )?;
×
624

625
        let properties = Self::read_raster_properties(dataset, dataset_params, &rasterband);
4✔
626

627
        let elapsed = start.elapsed();
4✔
628
        debug!("data loaded -> returning data grid, took {elapsed:?}");
4✔
629

630
        Ok(GdalIpcPayload {
4✔
631
            dimensions: read_advise.read_window_bounds,
4✔
632
            properties,
4✔
633
            data_variant: result_gdal_raster,
4✔
634
        })
4✔
635
    }
5✔
636
}
637

638
#[cfg(test)]
639
mod tests {
640

641
    use super::super::{
642
        FileNotFoundHandling, GridAndProperties,
643
        process_common::{
644
            IpcChannelMessage, IpcChannelMessagePayload, IpcProcessGdalErrorKind,
645
            IpcProcessRasterResult,
646
        },
647
    };
648
    use super::*;
649
    use float_cmp::assert_approx_eq;
650
    use geoengine_datatypes::{
651
        primitives::{AxisAlignedRectangle, SpatialPartition2D, TimeInstance, TimeInterval},
652
        raster::{
653
            GeoTransform, GridBoundingBox2D, GridShape2D, RasterDataType, RasterPropertiesKey,
654
            SpatialGridDefinition, TileInformation,
655
        },
656
        test_data,
657
        util::{gdal::hide_gdal_errors, test::TestDefault},
658
    };
659
    use httptest::{Expectation, Server, matchers::request, responders};
660

661
    fn get_params() -> GdalDatasetParameters {
2✔
662
        GdalDatasetParameters {
2✔
663
            file_path: test_data!("raster/modis_ndvi/MOD13A2_M_NDVI_2014-01-01.TIFF").into(),
2✔
664
            rasterband_channel: 1,
2✔
665
            geo_transform: GdalDatasetGeoTransform {
2✔
666
                origin_coordinate: (-180., 90.).into(),
2✔
667
                x_pixel_size: 0.1,
2✔
668
                y_pixel_size: -0.1,
2✔
669
            },
2✔
670
            width: 3600,
2✔
671
            height: 1800,
2✔
672
            file_not_found_handling: FileNotFoundHandling::NoData,
2✔
673
            no_data_value: Some(0.),
2✔
674
            properties_mapping: Some(vec![
2✔
675
                GdalMetadataMapping {
2✔
676
                    source_key: RasterPropertiesKey {
2✔
677
                        domain: None,
2✔
678
                        key: "AREA_OR_POINT".to_string(),
2✔
679
                    },
2✔
680
                    target_type: RasterPropertiesEntryType::String,
2✔
681
                    target_key: RasterPropertiesKey {
2✔
682
                        domain: None,
2✔
683
                        key: "AREA_OR_POINT".to_string(),
2✔
684
                    },
2✔
685
                },
2✔
686
                GdalMetadataMapping {
2✔
687
                    source_key: RasterPropertiesKey {
2✔
688
                        domain: Some("IMAGE_STRUCTURE".to_string()),
2✔
689
                        key: "COMPRESSION".to_string(),
2✔
690
                    },
2✔
691
                    target_type: RasterPropertiesEntryType::String,
2✔
692
                    target_key: RasterPropertiesKey {
2✔
693
                        domain: Some("IMAGE_STRUCTURE_INFO".to_string()),
2✔
694
                        key: "COMPRESSION".to_string(),
2✔
695
                    },
2✔
696
                },
2✔
697
            ]),
2✔
698
            gdal_open_options: None,
2✔
699
            gdal_config_options: None,
2✔
700
            allow_alphaband_as_mask: true,
2✔
701
            retry: None,
2✔
702
        }
2✔
703
    }
2✔
704

705
    fn tile_information_with_partition_and_shape(
1✔
706
        partition: SpatialPartition2D,
1✔
707
        shape: GridShape2D,
1✔
708
    ) -> TileInformation {
1✔
709
        let real_geotransform = GeoTransform::new(
1✔
710
            partition.upper_left(),
1✔
711
            partition.size_x() / shape.axis_size_x() as f64,
1✔
712
            -partition.size_y() / shape.axis_size_y() as f64,
1✔
713
        );
714

715
        TileInformation {
1✔
716
            tile_size_in_pixels: shape,
1✔
717
            global_tile_position: [0, 0].into(),
1✔
718
            global_geo_transform: real_geotransform,
1✔
719
        }
1✔
720
    }
1✔
721

722
    #[test]
723
    #[serial_test::serial]
724
    fn ipc_process_error_ipc_channel_roundtrip() {
1✔
725
        let error = IpcProcessError::GdalError {
1✔
726
            kind: IpcProcessGdalErrorKind::FileNotFound,
1✔
727
            details: "not found".into(),
1✔
728
        };
1✔
729

730
        let (sender, receiver) = ipc::channel::<IpcProcessRasterResult>().unwrap();
1✔
731
        sender.send(Err(error.clone())).unwrap();
1✔
732
        let decoded = receiver.recv().unwrap();
1✔
733

734
        assert_eq!(decoded, Err(error));
1✔
735
    }
736

737
    #[test]
738
    #[serial_test::serial]
739
    fn test_sending_gdal_dataset_parameters_via_string() {
1✔
740
        let msg = get_params();
1✔
741

742
        let (sender, receiver) = ipc_channel::ipc::channel::<String>().unwrap();
1✔
743

744
        sender.send(serde_json::to_string(&msg).unwrap()).unwrap();
1✔
745
        let recv = receiver.recv().unwrap();
1✔
746
        let recv = serde_json::from_str::<GdalDatasetParameters>(&recv).unwrap();
1✔
747
        assert_eq!(msg.properties_mapping, recv.properties_mapping);
1✔
748
        assert_eq!(msg, recv);
1✔
749
    }
750

751
    #[test]
752
    #[serial_test::serial]
753
    fn test_sending_gdal_dataset_parameters() {
1✔
754
        let msg = get_params();
1✔
755

756
        let (sender, receiver) = ipc_channel::ipc::channel().unwrap();
1✔
757

758
        sender.send(msg.clone()).unwrap();
1✔
759
        let recv = receiver.recv().unwrap();
1✔
760
        assert_eq!(msg.properties_mapping, recv.properties_mapping);
1✔
761

762
        assert_eq!(msg, recv);
1✔
763
    }
764

765
    #[test]
766
    #[serial_test::serial]
767
    fn test_sending_tile_information() {
1✔
768
        let output_shape: GridShape2D = [8, 8].into();
1✔
769
        let output_bounds =
1✔
770
            SpatialPartition2D::new_unchecked((-180., 90.).into(), (180., -90.).into());
1✔
771

772
        let msg = tile_information_with_partition_and_shape(output_bounds, output_shape);
1✔
773

774
        let (sender, receiver) = ipc_channel::ipc::channel().unwrap();
1✔
775

776
        sender.send(msg).unwrap();
1✔
777
        let recv = receiver.recv().unwrap();
1✔
778
        assert_eq!(msg, recv);
1✔
779
    }
780

781
    #[test]
782
    #[serial_test::serial]
783
    fn test_sending_time() {
1✔
784
        let msg = TimeInstance::from_millis(10).unwrap();
1✔
785

786
        let (sender, receiver) = ipc_channel::ipc::channel().unwrap();
1✔
787

788
        sender.send(msg).unwrap();
1✔
789
        let recv = receiver.recv().unwrap();
1✔
790
        assert_eq!(msg, recv);
1✔
791
    }
792

793
    #[test]
794
    #[serial_test::serial]
795
    fn test_sending_time_interval() {
1✔
796
        let msg = TimeInterval::default();
1✔
797

798
        let (sender, receiver) = ipc_channel::ipc::channel().unwrap();
1✔
799

800
        sender.send(msg).unwrap();
1✔
801
        let recv = receiver.recv().unwrap();
1✔
802
        assert_eq!(msg, recv);
1✔
803
    }
804

805
    #[test]
806
    #[serial_test::serial]
807
    fn test_sending_request_tile_data() {
1✔
808
        let output_shape: GridShape2D = [8, 8].into();
1✔
809

810
        let read_advise = GdalReadAdvise {
1✔
811
            gdal_read_widow: GdalReadWindow::new([0, 0].into(), output_shape),
1✔
812
            read_window_bounds: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
813
            bounds_of_target: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
814
            flip_y: false,
1✔
815
        };
1✔
816

817
        let payload = IpcChannelMessagePayload {
1✔
818
            data_type: RasterDataType::U8,
1✔
819
            dataset_params: GdalDatasetParameters {
1✔
820
                file_path: test_data!("raster/modis_ndvi/MOD13A2_M_NDVI_2014-01-01.TIFF").into(),
1✔
821
                rasterband_channel: 1,
1✔
822
                geo_transform: GdalDatasetGeoTransform {
1✔
823
                    origin_coordinate: (-180., 90.).into(),
1✔
824
                    x_pixel_size: 0.1,
1✔
825
                    y_pixel_size: -0.1,
1✔
826
                },
1✔
827
                width: 3600,
1✔
828
                height: 1800,
1✔
829
                file_not_found_handling: FileNotFoundHandling::NoData,
1✔
830
                no_data_value: Some(0.),
1✔
831
                properties_mapping: Some(vec![
1✔
832
                    GdalMetadataMapping {
1✔
833
                        source_key: RasterPropertiesKey {
1✔
834
                            domain: None,
1✔
835
                            key: "AREA_OR_POINT".to_string(),
1✔
836
                        },
1✔
837
                        target_type: RasterPropertiesEntryType::String,
1✔
838
                        target_key: RasterPropertiesKey {
1✔
839
                            domain: None,
1✔
840
                            key: "AREA_OR_POINT".to_string(),
1✔
841
                        },
1✔
842
                    },
1✔
843
                    GdalMetadataMapping {
1✔
844
                        source_key: RasterPropertiesKey {
1✔
845
                            domain: Some("IMAGE_STRUCTURE".to_string()),
1✔
846
                            key: "COMPRESSION".to_string(),
1✔
847
                        },
1✔
848
                        target_type: RasterPropertiesEntryType::String,
1✔
849
                        target_key: RasterPropertiesKey {
1✔
850
                            domain: Some("IMAGE_STRUCTURE_INFO".to_string()),
1✔
851
                            key: "COMPRESSION".to_string(),
1✔
852
                        },
1✔
853
                    },
1✔
854
                ]),
1✔
855
                gdal_open_options: None,
1✔
856
                gdal_config_options: None,
1✔
857
                allow_alphaband_as_mask: true,
1✔
858
                retry: None,
1✔
859
            },
1✔
860
            read_advise,
1✔
861
            read_id: None,
1✔
862
        };
1✔
863

864
        let msg = IpcChannelMessage::new_request_tile_message(payload);
1✔
865

866
        let (sender, receiver) = ipc_channel::ipc::channel().unwrap();
1✔
867

868
        sender.send(msg.clone()).unwrap();
1✔
869
        let recv = receiver.recv().unwrap();
1✔
870

871
        assert_eq!(msg, recv);
1✔
872
    }
873

874
    #[test]
875
    #[serial_test::serial]
876
    fn test_ipc_channel_roundtrip_tile() {
1✔
877
        let output_shape: GridShape2D = [8, 8].into();
1✔
878

879
        let read_advise = GdalReadAdvise {
1✔
880
            gdal_read_widow: GdalReadWindow::new([0, 0].into(), output_shape),
1✔
881
            read_window_bounds: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
882
            bounds_of_target: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
883
            flip_y: false,
1✔
884
        };
1✔
885

886
        let payload = IpcChannelMessagePayload {
1✔
887
            data_type: RasterDataType::U8,
1✔
888
            dataset_params: GdalDatasetParameters {
1✔
889
                file_path: test_data!("raster/modis_ndvi/MOD13A2_M_NDVI_2014-01-01.TIFF").into(),
1✔
890
                rasterband_channel: 1,
1✔
891
                geo_transform: GdalDatasetGeoTransform {
1✔
892
                    origin_coordinate: (-180., 90.).into(),
1✔
893
                    x_pixel_size: 0.1,
1✔
894
                    y_pixel_size: -0.1,
1✔
895
                },
1✔
896
                width: 3600,
1✔
897
                height: 1800,
1✔
898
                file_not_found_handling: FileNotFoundHandling::NoData,
1✔
899
                no_data_value: Some(0.),
1✔
900
                properties_mapping: Some(vec![
1✔
901
                    GdalMetadataMapping {
1✔
902
                        source_key: RasterPropertiesKey {
1✔
903
                            domain: None,
1✔
904
                            key: "AREA_OR_POINT".to_string(),
1✔
905
                        },
1✔
906
                        target_type: RasterPropertiesEntryType::String,
1✔
907
                        target_key: RasterPropertiesKey {
1✔
908
                            domain: None,
1✔
909
                            key: "AREA_OR_POINT".to_string(),
1✔
910
                        },
1✔
911
                    },
1✔
912
                    GdalMetadataMapping {
1✔
913
                        source_key: RasterPropertiesKey {
1✔
914
                            domain: Some("IMAGE_STRUCTURE".to_string()),
1✔
915
                            key: "COMPRESSION".to_string(),
1✔
916
                        },
1✔
917
                        target_type: RasterPropertiesEntryType::String,
1✔
918
                        target_key: RasterPropertiesKey {
1✔
919
                            domain: Some("IMAGE_STRUCTURE_INFO".to_string()),
1✔
920
                            key: "COMPRESSION".to_string(),
1✔
921
                        },
1✔
922
                    },
1✔
923
                ]),
1✔
924
                gdal_open_options: None,
1✔
925
                gdal_config_options: None,
1✔
926
                allow_alphaband_as_mask: true,
1✔
927
                retry: None,
1✔
928
            },
1✔
929
            read_advise,
1✔
930
            read_id: None,
1✔
931
        };
1✔
932

933
        let msg = IpcChannelMessage::new_request_tile_message(payload);
1✔
934

935
        let (_child_guard, sender, receiver) = spawn_ipc_server_process::<
1✔
936
            IpcChannelMessage,
1✔
937
            IpcProcessRasterResult,
1✔
938
        >(&WorkerConfig::default())
1✔
939
        .unwrap();
1✔
940

941
        sender.send(msg).unwrap();
1✔
942
        let rx_result = receiver
1✔
943
            .recv()
1✔
944
            .inspect_err(|e| panic!("IPC receive: {e:?}"))
1✔
945
            .unwrap();
1✔
946

947
        let payload = match rx_result {
1✔
948
            Ok(r) => r,
1✔
NEW
949
            Err(e) => panic!("Error receiving from IPC process: {e:?}"),
×
950
        };
951

952
        let result_2: GdalIpcPayload<u8> = (&payload).try_into().unwrap();
1✔
953

954
        let grid_and_props: GridAndProperties<u8, GridBoundingBox2D> = result_2.into();
1✔
955

956
        assert!(!grid_and_props.grid.is_empty());
1✔
957

958
        let grid = grid_and_props.grid.into_materialized_masked_grid();
1✔
959

960
        assert_eq!(grid.inner_grid.data.len(), 64);
1✔
961
        assert_eq!(grid.validity_mask.data.len(), 64);
1✔
962
    }
963

964
    // This method loads raster data from a cropped MODIS NDVI raster.
965
    // To inspect the byte values first convert the file to XYZ with GDAL:
966
    // 'gdal_translate -of xyz MOD13A2_M_NDVI_2014-04-01_30X30.tif MOD13A2_M_NDVI_2014-04-01_30x30.xyz'
967
    // Then you can convert them to gruped bytes:
968
    // 'cut -d ' ' -f 1,2 --complement MOD13A2_M_NDVI_2014-04-01_30x30.xyz | xargs -n 30 > MOD13A2_M_NDVI_2014-04-01_30x30_bytes.txt'.
969
    fn load_ndvi_apr_2014_cropped(
2✔
970
        gdal_read_advice: GdalReadAdvise,
2✔
971
    ) -> Result<GridAndProperties<u8>, IpcProcessError> {
2✔
972
        let dataset_params = GdalDatasetParameters {
2✔
973
            file_path: test_data!("raster/modis_ndvi/cropped/MOD13A2_M_NDVI_2014-04-01_30x30.tif")
2✔
974
                .into(),
2✔
975
            rasterband_channel: 1,
2✔
976
            geo_transform: GdalDatasetGeoTransform {
2✔
977
                origin_coordinate: (8.0, 57.4).into(),
2✔
978
                x_pixel_size: 0.1,
2✔
979
                y_pixel_size: -0.1,
2✔
980
            },
2✔
981
            width: 30,
2✔
982
            height: 30,
2✔
983
            file_not_found_handling: FileNotFoundHandling::NoData,
2✔
984
            no_data_value: Some(255.),
2✔
985
            properties_mapping: Some(vec![GdalMetadataMapping {
2✔
986
                source_key: RasterPropertiesKey {
2✔
987
                    domain: None,
2✔
988
                    key: "AREA_OR_POINT".to_string(),
2✔
989
                },
2✔
990
                target_type: RasterPropertiesEntryType::String,
2✔
991
                target_key: RasterPropertiesKey {
2✔
992
                    domain: None,
2✔
993
                    key: "AREA_OR_POINT".to_string(),
2✔
994
                },
2✔
995
            }]),
2✔
996
            gdal_open_options: None,
2✔
997
            gdal_config_options: None,
2✔
998
            allow_alphaband_as_mask: true,
2✔
999
            retry: None,
2✔
1000
        };
2✔
1001

1002
        let mut gdc = GdalDatasetHolder::new();
2✔
1003
        let dataset = gdc.get_or_open(&dataset_params).unwrap();
2✔
1004

1005
        let reader_payload =
2✔
1006
            GdalHandling::load_tile_data::<u8>(dataset, &dataset_params, gdal_read_advice)?;
2✔
1007

1008
        let grid_and_props: GridAndProperties<u8, GridBoundingBox2D> = reader_payload.into();
2✔
1009
        Ok(grid_and_props.into())
2✔
1010
    }
2✔
1011

1012
    #[tokio::test]
1013
    #[serial_test::serial]
1014
    async fn test_load_tile_data_top_left() {
2✔
1015
        let gdal_read_advice = GdalReadAdvise {
1✔
1016
            gdal_read_widow: GdalReadWindow::new([0, 0].into(), [8, 8].into()),
1✔
1017
            read_window_bounds: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1018
            bounds_of_target: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1019
            flip_y: false,
1✔
1020
        };
1✔
1021

1022
        let GridAndProperties { grid, properties } =
1✔
1023
            load_ndvi_apr_2014_cropped(gdal_read_advice).unwrap();
1✔
1024

1025
        assert!(!grid.is_empty());
1✔
1026

1027
        let grid = grid.into_materialized_masked_grid();
1✔
1028

1029
        assert_eq!(grid.inner_grid.data.len(), 64);
1✔
1030
        // pixel value are the top left 8x8 block from MOD13A2_M_NDVI_2014-04-01_27x27_bytes.txt
1031
        assert_eq!(
1✔
1032
            grid.inner_grid.data,
1033
            &[
1034
                255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
1035
                255, 255, 255, 255, 255, 255, 127, 107, 255, 255, 255, 255, 255, 164, 185, 182,
1036
                255, 255, 255, 175, 186, 190, 167, 140, 255, 255, 161, 175, 184, 173, 170, 188,
1037
                255, 255, 128, 177, 165, 145, 191, 174, 255, 117, 100, 174, 159, 147, 99, 135
1038
            ]
1039
        );
1040

1041
        assert_eq!(grid.validity_mask.data.len(), 64);
1✔
1042
        // pixel mask is pixel > 0 from the top left 8x8 block from MOD13A2_M_NDVI_2014-04-01_27x27_bytes.txt
1043
        assert_eq!(
1✔
1044
            grid.validity_mask.data,
1045
            &[
1046
                false, false, false, false, false, false, false, false, false, false, false, false,
1047
                false, false, false, false, false, false, false, false, false, false, true, true,
1048
                false, false, false, false, false, true, true, true, false, false, false, true,
1049
                true, true, true, true, false, false, true, true, true, true, true, true, false,
1050
                false, true, true, true, true, true, true, false, true, true, true, true, true,
1051
                true, true
1052
            ]
1053
        );
1054

1055
        assert_eq!(
1✔
1056
            properties.get_property(&RasterPropertiesKey {
1✔
1057
                key: "AREA_OR_POINT".to_string(),
1✔
1058
                domain: None,
1✔
1059
            }),
1✔
1060
            Some(&RasterPropertiesEntry::String("Area".to_string()))
1✔
1061
        );
1✔
1062
    }
1063

1064
    #[test]
1065
    #[serial_test::serial]
1066
    fn test_load_tile_data_overlaps_dataset_bounds_top_left_out1() {
1✔
1067
        // shift world bbox one pixel up and to the left
1068
        let gdal_read_advice = GdalReadAdvise {
1✔
1069
            gdal_read_widow: GdalReadWindow::new([0, 0].into(), [7, 7].into()), // this is the data we can read
1✔
1070
            read_window_bounds: GridBoundingBox2D::new([1, 1], [7, 7]).unwrap(), // this is the area we can fill in target
1✔
1071
            bounds_of_target: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(), // this is the area of the target
1✔
1072
            flip_y: false,
1✔
1073
        };
1✔
1074

1075
        let GridAndProperties {
1076
            grid,
1✔
1077
            properties: _properties,
1✔
1078
        } = load_ndvi_apr_2014_cropped(gdal_read_advice).unwrap();
1✔
1079

1080
        assert!(!grid.is_empty());
1✔
1081

1082
        let x = grid.into_materialized_masked_grid();
1✔
1083

1084
        assert_eq!(x.inner_grid.data.len(), 49);
1✔
1085
        assert_eq!(
1✔
1086
            x.inner_grid.data,
1087
            &[
1088
                255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
1089
                255, 255, 255, 255, 127, 255, 255, 255, 255, 255, 164, 185, 255, 255, 255, 175,
1090
                186, 190, 167, 255, 255, 161, 175, 184, 173, 170, 255, 255, 128, 177, 165, 145,
1091
                191,
1092
            ]
1093
        );
1094

1095
        assert_eq!(x.validity_mask.data.len(), 49);
1✔
1096
        // pixel mask is pixel == 255 from the top left 8x8 block from MOD13A2_M_NDVI_2014-04-01_27x27_bytes.txt
1097
        assert_eq!(
1✔
1098
            x.validity_mask.data,
1099
            &[
1100
                false, false, false, false, false, false, false, false, false, false, false, false,
1101
                false, false, false, false, false, false, false, false, true, false, false, false,
1102
                false, false, true, true, false, false, false, true, true, true, true, false,
1103
                false, true, true, true, true, true, false, false, true, true, true, true, true,
1104
            ]
1105
        );
1106
    }
1107

1108
    #[test]
1109
    #[serial_test::serial]
1110
    fn it_creates_no_data_only_for_missing_files() {
1✔
1111
        hide_gdal_errors();
1✔
1112

1113
        let ds = GdalDatasetParameters {
1✔
1114
            file_path: "nonexisting_file.tif".into(),
1✔
1115
            rasterband_channel: 1,
1✔
1116
            geo_transform: GdalDatasetGeoTransform {
1✔
1117
                origin_coordinate: (-180., 90.).into(),
1✔
1118
                x_pixel_size: 0.1,
1✔
1119
                y_pixel_size: -0.1,
1✔
1120
            },
1✔
1121
            width: 3600,
1✔
1122
            height: 1800,
1✔
1123
            file_not_found_handling: FileNotFoundHandling::NoData,
1✔
1124
            no_data_value: None,
1✔
1125
            properties_mapping: None,
1✔
1126
            gdal_open_options: None,
1✔
1127
            gdal_config_options: None,
1✔
1128
            allow_alphaband_as_mask: true,
1✔
1129
            retry: None,
1✔
1130
        };
1✔
1131

1132
        let gdal_read_advice = GdalReadAdvise {
1✔
1133
            gdal_read_widow: GdalReadWindow::new([0, 0].into(), [8, 8].into()),
1✔
1134
            read_window_bounds: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1135
            bounds_of_target: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1136
            flip_y: false,
1✔
1137
        };
1✔
1138

1139
        let mut gdc = GdalDatasetHolder::new();
1✔
1140

1141
        let result =
1✔
1142
            GdalHandling::load_tile_data_with_dataset_retry::<u8>(&mut gdc, &ds, gdal_read_advice);
1✔
1143

1144
        // file not found => specific error
1145
        match result {
1✔
1146
            Err(IpcProcessError::GdalError {
1147
                kind: IpcProcessGdalErrorKind::FileNotFound,
1148
                ..
1149
            }) => {}
1✔
UNCOV
1150
            _ => panic!("expected FileNotFound error"),
×
1151
        }
1152

1153
        let ds = GdalDatasetParameters {
1✔
1154
            file_path: test_data!("raster/modis_ndvi/MOD13A2_M_NDVI_2014-01-01.TIFF").into(),
1✔
1155
            rasterband_channel: 100, // invalid channel
1✔
1156
            geo_transform: GdalDatasetGeoTransform {
1✔
1157
                origin_coordinate: (-180., 90.).into(),
1✔
1158
                x_pixel_size: 0.1,
1✔
1159
                y_pixel_size: -0.1,
1✔
1160
            },
1✔
1161
            width: 3600,
1✔
1162
            height: 1800,
1✔
1163
            file_not_found_handling: FileNotFoundHandling::NoData,
1✔
1164
            no_data_value: None,
1✔
1165
            properties_mapping: None,
1✔
1166
            gdal_open_options: None,
1✔
1167
            gdal_config_options: None,
1✔
1168
            allow_alphaband_as_mask: true,
1✔
1169
            retry: None,
1✔
1170
        };
1✔
1171

1172
        // invalid channel => error
1173
        let result =
1✔
1174
            GdalHandling::load_tile_data_with_dataset_retry::<u8>(&mut gdc, &ds, gdal_read_advice);
1✔
1175
        match result {
1✔
1176
            Err(IpcProcessError::GdalError { .. }) => {}
1✔
1177
            _ => panic!("expected GdalError error"),
×
1178
        }
1179
    }
1180

1181
    #[test]
1182
    #[serial_test::serial]
1183
    fn it_creates_no_data_only_for_http_404() {
1✔
1184
        let server = Server::run();
1✔
1185

1186
        server.expect(
1✔
1187
            Expectation::matching(request::method_path("HEAD", "/non_existing.tif"))
1✔
1188
                .times(1)
1✔
1189
                .respond_with(responders::cycle![responders::status_code(404),]),
1✔
1190
        );
1191

1192
        server.expect(
1✔
1193
            Expectation::matching(request::method_path("HEAD", "/internal_error.tif"))
1✔
1194
                .times(1)
1✔
1195
                .respond_with(responders::cycle![responders::status_code(500),]),
1✔
1196
        );
1197

1198
        let ds = GdalDatasetParameters {
1✔
1199
            file_path: server.url_str("/non_existing.tif").into(),
1✔
1200
            rasterband_channel: 1,
1✔
1201
            geo_transform: TestDefault::test_default(),
1✔
1202
            width: 100,
1✔
1203
            height: 100,
1✔
1204
            file_not_found_handling: FileNotFoundHandling::NoData,
1✔
1205
            no_data_value: None,
1✔
1206
            properties_mapping: None,
1✔
1207
            gdal_open_options: None,
1✔
1208
            gdal_config_options: Some(vec![
1✔
1209
                (
1✔
1210
                    "CPL_VSIL_CURL_ALLOWED_EXTENSIONS".to_owned(),
1✔
1211
                    ".tif".to_owned(),
1✔
1212
                ),
1✔
1213
                (
1✔
1214
                    "GDAL_DISABLE_READDIR_ON_OPEN".to_owned(),
1✔
1215
                    "EMPTY_DIR".to_owned(),
1✔
1216
                ),
1✔
1217
                ("GDAL_HTTP_NETRC".to_owned(), "NO".to_owned()),
1✔
1218
                ("GDAL_HTTP_MAX_RETRY".to_owned(), "0".to_string()),
1✔
1219
            ]),
1✔
1220
            allow_alphaband_as_mask: true,
1✔
1221
            retry: None,
1✔
1222
        };
1✔
1223

1224
        // 404 => no data
1225
        let gdal_read_advice = GdalReadAdvise {
1✔
1226
            gdal_read_widow: GdalReadWindow::new([0, 0].into(), [8, 8].into()),
1✔
1227
            read_window_bounds: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1228
            bounds_of_target: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1229
            flip_y: false,
1✔
1230
        };
1✔
1231

1232
        let mut gdc = GdalDatasetHolder::new();
1✔
1233

1234
        let result =
1✔
1235
            GdalHandling::load_tile_data_with_dataset_retry::<u8>(&mut gdc, &ds, gdal_read_advice);
1✔
1236

1237
        // file not found => specific error
1238
        match result {
1✔
1239
            Err(IpcProcessError::GdalError {
1240
                kind: IpcProcessGdalErrorKind::FileNotFound,
1241
                ..
1242
            }) => {}
1✔
UNCOV
1243
            _ => panic!("expected FileNotFound error"),
×
1244
        }
1245

1246
        let ds = GdalDatasetParameters {
1✔
1247
            file_path: server.url_str("/internal_error.tif").into(),
1✔
1248
            rasterband_channel: 1,
1✔
1249
            geo_transform: TestDefault::test_default(),
1✔
1250
            width: 100,
1✔
1251
            height: 100,
1✔
1252
            file_not_found_handling: FileNotFoundHandling::NoData,
1✔
1253
            no_data_value: None,
1✔
1254
            properties_mapping: None,
1✔
1255
            gdal_open_options: None,
1✔
1256
            gdal_config_options: Some(vec![
1✔
1257
                (
1✔
1258
                    "CPL_VSIL_CURL_ALLOWED_EXTENSIONS".to_owned(),
1✔
1259
                    ".tif".to_owned(),
1✔
1260
                ),
1✔
1261
                (
1✔
1262
                    "GDAL_DISABLE_READDIR_ON_OPEN".to_owned(),
1✔
1263
                    "EMPTY_DIR".to_owned(),
1✔
1264
                ),
1✔
1265
                ("GDAL_HTTP_NETRC".to_owned(), "NO".to_owned()),
1✔
1266
                ("GDAL_HTTP_MAX_RETRY".to_owned(), "0".to_string()),
1✔
1267
            ]),
1✔
1268
            allow_alphaband_as_mask: true,
1✔
1269
            retry: None,
1✔
1270
        };
1✔
1271

1272
        // 500 => error
1273
        let result =
1✔
1274
            GdalHandling::load_tile_data_with_dataset_retry::<u8>(&mut gdc, &ds, gdal_read_advice);
1✔
1275
        match result {
1✔
1276
            Err(IpcProcessError::GdalError { .. }) => {}
1✔
1277
            _ => panic!("expected GdalError error"),
×
1278
        }
1279
    }
1280

1281
    #[test]
1282
    #[serial_test::serial]
1283
    fn it_retries_only_after_clearing_vsi_cache() {
1✔
1284
        hide_gdal_errors();
1✔
1285

1286
        let server = Server::run();
1✔
1287

1288
        server.expect(
1✔
1289
            Expectation::matching(request::method_path("HEAD", "/foo.tif"))
1✔
1290
                .times(2)
1✔
1291
                .respond_with(responders::cycle![
1✔
1292
                    // first generic error
1293
                    responders::status_code(500),
1✔
1294
                    // then 404 file not found
1295
                    responders::status_code(404)
1✔
1296
                ]),
1297
        );
1298

1299
        let params = GdalDatasetParameters {
1✔
1300
            file_path: server.url_str("/foo.tif").into(),
1✔
1301
            rasterband_channel: 1,
1✔
1302
            geo_transform: TestDefault::test_default(),
1✔
1303
            width: 100,
1✔
1304
            height: 100,
1✔
1305
            file_not_found_handling: FileNotFoundHandling::NoData,
1✔
1306
            no_data_value: None,
1✔
1307
            properties_mapping: None,
1✔
1308
            gdal_open_options: None,
1✔
1309
            gdal_config_options: Some(vec![
1✔
1310
                (
1✔
1311
                    "CPL_VSIL_CURL_ALLOWED_EXTENSIONS".to_owned(),
1✔
1312
                    ".tif".to_owned(),
1✔
1313
                ),
1✔
1314
                (
1✔
1315
                    "GDAL_DISABLE_READDIR_ON_OPEN".to_owned(),
1✔
1316
                    "EMPTY_DIR".to_owned(),
1✔
1317
                ),
1✔
1318
                ("GDAL_HTTP_NETRC".to_owned(), "NO".to_owned()),
1✔
1319
                ("GDAL_HTTP_MAX_RETRY".to_owned(), "0".to_string()),
1✔
1320
            ]),
1✔
1321
            allow_alphaband_as_mask: true,
1✔
1322
            retry: None,
1✔
1323
        };
1✔
1324

1325
        // The `/vsicurl/` prefix is added when the dataset is opened
1326
        let file_path = params.file_path_for_open();
1✔
1327

1328
        let _thread_local_configs = params
1✔
1329
            .gdal_config_options
1✔
1330
            .as_ref()
1✔
1331
            .map(|config_options| GdalConfigOptions::new(config_options));
1✔
1332

1333
        // first fail
1334
        let result = gdal_open_ex_gdal_error(
1✔
1335
            file_path.as_path(),
1✔
1336
            DatasetOptions {
1✔
1337
                open_flags: GdalOpenFlags::GDAL_OF_RASTER,
1✔
1338
                ..DatasetOptions::default()
1✔
1339
            },
1✔
1340
        );
1341

1342
        // it failed, but not with file not found
1343
        assert!(result.is_err());
1✔
1344
        if let Err(error) = result {
1✔
1345
            assert!(!GdalHandling::error_is_gdal_file_not_found(&error));
1✔
1346
        }
×
1347

1348
        // second fail doesn't even try, so still not "file not found", even though it should be now
1349
        let result = gdal_open_ex_gdal_error(
1✔
1350
            file_path.as_path(),
1✔
1351
            DatasetOptions {
1✔
1352
                open_flags: GdalOpenFlags::GDAL_OF_RASTER,
1✔
1353
                ..DatasetOptions::default()
1✔
1354
            },
1✔
1355
        );
1356

1357
        assert!(result.is_err());
1✔
1358
        if let Err(error) = result {
1✔
1359
            assert!(!GdalHandling::error_is_gdal_file_not_found(&error));
1✔
1360
        }
×
1361

1362
        GdalHandling::clear_gdal_vsi_cache_for_path(file_path.as_path());
1✔
1363

1364
        // after clearing the cache, it tries again
1365
        let result = gdal_open_ex_gdal_error(
1✔
1366
            file_path.as_path(),
1✔
1367
            DatasetOptions {
1✔
1368
                open_flags: GdalOpenFlags::GDAL_OF_RASTER,
1✔
1369
                ..DatasetOptions::default()
1✔
1370
            },
1✔
1371
        );
1372

1373
        // now we get the file not found error
1374
        assert!(result.is_err());
1✔
1375
        if let Err(error) = result {
1✔
1376
            assert!(GdalHandling::error_is_gdal_file_not_found(&error));
1✔
1377
        }
×
1378
    }
1379

1380
    #[test]
1381
    #[serial_test::serial]
1382
    #[allow(clippy::too_many_lines)]
1383
    fn read_up_side_down_raster() {
1✔
1384
        let up_side_down_params = GdalDatasetParameters {
1✔
1385
            file_path: test_data!(
1✔
1386
                "raster/modis_ndvi/flipped_axis_y/MOD13A2_M_NDVI_2014-01-01_flipped_y.tiff"
1✔
1387
            )
1✔
1388
            .into(),
1✔
1389
            rasterband_channel: 1,
1✔
1390
            geo_transform: GdalDatasetGeoTransform {
1✔
1391
                origin_coordinate: (-180., -90.).into(),
1✔
1392
                x_pixel_size: 0.1,
1✔
1393
                y_pixel_size: 0.1,
1✔
1394
            },
1✔
1395
            width: 3600,
1✔
1396
            height: 1800,
1✔
1397
            file_not_found_handling: FileNotFoundHandling::NoData,
1✔
1398
            no_data_value: Some(0.),
1✔
1399
            properties_mapping: Some(vec![
1✔
1400
                GdalMetadataMapping {
1✔
1401
                    source_key: RasterPropertiesKey {
1✔
1402
                        domain: None,
1✔
1403
                        key: "AREA_OR_POINT".to_string(),
1✔
1404
                    },
1✔
1405
                    target_type: RasterPropertiesEntryType::String,
1✔
1406
                    target_key: RasterPropertiesKey {
1✔
1407
                        domain: None,
1✔
1408
                        key: "AREA_OR_POINT".to_string(),
1✔
1409
                    },
1✔
1410
                },
1✔
1411
                GdalMetadataMapping {
1✔
1412
                    source_key: RasterPropertiesKey {
1✔
1413
                        domain: Some("IMAGE_STRUCTURE".to_string()),
1✔
1414
                        key: "COMPRESSION".to_string(),
1✔
1415
                    },
1✔
1416
                    target_type: RasterPropertiesEntryType::String,
1✔
1417
                    target_key: RasterPropertiesKey {
1✔
1418
                        domain: Some("IMAGE_STRUCTURE_INFO".to_string()),
1✔
1419
                        key: "COMPRESSION".to_string(),
1✔
1420
                    },
1✔
1421
                },
1✔
1422
            ]),
1✔
1423
            gdal_open_options: None,
1✔
1424
            gdal_config_options: None,
1✔
1425
            allow_alphaband_as_mask: true,
1✔
1426
            retry: None,
1✔
1427
        };
1✔
1428

1429
        let ge_global_dataset_grid = SpatialGridDefinition::new(
1✔
1430
            GeoTransform::new(Coordinate2D::new(-180., 90.), 0.1, -0.1),
1✔
1431
            GridBoundingBox2D::new_min_max(0, 1799, 0, 3599).unwrap(),
1✔
1432
        );
1433

1434
        let gdal_dataset_grid = ge_global_dataset_grid.flip_axis_y(); // first, flip axis
1✔
1435
        assert_approx_eq!(
1✔
1436
            Coordinate2D,
1437
            gdal_dataset_grid.geo_transform().origin_coordinate,
1✔
1438
            Coordinate2D::new(-180., 90.)
1✔
1439
        );
1440
        assert_approx_eq!(f64, gdal_dataset_grid.geo_transform.y_pixel_size(), 0.1);
1✔
1441
        assert_approx_eq!(f64, gdal_dataset_grid.geo_transform.x_pixel_size(), 0.1);
1✔
1442
        assert_eq!(
1✔
1443
            gdal_dataset_grid.grid_bounds,
1444
            GridBoundingBox2D::new_min_max(-1800, -1, 0, 3599).unwrap()
1✔
1445
        );
1446

1447
        let gdal_dataset_grid = gdal_dataset_grid
1✔
1448
            .with_moved_origin_exact_grid(Coordinate2D::new(-180., -90.))
1✔
1449
            .unwrap(); // second, move origin (to other side of axis)
1✔
1450
        assert_approx_eq!(
1✔
1451
            Coordinate2D,
1452
            gdal_dataset_grid.geo_transform().origin_coordinate,
1✔
1453
            Coordinate2D::new(-180., -90.)
1✔
1454
        );
1455
        assert_approx_eq!(f64, gdal_dataset_grid.geo_transform.y_pixel_size(), 0.1);
1✔
1456
        assert_approx_eq!(f64, gdal_dataset_grid.geo_transform.x_pixel_size(), 0.1);
1✔
1457
        assert_eq!(
1✔
1458
            gdal_dataset_grid.grid_bounds,
1459
            GridBoundingBox2D::new_min_max(0, 1799, 0, 3599).unwrap()
1✔
1460
        );
1461

1462
        let gdal_read_advice = GdalReadAdvise {
1✔
1463
            gdal_read_widow: GdalReadWindow::new([1466, 1880].into(), [8, 8].into()),
1✔
1464
            read_window_bounds: GridBoundingBox2D::new([326, 1880], [326 + 7, 1880 + 7]).unwrap(),
1✔
1465
            bounds_of_target: GridBoundingBox2D::new([326, 1880], [326 + 7, 1880 + 7]).unwrap(),
1✔
1466
            flip_y: true,
1✔
1467
        };
1✔
1468

1469
        let mut gdc = GdalDatasetHolder::new();
1✔
1470
        let dataset = gdc.get_or_open(&up_side_down_params).unwrap();
1✔
1471

1472
        let reader_payload =
1✔
1473
            GdalHandling::load_tile_data::<u8>(dataset, &up_side_down_params, gdal_read_advice)
1✔
1474
                .unwrap();
1✔
1475

1476
        let GridAndProperties { grid, properties } = reader_payload.into();
1✔
1477
        assert!(!grid.is_empty());
1✔
1478
        let grid = grid.into_materialized_masked_grid();
1✔
1479

1480
        assert_eq!(grid.inner_grid.data.len(), 64);
1✔
1481

1482
        assert_eq!(
1✔
1483
            grid.inner_grid.data,
1484
            &[
1485
                // this is not yet flipped!
1486
                255, 47, 42, 82, 81, 76, 73, 98, 255, 255, 59, 95, 85, 66, 105, 104, 255, 255, 91,
1487
                97, 100, 86, 78, 106, 255, 255, 255, 97, 102, 91, 73, 72, 255, 255, 255, 255, 255,
1488
                68, 81, 93, 255, 255, 255, 255, 255, 255, 53, 47, 255, 255, 255, 255, 255, 255,
1489
                255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
1490
            ]
1491
        );
1492

1493
        assert_eq!(grid.validity_mask.data.len(), 64);
1✔
1494
        assert_eq!(grid.validity_mask.data, &[true; 64]);
1✔
1495

1496
        assert!(properties.offset_option().is_none());
1✔
1497
        assert!(properties.scale_option().is_none());
1✔
1498
    }
1499

1500
    #[test]
1501
    #[serial_test::serial]
1502
    fn read_raster_and_offset_scale() {
1✔
1503
        let up_side_down_params = GdalDatasetParameters {
1✔
1504
            file_path: test_data!("raster/modis_ndvi/cropped/MOD13A2_M_NDVI_2014-04-01_30x30.tif")
1✔
1505
                .into(),
1✔
1506
            rasterband_channel: 1,
1✔
1507
            geo_transform: GdalDatasetGeoTransform {
1✔
1508
                origin_coordinate: (8.0, 57.4).into(),
1✔
1509
                x_pixel_size: 0.1,
1✔
1510
                y_pixel_size: -0.1,
1✔
1511
            },
1✔
1512
            width: 30,
1✔
1513
            height: 30,
1✔
1514
            file_not_found_handling: FileNotFoundHandling::NoData,
1✔
1515
            no_data_value: Some(255.),
1✔
1516
            properties_mapping: None,
1✔
1517
            gdal_open_options: None,
1✔
1518
            gdal_config_options: None,
1✔
1519
            allow_alphaband_as_mask: true,
1✔
1520
            retry: None,
1✔
1521
        };
1✔
1522

1523
        let gdal_read_advice = GdalReadAdvise {
1✔
1524
            gdal_read_widow: GdalReadWindow::new([0, 0].into(), [8, 8].into()),
1✔
1525
            read_window_bounds: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1526
            bounds_of_target: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1527
            flip_y: false,
1✔
1528
        };
1✔
1529

1530
        let mut gdc = GdalDatasetHolder::new();
1✔
1531
        let dataset = gdc.get_or_open(&up_side_down_params).unwrap();
1✔
1532

1533
        let GridAndProperties { grid, properties } =
1✔
1534
            GdalHandling::load_tile_data::<u8>(dataset, &up_side_down_params, gdal_read_advice)
1✔
1535
                .unwrap()
1✔
1536
                .into();
1✔
1537

1538
        assert!(!grid.is_empty());
1✔
1539

1540
        let grid = grid.into_materialized_masked_grid();
1✔
1541

1542
        assert_eq!(grid.inner_grid.data.len(), 64);
1✔
1543
        // pixel value are the top left 8x8 block from MOD13A2_M_NDVI_2014-04-01_27x27_bytes.txt
1544
        assert_eq!(
1✔
1545
            grid.inner_grid.data,
1546
            &[
1547
                255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
1548
                255, 255, 255, 255, 255, 255, 127, 107, 255, 255, 255, 255, 255, 164, 185, 182,
1549
                255, 255, 255, 175, 186, 190, 167, 140, 255, 255, 161, 175, 184, 173, 170, 188,
1550
                255, 255, 128, 177, 165, 145, 191, 174, 255, 117, 100, 174, 159, 147, 99, 135
1551
            ]
1552
        );
1553

1554
        assert_eq!(grid.validity_mask.data.len(), 64);
1✔
1555
        // pixel mask is pixel > 0 from the top left 8x8 block from MOD13A2_M_NDVI_2014-04-01_27x27_bytes.txt
1556
        assert_eq!(
1✔
1557
            grid.validity_mask.data,
1558
            &[
1559
                false, false, false, false, false, false, false, false, false, false, false, false,
1560
                false, false, false, false, false, false, false, false, false, false, true, true,
1561
                false, false, false, false, false, true, true, true, false, false, false, true,
1562
                true, true, true, true, false, false, true, true, true, true, true, true, false,
1563
                false, true, true, true, true, true, true, false, true, true, true, true, true,
1564
                true, true
1565
            ]
1566
        );
1567

1568
        assert_eq!(properties.offset_option(), Some(1.));
1✔
1569
        assert_eq!(properties.scale_option(), Some(2.));
1✔
1570

1571
        assert!(approx_eq!(f64, properties.offset(), 1.));
1✔
1572
        assert!(approx_eq!(f64, properties.scale(), 2.));
1✔
1573
    }
1574
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc