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

geo-engine / geoengine / 29828895317

21 Jul 2026 12:08PM UTC coverage: 87.59% (-0.2%) from 87.839%
29828895317

Pull #1192

github

web-flow
Merge bd6b2979b into 1baac981e
Pull Request #1192: feat: add Gdal process pool

3028 of 3724 new or added lines in 38 files covered. (81.31%)

16 existing lines in 5 files now uncovered.

123314 of 140785 relevant lines covered (87.59%)

481313.41 hits per line

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

92.62
/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,063✔
45
    GDALSOURCE_PROCESS_PATH.get_or_init(|| {
1,063✔
46
        // 1. Try the environment variable path first
47
        if let Ok(env_path) = env::var("GDALSOURCE_PROCESS_PATH")
2✔
NEW
48
            && !env_path.is_empty()
×
49
        {
NEW
50
            let path = PathBuf::from(env_path);
×
NEW
51
            if path.is_file() {
×
NEW
52
                tracing::debug!(
×
53
                    "Using gdalsource-process path from environment variable: {}",
NEW
54
                    path.display()
×
55
                );
NEW
56
                return path;
×
NEW
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,063✔
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,069✔
95
        // Forcefully kill the process when the guard is dropped
96
        let _ = self.child.kill();
1,069✔
97
        let _ = self.child.wait(); // Prevent zombie processes
1,069✔
98
    }
1,069✔
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 {
338✔
126
    "geo_engine_worker".to_string()
338✔
127
}
338✔
128

129
impl Default for WorkerLoggingConfig {
130
    fn default() -> Self {
338✔
131
        Self {
338✔
132
            log_spec: "info".to_string(),
338✔
133
            log_to_file: false,
338✔
134
            filename_prefix: default_worker_log_prefix(),
338✔
135
            log_directory: None,
338✔
136
            worker_id: 0,
338✔
137
        }
338✔
138
    }
338✔
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 {
338✔
149
        Self {
338✔
150
            enabled: false,
338✔
151
            endpoint: "http://127.0.0.1:4317".to_string(),
338✔
152
        }
338✔
153
    }
338✔
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,063✔
169
    worker_config: &WorkerConfig,
1,063✔
170
) -> Result<(ChildProcessGuard, IpcSender<S>, IpcReceiver<R>), IpcProcessError> {
1,063✔
171
    let (server, token) = ipc::IpcOneShotServer::<(IpcSender<S>, IpcReceiver<R>)>::new()
1,063✔
172
        .map_err(IpcProcessError::from)?;
1,063✔
173

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

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

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

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

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

187
    if std::cfg!(debug_assertions) {
1,063✔
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,063✔
189
        cmd.env_remove("LLVM_PROFILE_FILE")
1,063✔
190
            .env_remove("LLVM_PROFILE_FILE_NAME");
1,063✔
191
    }
1,063✔
192

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

195
    let (_rx, channels) = server.accept().map_err(IpcProcessError::from)?;
1,063✔
196
    Ok((ChildProcessGuard { child }, channels.0, channels.1))
1,063✔
197
}
1,063✔
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.
NEW
203
pub fn setup_client<S, C>(
×
NEW
204
    token: String,
×
NEW
205
) -> crate::util::Result<(IpcSender<C>, IpcReceiver<S>), IpcProcessError>
×
NEW
206
where
×
NEW
207
    S: for<'de> serde::Deserialize<'de> + serde::Serialize,
×
NEW
208
    C: for<'de> serde::Deserialize<'de> + serde::Serialize,
×
209
{
NEW
210
    let (server_sender, client_reciever) = ipc::channel::<S>()?;
×
NEW
211
    let (client_sender, server_reciever) = ipc::channel::<C>()?;
×
212

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

NEW
215
    sender.send((server_sender, server_reciever))?;
×
NEW
216
    Ok((client_sender, client_reciever))
×
NEW
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,
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

NEW
268
    pub fn get(&mut self, params: &GdalDatasetParameters) -> Option<&mut GdalDataset> {
×
NEW
269
        if Self::is_hit(self.params.as_ref(), params) {
×
NEW
270
            self.dataset.as_mut()
×
271
        } else {
NEW
272
            None
×
273
        }
NEW
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✔
NEW
294
            let _span = tracing::debug_span!("gdal_dataset_cache_hit").entered();
×
NEW
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✔
NEW
319
            current_ds_params.file_path == other.file_path
×
NEW
320
                && current_ds_params.gdal_open_options == other.gdal_open_options
×
NEW
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

NEW
336
    pub fn contains(&self, params: &GdalDatasetParameters) -> bool {
×
NEW
337
        Self::is_hit(self.params.as_ref(), params)
×
NEW
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_vsi_curl = dataset_params.is_vis_curl();
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_vsi_curl {
3✔
387
                            Self::clear_gdal_vsi_cache_for_path(dp.file_path.as_path());
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_vsi_curl {
1✔
NEW
395
                        Self::clear_gdal_vsi_cache_for_path(dp.file_path.as_path());
×
396
                    }
1✔
397
                })
1✔
398
            },
4✔
399
            // If the file explicitly does not exist, do not waste time retrying
NEW
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✔
NEW
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✔
NEW
442
            debug!("all pixels are valid --> skip no-data and mask handling.");
×
NEW
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✔
NEW
457
            }
×
NEW
458
            return Ok(GdalDataGridVariant::AllValid { data: buffer_data });
×
NEW
459
        }
×
460

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

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

NEW
470
        let mask_band = rasterband.open_mask_band()?;
×
NEW
471
        let mask_buffer = mask_band.read_as::<u8>(
×
NEW
472
            read_window.gdal_window_start(), // pixelspace origin
×
NEW
473
            read_window.gdal_window_size(),  // pixelspace size
×
NEW
474
            gdal_out_shape,                  // requested raster size
×
NEW
475
            None,                            // sampling mode
×
NEW
476
        )?;
×
NEW
477
        let (_, mask_buffer_data) = mask_buffer.into_shape_and_vec();
×
NEW
478
        Ok(GdalDataGridVariant::WithExplicitMask {
×
NEW
479
            data: buffer_data,
×
NEW
480
            validity_mask: mask_buffer_data,
×
NEW
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✔
NEW
503
                    RasterPropertiesEntryType::Number => d.parse::<f64>().map_or_else(
×
NEW
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: {:?} ",
NEW
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,
NEW
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✔
NEW
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.clone()).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.clone()).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
                .map_err(IpcProcessError::from)?;
2✔
1008

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1278
    #[test]
1279
    #[serial_test::serial]
1280
    fn it_retries_only_after_clearing_vsi_cache() {
1✔
1281
        hide_gdal_errors();
1✔
1282

1283
        let server = Server::run();
1✔
1284

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

1296
        let file_path: PathBuf = format!("/vsicurl/{}", server.url_str("/foo.tif")).into();
1✔
1297

1298
        let options = Some(vec![
1✔
1299
            (
1✔
1300
                "CPL_VSIL_CURL_ALLOWED_EXTENSIONS".to_owned(),
1✔
1301
                ".tif".to_owned(),
1✔
1302
            ),
1✔
1303
            (
1✔
1304
                "GDAL_DISABLE_READDIR_ON_OPEN".to_owned(),
1✔
1305
                "EMPTY_DIR".to_owned(),
1✔
1306
            ),
1✔
1307
            ("GDAL_HTTP_NETRC".to_owned(), "NO".to_owned()),
1✔
1308
            ("GDAL_HTTP_MAX_RETRY".to_owned(), "0".to_string()),
1✔
1309
        ]);
1✔
1310

1311
        let _thread_local_configs = options
1✔
1312
            .as_ref()
1✔
1313
            .map(|config_options| GdalConfigOptions::new(config_options));
1✔
1314

1315
        // first fail
1316
        let result = gdal_open_ex_gdal_error(
1✔
1317
            file_path.as_path(),
1✔
1318
            DatasetOptions {
1✔
1319
                open_flags: GdalOpenFlags::GDAL_OF_RASTER,
1✔
1320
                ..DatasetOptions::default()
1✔
1321
            },
1✔
1322
        );
1323

1324
        // it failed, but not with file not found
1325
        assert!(result.is_err());
1✔
1326
        if let Err(error) = result {
1✔
1327
            assert!(!GdalHandling::error_is_gdal_file_not_found(&error));
1✔
NEW
1328
        }
×
1329

1330
        // second fail doesn't even try, so still not "file not found", even though it should be now
1331
        let result = gdal_open_ex_gdal_error(
1✔
1332
            file_path.as_path(),
1✔
1333
            DatasetOptions {
1✔
1334
                open_flags: GdalOpenFlags::GDAL_OF_RASTER,
1✔
1335
                ..DatasetOptions::default()
1✔
1336
            },
1✔
1337
        );
1338

1339
        assert!(result.is_err());
1✔
1340
        if let Err(error) = result {
1✔
1341
            assert!(!GdalHandling::error_is_gdal_file_not_found(&error));
1✔
NEW
1342
        }
×
1343

1344
        GdalHandling::clear_gdal_vsi_cache_for_path(file_path.as_path());
1✔
1345

1346
        // after clearing the cache, it tries again
1347
        let result = gdal_open_ex_gdal_error(
1✔
1348
            file_path.as_path(),
1✔
1349
            DatasetOptions {
1✔
1350
                open_flags: GdalOpenFlags::GDAL_OF_RASTER,
1✔
1351
                ..DatasetOptions::default()
1✔
1352
            },
1✔
1353
        );
1354

1355
        // now we get the file not found error
1356
        assert!(result.is_err());
1✔
1357
        if let Err(error) = result {
1✔
1358
            assert!(GdalHandling::error_is_gdal_file_not_found(&error));
1✔
NEW
1359
        }
×
1360
    }
1361

1362
    #[test]
1363
    #[serial_test::serial]
1364
    #[allow(clippy::too_many_lines)]
1365
    fn read_up_side_down_raster() {
1✔
1366
        let up_side_down_params = GdalDatasetParameters {
1✔
1367
            file_path: test_data!(
1✔
1368
                "raster/modis_ndvi/flipped_axis_y/MOD13A2_M_NDVI_2014-01-01_flipped_y.tiff"
1✔
1369
            )
1✔
1370
            .into(),
1✔
1371
            rasterband_channel: 1,
1✔
1372
            geo_transform: GdalDatasetGeoTransform {
1✔
1373
                origin_coordinate: (-180., -90.).into(),
1✔
1374
                x_pixel_size: 0.1,
1✔
1375
                y_pixel_size: 0.1,
1✔
1376
            },
1✔
1377
            width: 3600,
1✔
1378
            height: 1800,
1✔
1379
            file_not_found_handling: FileNotFoundHandling::NoData,
1✔
1380
            no_data_value: Some(0.),
1✔
1381
            properties_mapping: Some(vec![
1✔
1382
                GdalMetadataMapping {
1✔
1383
                    source_key: RasterPropertiesKey {
1✔
1384
                        domain: None,
1✔
1385
                        key: "AREA_OR_POINT".to_string(),
1✔
1386
                    },
1✔
1387
                    target_type: RasterPropertiesEntryType::String,
1✔
1388
                    target_key: RasterPropertiesKey {
1✔
1389
                        domain: None,
1✔
1390
                        key: "AREA_OR_POINT".to_string(),
1✔
1391
                    },
1✔
1392
                },
1✔
1393
                GdalMetadataMapping {
1✔
1394
                    source_key: RasterPropertiesKey {
1✔
1395
                        domain: Some("IMAGE_STRUCTURE".to_string()),
1✔
1396
                        key: "COMPRESSION".to_string(),
1✔
1397
                    },
1✔
1398
                    target_type: RasterPropertiesEntryType::String,
1✔
1399
                    target_key: RasterPropertiesKey {
1✔
1400
                        domain: Some("IMAGE_STRUCTURE_INFO".to_string()),
1✔
1401
                        key: "COMPRESSION".to_string(),
1✔
1402
                    },
1✔
1403
                },
1✔
1404
            ]),
1✔
1405
            gdal_open_options: None,
1✔
1406
            gdal_config_options: None,
1✔
1407
            allow_alphaband_as_mask: true,
1✔
1408
            retry: None,
1✔
1409
        };
1✔
1410

1411
        let ge_global_dataset_grid = SpatialGridDefinition::new(
1✔
1412
            GeoTransform::new(Coordinate2D::new(-180., 90.), 0.1, -0.1),
1✔
1413
            GridBoundingBox2D::new_min_max(0, 1799, 0, 3599).unwrap(),
1✔
1414
        );
1415

1416
        let gdal_dataset_grid = ge_global_dataset_grid.flip_axis_y(); // first, flip axis
1✔
1417
        assert_approx_eq!(
1✔
1418
            Coordinate2D,
1419
            gdal_dataset_grid.geo_transform().origin_coordinate,
1✔
1420
            Coordinate2D::new(-180., 90.)
1✔
1421
        );
1422
        assert_approx_eq!(f64, gdal_dataset_grid.geo_transform.y_pixel_size(), 0.1);
1✔
1423
        assert_approx_eq!(f64, gdal_dataset_grid.geo_transform.x_pixel_size(), 0.1);
1✔
1424
        assert_eq!(
1✔
1425
            gdal_dataset_grid.grid_bounds,
1426
            GridBoundingBox2D::new_min_max(-1800, -1, 0, 3599).unwrap()
1✔
1427
        );
1428

1429
        let gdal_dataset_grid = gdal_dataset_grid
1✔
1430
            .with_moved_origin_exact_grid(Coordinate2D::new(-180., -90.))
1✔
1431
            .unwrap(); // second, move origin (to other side of axis)
1✔
1432
        assert_approx_eq!(
1✔
1433
            Coordinate2D,
1434
            gdal_dataset_grid.geo_transform().origin_coordinate,
1✔
1435
            Coordinate2D::new(-180., -90.)
1✔
1436
        );
1437
        assert_approx_eq!(f64, gdal_dataset_grid.geo_transform.y_pixel_size(), 0.1);
1✔
1438
        assert_approx_eq!(f64, gdal_dataset_grid.geo_transform.x_pixel_size(), 0.1);
1✔
1439
        assert_eq!(
1✔
1440
            gdal_dataset_grid.grid_bounds,
1441
            GridBoundingBox2D::new_min_max(0, 1799, 0, 3599).unwrap()
1✔
1442
        );
1443

1444
        let gdal_read_advice = GdalReadAdvise {
1✔
1445
            gdal_read_widow: GdalReadWindow::new([1466, 1880].into(), [8, 8].into()),
1✔
1446
            read_window_bounds: GridBoundingBox2D::new([326, 1880], [326 + 7, 1880 + 7]).unwrap(),
1✔
1447
            bounds_of_target: GridBoundingBox2D::new([326, 1880], [326 + 7, 1880 + 7]).unwrap(),
1✔
1448
            flip_y: true,
1✔
1449
        };
1✔
1450

1451
        let mut gdc = GdalDatasetHolder::new();
1✔
1452
        let dataset = gdc.get_or_open(&up_side_down_params).unwrap();
1✔
1453

1454
        let reader_payload =
1✔
1455
            GdalHandling::load_tile_data::<u8>(dataset, &up_side_down_params, gdal_read_advice)
1✔
1456
                .unwrap();
1✔
1457

1458
        let GridAndProperties { grid, properties } = reader_payload.into();
1✔
1459
        assert!(!grid.is_empty());
1✔
1460
        let grid = grid.into_materialized_masked_grid();
1✔
1461

1462
        assert_eq!(grid.inner_grid.data.len(), 64);
1✔
1463

1464
        assert_eq!(
1✔
1465
            grid.inner_grid.data,
1466
            &[
1467
                // this is not yet flipped!
1468
                255, 47, 42, 82, 81, 76, 73, 98, 255, 255, 59, 95, 85, 66, 105, 104, 255, 255, 91,
1469
                97, 100, 86, 78, 106, 255, 255, 255, 97, 102, 91, 73, 72, 255, 255, 255, 255, 255,
1470
                68, 81, 93, 255, 255, 255, 255, 255, 255, 53, 47, 255, 255, 255, 255, 255, 255,
1471
                255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
1472
            ]
1473
        );
1474

1475
        assert_eq!(grid.validity_mask.data.len(), 64);
1✔
1476
        assert_eq!(grid.validity_mask.data, &[true; 64]);
1✔
1477

1478
        assert!(properties.offset_option().is_none());
1✔
1479
        assert!(properties.scale_option().is_none());
1✔
1480
    }
1481

1482
    #[test]
1483
    #[serial_test::serial]
1484
    fn read_raster_and_offset_scale() {
1✔
1485
        let up_side_down_params = GdalDatasetParameters {
1✔
1486
            file_path: test_data!("raster/modis_ndvi/cropped/MOD13A2_M_NDVI_2014-04-01_30x30.tif")
1✔
1487
                .into(),
1✔
1488
            rasterband_channel: 1,
1✔
1489
            geo_transform: GdalDatasetGeoTransform {
1✔
1490
                origin_coordinate: (8.0, 57.4).into(),
1✔
1491
                x_pixel_size: 0.1,
1✔
1492
                y_pixel_size: -0.1,
1✔
1493
            },
1✔
1494
            width: 30,
1✔
1495
            height: 30,
1✔
1496
            file_not_found_handling: FileNotFoundHandling::NoData,
1✔
1497
            no_data_value: Some(255.),
1✔
1498
            properties_mapping: None,
1✔
1499
            gdal_open_options: None,
1✔
1500
            gdal_config_options: None,
1✔
1501
            allow_alphaband_as_mask: true,
1✔
1502
            retry: None,
1✔
1503
        };
1✔
1504

1505
        let gdal_read_advice = GdalReadAdvise {
1✔
1506
            gdal_read_widow: GdalReadWindow::new([0, 0].into(), [8, 8].into()),
1✔
1507
            read_window_bounds: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1508
            bounds_of_target: GridBoundingBox2D::new([0, 0], [7, 7]).unwrap(),
1✔
1509
            flip_y: false,
1✔
1510
        };
1✔
1511

1512
        let mut gdc = GdalDatasetHolder::new();
1✔
1513
        let dataset = gdc.get_or_open(&up_side_down_params).unwrap();
1✔
1514

1515
        let GridAndProperties { grid, properties } =
1✔
1516
            GdalHandling::load_tile_data::<u8>(dataset, &up_side_down_params, gdal_read_advice)
1✔
1517
                .unwrap()
1✔
1518
                .into();
1✔
1519

1520
        assert!(!grid.is_empty());
1✔
1521

1522
        let grid = grid.into_materialized_masked_grid();
1✔
1523

1524
        assert_eq!(grid.inner_grid.data.len(), 64);
1✔
1525
        // pixel value are the top left 8x8 block from MOD13A2_M_NDVI_2014-04-01_27x27_bytes.txt
1526
        assert_eq!(
1✔
1527
            grid.inner_grid.data,
1528
            &[
1529
                255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
1530
                255, 255, 255, 255, 255, 255, 127, 107, 255, 255, 255, 255, 255, 164, 185, 182,
1531
                255, 255, 255, 175, 186, 190, 167, 140, 255, 255, 161, 175, 184, 173, 170, 188,
1532
                255, 255, 128, 177, 165, 145, 191, 174, 255, 117, 100, 174, 159, 147, 99, 135
1533
            ]
1534
        );
1535

1536
        assert_eq!(grid.validity_mask.data.len(), 64);
1✔
1537
        // pixel mask is pixel > 0 from the top left 8x8 block from MOD13A2_M_NDVI_2014-04-01_27x27_bytes.txt
1538
        assert_eq!(
1✔
1539
            grid.validity_mask.data,
1540
            &[
1541
                false, false, false, false, false, false, false, false, false, false, false, false,
1542
                false, false, false, false, false, false, false, false, false, false, true, true,
1543
                false, false, false, false, false, true, true, true, false, false, false, true,
1544
                true, true, true, true, false, false, true, true, true, true, true, true, false,
1545
                false, true, true, true, true, true, true, false, true, true, true, true, true,
1546
                true, true
1547
            ]
1548
        );
1549

1550
        assert_eq!(properties.offset_option(), Some(1.));
1✔
1551
        assert_eq!(properties.scale_option(), Some(2.));
1✔
1552

1553
        assert!(approx_eq!(f64, properties.offset(), 1.));
1✔
1554
        assert!(approx_eq!(f64, properties.scale(), 2.));
1✔
1555
    }
1556
}
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