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

geo-engine / geoengine / 29338235936

14 Jul 2026 01:17PM UTC coverage: 87.769% (-0.005%) from 87.774%
29338235936

push

github

web-flow
fix: add resolution to python down/up sampling operators (#1199)

* fix: add resolution and outputOriginReference to python down/up sampling operators

- Added output_origin_reference support to Interpolation and Downsampling
- Fix duplicate 'fraction' branch bug in outputResolution parser
- Add Downsampling to RasterOperator dispatch
- Add websocket error logging in workflows.rs
- Add inspect_err on tile stream

* feat(backend): add Downsampling operator and Fraction type to API model

Also update www plugin to extract oneOf descriptions for operator parameter tables.

* chore(api-clients): regenerate OpenAPI spec, API clients, and docs

* remove commented-out assert

* fix: deduplicate Fraction1/Fraction2 in generated API clients

Remove variant-level doc comments from Fraction(Fraction) in both
InterpolationResolution and DownsamplingResolution enums. The
Fraction struct's own field docs already describe the data — the
context-specific descriptions ('Upscale factor' / 'Downscale factor')
caused the OpenAPI generator to create separate Fraction1 and Fraction2
classes for structurally identical allOf wrappers.

With identical allOf wrappers the generator now produces a single shared
Fraction1 (the tagged variant with the 'type: fraction' discriminator)
used by both enums.

* revert: remove non-essential changes from PR

- Revert websocket error logging (workflows.rs, websocket_stream.rs)
- Revert Jupyter notebook metadata changes (interpolation.ipynb)
- Revert unrelated TemporalRasterAggregation doc updates
- Revert auto-generated python types.md to_api_dict docs

These changes are not essential to the Downsampling feature.

* revert: non-essential docs improvements

Revert Interpolation.md description update and astro-openapi-plugin.ts
oneOf fallback — not essential to the Downsampling feature.

* revert: non-essential types.py changes

Revert SpatialResolution typed dict, assert deletion, and GeoTransform
docstring — not required for the Downsampli... (continued)

72 of 97 new or added lines in 5 files covered. (74.23%)

2 existing lines in 1 file now uncovered.

121729 of 138692 relevant lines covered (87.77%)

471993.26 hits per line

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

90.97
/geoengine/operators/src/processing/downsample/mod.rs
1
use crate::adapters::{
2
    FoldTileAccu, FoldTileAccuMut, RasterSubQueryAdapter, SubQueryTileAggregator,
3
};
4
use crate::engine::{
5
    CanonicOperatorName, ExecutionContext, InitializedRasterOperator, InitializedSources, Operator,
6
    OperatorName, QueryContext, QueryProcessor, RasterOperator, RasterQueryProcessor,
7
    RasterResultDescriptor, SingleRasterSource, TypedRasterQueryProcessor, WorkflowOperatorPath,
8
};
9
use crate::optimization::{OptimizableOperator, OptimizationError};
10
use crate::util::Result;
11
use async_trait::async_trait;
12
use futures::future::BoxFuture;
13
use futures::stream::BoxStream;
14
use futures::{Future, FutureExt, TryFuture, TryFutureExt};
15
use geoengine_datatypes::primitives::{
16
    BandSelection, CacheHint, Coordinate2D, find_next_best_overview_level_resolution,
17
};
18
use geoengine_datatypes::primitives::{RasterQueryRectangle, SpatialResolution, TimeInterval};
19
use geoengine_datatypes::raster::{
20
    ChangeGridBounds, GeoTransform, GridBoundingBox2D, GridContains, GridIdx2D, GridIndexAccess,
21
    GridOrEmpty, Pixel, RasterTile2D, TileInformation, TilingSpecification,
22
    UpdateIndexedElementsParallel,
23
};
24
use rayon::ThreadPool;
25
use serde::{Deserialize, Serialize};
26
use snafu::{Snafu, ensure};
27
use std::marker::PhantomData;
28
use std::sync::Arc;
29

30
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
31
#[serde(rename_all = "camelCase")]
32
pub struct DownsamplingParams {
33
    pub sampling_method: DownsamplingMethod,
34
    pub output_resolution: DownsamplingResolution,
35
    pub output_origin_reference: Option<Coordinate2D>,
36
}
37

38
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
39
#[serde(rename_all = "camelCase")]
40
pub struct Fraction {
41
    pub x: f64,
42
    pub y: f64,
43
}
44

45
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
46
#[serde(rename_all = "camelCase", tag = "type")]
47
pub enum DownsamplingResolution {
48
    Resolution(SpatialResolution),
49
    Fraction(Fraction),
50
}
51

52
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
53
#[serde(rename_all = "camelCase")]
54
pub enum DownsamplingMethod {
55
    NearestNeighbor,
56
    // Mean,
57
}
58

59
#[derive(Debug, Snafu)]
60
#[snafu(visibility(pub(crate)), context(suffix(false)), module(error))]
61
pub enum DownsamplingError {
62
    #[snafu(display("The fraction used to downsample must be >= 1, was {f}."))]
63
    FractionMustBeOneOrLarger { f: f64 },
64
    #[snafu(display("The output resolution must be higher than the input resolution."))]
65
    OutputMustBeLowerResolutionThanInput {
66
        input: SpatialResolution,
67
        output: SpatialResolution,
68
    },
69
}
70

71
pub type Downsampling = Operator<DownsamplingParams, SingleRasterSource>;
72

73
impl OperatorName for Downsampling {
74
    const TYPE_NAME: &'static str = "Downsampling";
75
}
76

77
#[typetag::serde]
×
78
#[async_trait]
79
impl RasterOperator for Downsampling {
80
    async fn _initialize(
81
        self: Box<Self>,
82
        path: WorkflowOperatorPath,
83
        context: &dyn ExecutionContext,
84
    ) -> Result<Box<dyn InitializedRasterOperator>> {
10✔
85
        let name = CanonicOperatorName::from(&self);
86
        let initialized_source = self
87
            .sources
88
            .initialize_sources(path.clone(), context)
89
            .await?;
90
        InitializedDownsampling::new_with_source_and_params(
91
            name,
92
            path,
93
            initialized_source.raster,
94
            self.params,
95
            context.tiling_specification(),
96
        )
97
        .map(InitializedRasterOperator::boxed)
98
    }
10✔
99

100
    span_fn!(Downsampling);
101
}
102

103
pub struct InitializedDownsampling<O: InitializedRasterOperator> {
104
    name: CanonicOperatorName,
105
    path: WorkflowOperatorPath,
106
    output_result_descriptor: RasterResultDescriptor,
107
    raster_source: O,
108
    sampling_method: DownsamplingMethod,
109
    tiling_specification: TilingSpecification,
110
}
111

112
impl<O: InitializedRasterOperator> InitializedDownsampling<O> {
113
    pub fn new_with_source_and_params(
10✔
114
        name: CanonicOperatorName,
10✔
115
        path: WorkflowOperatorPath,
10✔
116
        raster_source: O,
10✔
117
        params: DownsamplingParams,
10✔
118
        tiling_specification: TilingSpecification,
10✔
119
    ) -> Result<Self> {
10✔
120
        let in_descriptor = raster_source.result_descriptor();
10✔
121

122
        let in_spatial_grid = in_descriptor.spatial_grid_descriptor();
10✔
123

124
        let output_resolution = match params.output_resolution {
10✔
125
            DownsamplingResolution::Resolution(res) => {
10✔
126
                ensure!(
10✔
127
                    res.x.abs() >= in_spatial_grid.spatial_resolution().x.abs(),
10✔
128
                    error::OutputMustBeLowerResolutionThanInput {
×
129
                        input: in_spatial_grid.spatial_resolution(),
×
130
                        output: res
×
131
                    }
×
132
                );
133
                ensure!(
10✔
134
                    res.y.abs() >= in_spatial_grid.spatial_resolution().y.abs(), // TODO: allow neg y size in SpatialResolution
10✔
135
                    error::OutputMustBeLowerResolutionThanInput {
×
136
                        input: in_spatial_grid.spatial_resolution(),
×
137
                        output: res
×
138
                    }
×
139
                );
140
                res
10✔
141
            }
142

NEW
143
            DownsamplingResolution::Fraction(fraction) => {
×
NEW
144
                ensure!(
×
NEW
145
                    fraction.x >= 1.0,
×
NEW
146
                    error::FractionMustBeOneOrLarger { f: fraction.x }
×
147
                );
NEW
148
                ensure!(
×
NEW
149
                    fraction.y >= 1.0,
×
NEW
150
                    error::FractionMustBeOneOrLarger { f: fraction.y }
×
151
                );
152

153
                SpatialResolution::new_unchecked(
×
NEW
154
                    in_spatial_grid.spatial_resolution().x * fraction.x,
×
NEW
155
                    in_spatial_grid.spatial_resolution().y.abs() * fraction.y, // TODO: allow negative size
×
156
                )
157
            }
158
        };
159

160
        let output_gspatial_grid = if let Some(oc) = params.output_origin_reference {
10✔
161
            in_spatial_grid
×
162
                .with_moved_origin_to_nearest_grid_edge(oc)
×
163
                .replace_origin(oc)
×
164
                .with_changed_resolution(output_resolution)
×
165
        } else {
166
            in_spatial_grid.with_changed_resolution(output_resolution)
10✔
167
        };
168

169
        let out_descriptor = RasterResultDescriptor {
10✔
170
            spatial_reference: in_descriptor.spatial_reference,
10✔
171
            data_type: in_descriptor.data_type, // TODO: datatype depends on resample method!
10✔
172
            time: in_descriptor.time,
10✔
173
            spatial_grid: output_gspatial_grid,
10✔
174
            bands: in_descriptor.bands.clone(),
10✔
175
        };
10✔
176

177
        Ok(InitializedDownsampling {
10✔
178
            name,
10✔
179
            path,
10✔
180
            output_result_descriptor: out_descriptor,
10✔
181
            raster_source,
10✔
182
            sampling_method: params.sampling_method,
10✔
183
            tiling_specification,
10✔
184
        })
10✔
185
    }
10✔
186
}
187

188
impl<O: InitializedRasterOperator> InitializedRasterOperator for InitializedDownsampling<O> {
189
    fn query_processor(&self) -> Result<TypedRasterQueryProcessor> {
5✔
190
        let source_processor = self.raster_source.query_processor()?;
5✔
191

192
        let res = call_on_generic_raster_processor!(
5✔
193
            source_processor, p => match self.sampling_method  {
5✔
194
                DownsamplingMethod::NearestNeighbor => DownsampleProcessor::<_,_>::new(
5✔
195
                        p,
5✔
196
                        self.output_result_descriptor.clone(),
5✔
197
                        self.tiling_specification,
5✔
198
                    ).boxed()
5✔
199
                    .into(),
5✔
200
            }
201
        );
202

203
        Ok(res)
5✔
204
    }
5✔
205

206
    fn result_descriptor(&self) -> &RasterResultDescriptor {
13✔
207
        &self.output_result_descriptor
13✔
208
    }
13✔
209

210
    fn canonic_name(&self) -> CanonicOperatorName {
×
211
        self.name.clone()
×
212
    }
×
213

214
    fn name(&self) -> &'static str {
3✔
215
        Downsampling::TYPE_NAME
3✔
216
    }
3✔
217

218
    fn path(&self) -> WorkflowOperatorPath {
3✔
219
        self.path.clone()
3✔
220
    }
3✔
221

222
    fn optimize(
2✔
223
        &self,
2✔
224
        target_resolution: SpatialResolution,
2✔
225
    ) -> Result<Box<dyn RasterOperator>, OptimizationError> {
2✔
226
        self.ensure_resolution_is_compatible_for_optimization(target_resolution)?;
2✔
227

228
        let out_descriptor = self.result_descriptor();
2✔
229
        let in_descriptor = self.raster_source.result_descriptor();
2✔
230

231
        let input_resolution = in_descriptor.spatial_grid.spatial_resolution();
2✔
232

233
        let new_origin = if in_descriptor.spatial_grid.geo_transform().origin_coordinate
2✔
234
            == out_descriptor
2✔
235
                .spatial_grid
2✔
236
                .geo_transform()
2✔
237
                .origin_coordinate
2✔
238
        {
239
            None
2✔
240
        } else {
241
            Some(
×
242
                out_descriptor
×
243
                    .spatial_grid
×
244
                    .geo_transform()
×
245
                    .origin_coordinate,
×
246
            )
×
247
        };
248

249
        if input_resolution == target_resolution {
2✔
250
            // special case where downsampling becomes redundant, unless it also regrids
251

252
            // TODO: source does not need to be optimized, but we need it as an `RasterOperator` and not `InitializedRasterOperator`
253
            let optimzed_source = self.raster_source.optimize(target_resolution)?;
×
254

255
            if new_origin.is_some() {
×
256
                return Ok(Downsampling {
×
257
                    params: DownsamplingParams {
×
258
                        sampling_method: self.sampling_method,
×
259
                        output_resolution: DownsamplingResolution::Resolution(target_resolution),
×
260
                        output_origin_reference: new_origin,
×
261
                    },
×
262
                    sources: SingleRasterSource {
×
263
                        raster: optimzed_source,
×
264
                    },
×
265
                }
×
266
                .boxed());
×
267
            }
×
268
            return Ok(optimzed_source);
×
269
        }
2✔
270

271
        // target resolution must be coarser than input
272
        debug_assert!(input_resolution < target_resolution);
2✔
273

274
        let snapped_input_resolution =
2✔
275
            find_next_best_overview_level_resolution(input_resolution, target_resolution);
2✔
276

277
        let optimzed_source = self.raster_source.optimize(snapped_input_resolution)?;
2✔
278

279
        Ok(Downsampling {
2✔
280
            params: DownsamplingParams {
2✔
281
                sampling_method: self.sampling_method,
2✔
282
                output_resolution: DownsamplingResolution::Resolution(target_resolution),
2✔
283
                output_origin_reference: new_origin,
2✔
284
            },
2✔
285
            sources: SingleRasterSource {
2✔
286
                raster: optimzed_source,
2✔
287
            },
2✔
288
        }
2✔
289
        .boxed())
2✔
290
    }
2✔
291
}
292

293
pub struct DownsampleProcessor<Q, P>
294
where
295
    Q: RasterQueryProcessor<RasterType = P>,
296
    P: Copy,
297
{
298
    source: Q,
299
    out_result_descriptor: RasterResultDescriptor,
300
    tiling_specification: TilingSpecification,
301
}
302

303
impl<Q, P> DownsampleProcessor<Q, P>
304
where
305
    Q: RasterQueryProcessor<RasterType = P>,
306
    P: Copy,
307
{
308
    pub fn new(
5✔
309
        source: Q,
5✔
310
        out_result_descriptor: RasterResultDescriptor,
5✔
311
        tiling_specification: TilingSpecification,
5✔
312
    ) -> Self {
5✔
313
        Self {
5✔
314
            source,
5✔
315
            out_result_descriptor,
5✔
316
            tiling_specification,
5✔
317
        }
5✔
318
    }
5✔
319
}
320

321
#[async_trait]
322
impl<Q, P> RasterQueryProcessor for DownsampleProcessor<Q, P>
323
where
324
    Q: RasterQueryProcessor<RasterType = P>,
325
    P: Pixel,
326
{
327
    type RasterType = P;
328

329
    async fn _time_query<'a>(
330
        &'a self,
331
        query: geoengine_datatypes::primitives::TimeInterval,
332
        ctx: &'a dyn QueryContext,
333
    ) -> Result<BoxStream<'a, Result<geoengine_datatypes::primitives::TimeInterval>>> {
2✔
334
        self.source.time_query(query, ctx).await
335
    }
2✔
336
}
337

338
#[async_trait]
339
impl<Q, P> QueryProcessor for DownsampleProcessor<Q, P>
340
where
341
    Q: RasterQueryProcessor<RasterType = P>,
342
    P: Pixel,
343
{
344
    type Output = RasterTile2D<P>;
345
    type SpatialBounds = GridBoundingBox2D;
346
    type Selection = BandSelection;
347
    type ResultDescription = RasterResultDescriptor;
348

349
    async fn _query<'a>(
350
        &'a self,
351
        query: RasterQueryRectangle,
352
        ctx: &'a dyn QueryContext,
353
    ) -> Result<BoxStream<'a, Result<Self::Output>>> {
5✔
354
        // do not interpolate if the source resolution is already fine enough
355

356
        let in_spatial_grid = self.source.result_descriptor().spatial_grid_descriptor();
357
        let out_spatial_grid = self.result_descriptor().spatial_grid_descriptor();
358

359
        // if the output resolution is the same as the input resolution, we can just forward the query // TODO: except the origin changes?
360
        if in_spatial_grid == out_spatial_grid {
361
            return self.source.query(query, ctx).await;
362
        }
363

364
        let tiling_grid_definition =
365
            out_spatial_grid.tiling_grid_definition(ctx.tiling_specification());
366
        // This is the tiling strategy we want to fill
367
        let tiling_strategy: geoengine_datatypes::raster::TilingStrategy =
368
            tiling_grid_definition.generate_data_tiling_strategy();
369

370
        let sub_query = DownsampleSubQuery::<_, P> {
371
            input_geo_transform: in_spatial_grid
372
                .tiling_grid_definition(ctx.tiling_specification())
373
                .tiling_geo_transform(),
374
            output_geo_transform: tiling_grid_definition.tiling_geo_transform(),
375
            fold_fn: fold_future,
376
            tiling_specification: self.tiling_specification,
377
            _phantom_pixel_type: PhantomData,
378
        };
379

380
        let time_stream = self.time_query(query.time_interval(), ctx).await?;
381

382
        Ok(Box::pin(RasterSubQueryAdapter::<'a, P, _, _, _>::new(
383
            &self.source,
384
            query,
385
            tiling_strategy,
386
            ctx,
387
            sub_query,
388
            time_stream,
389
        )))
390
    }
5✔
391

392
    fn result_descriptor(&self) -> &RasterResultDescriptor {
26✔
393
        &self.out_result_descriptor
26✔
394
    }
26✔
395
}
396

397
#[derive(Debug, Clone)]
398
pub struct DownsampleSubQuery<F, T> {
399
    input_geo_transform: GeoTransform,
400
    output_geo_transform: GeoTransform,
401
    fold_fn: F,
402
    tiling_specification: TilingSpecification,
403
    _phantom_pixel_type: PhantomData<T>,
404
}
405

406
impl<'a, T, FoldM, FoldF> SubQueryTileAggregator<'a, T> for DownsampleSubQuery<FoldM, T>
407
where
408
    T: Pixel,
409
    FoldM: Send + Sync + 'a + Clone + Fn(DownsampleAccu<T>, RasterTile2D<T>) -> FoldF,
410
    FoldF: Send + TryFuture<Ok = DownsampleAccu<T>, Error = crate::error::Error>,
411
{
412
    type FoldFuture = FoldF;
413

414
    type FoldMethod = FoldM;
415

416
    type TileAccu = DownsampleAccu<T>;
417
    type TileAccuFuture = BoxFuture<'a, Result<Self::TileAccu>>;
418

419
    fn new_fold_accu(
22✔
420
        &self,
22✔
421
        tile_info: TileInformation,
22✔
422
        query_rect: RasterQueryRectangle,
22✔
423
        pool: &Arc<ThreadPool>,
22✔
424
    ) -> Self::TileAccuFuture {
22✔
425
        create_accu(
22✔
426
            self.input_geo_transform,
22✔
427
            self.output_geo_transform,
22✔
428
            tile_info,
22✔
429
            &query_rect,
22✔
430
            pool.clone(),
22✔
431
            self.tiling_specification,
22✔
432
        )
22✔
433
        .boxed()
22✔
434
    }
22✔
435

436
    fn tile_query_rectangle(
22✔
437
        &self,
22✔
438
        tile_info: TileInformation,
22✔
439
        _query_rect: RasterQueryRectangle,
22✔
440
        time: TimeInterval,
22✔
441
        band_idx: u32,
22✔
442
    ) -> Result<Option<RasterQueryRectangle>> {
22✔
443
        let out_tile_pixel_bounds = tile_info.global_pixel_bounds();
22✔
444
        //.intersection(&query_rect.spatial_query.grid_bounds());
445
        //let out_tile_pixel_bounds = out_tile_pixel_bounds;
446
        let out_tile_spatial_bounds = self
22✔
447
            .output_geo_transform
22✔
448
            .grid_to_spatial_bounds(&out_tile_pixel_bounds);
22✔
449
        let input_pixel_bounds = self
22✔
450
            .input_geo_transform
22✔
451
            .spatial_to_grid_bounds(&out_tile_spatial_bounds);
22✔
452

453
        Ok(Some(RasterQueryRectangle::new(
22✔
454
            input_pixel_bounds,
22✔
455
            time,
22✔
456
            BandSelection::new_single(band_idx),
22✔
457
        )))
22✔
458
    }
22✔
459

460
    fn fold_method(&self) -> Self::FoldMethod {
22✔
461
        self.fold_fn.clone()
22✔
462
    }
22✔
463
}
464

465
#[derive(Clone, Debug)]
466
pub struct DownsampleAccu<T: Pixel> {
467
    pub output_tile_info: TileInformation,
468
    pub output_grid: GridOrEmpty<GridBoundingBox2D, T>,
469
    pub input_global_geo_transform: GeoTransform,
470

471
    pub time: Option<TimeInterval>,
472
    pub cache_hint: CacheHint,
473
    pub pool: Arc<ThreadPool>,
474
}
475

476
impl<T: Pixel> DownsampleAccu<T> {
477
    pub fn new(
22✔
478
        output_tile_info: TileInformation,
22✔
479
        input_global_geo_transform: GeoTransform,
22✔
480
        time: Option<TimeInterval>,
22✔
481
        cache_hint: CacheHint,
22✔
482
        pool: Arc<ThreadPool>,
22✔
483
    ) -> Self {
22✔
484
        DownsampleAccu {
22✔
485
            output_tile_info,
22✔
486
            output_grid: GridOrEmpty::new_empty_shape(output_tile_info.global_pixel_bounds()),
22✔
487
            input_global_geo_transform,
22✔
488
            time,
22✔
489
            cache_hint,
22✔
490
            pool,
22✔
491
        }
22✔
492
    }
22✔
493
}
494

495
#[async_trait]
496
impl<T: Pixel> FoldTileAccu for DownsampleAccu<T> {
497
    type RasterType = T;
498

499
    async fn into_tile(self) -> Result<RasterTile2D<Self::RasterType>> {
22✔
500
        // TODO: later do conversation of accu into tile here
501

502
        let output_tile = RasterTile2D::new_with_tile_info(
503
            self.time.expect("there is at least one input"),
504
            self.output_tile_info,
505
            0, // TODO: need band?
506
            self.output_grid.unbounded(),
507
            self.cache_hint,
508
        );
509

510
        Ok(output_tile)
511
    }
22✔
512

513
    fn thread_pool(&self) -> &Arc<ThreadPool> {
×
514
        &self.pool
×
515
    }
×
516
}
517

518
impl<T: Pixel> FoldTileAccuMut for DownsampleAccu<T> {
519
    fn set_time(&mut self, time: TimeInterval) {
102✔
520
        self.time = Some(time);
102✔
521
    }
102✔
522

523
    fn set_cache_hint(&mut self, cache_hint: CacheHint) {
×
524
        self.cache_hint = cache_hint;
×
525
    }
×
526
}
527

528
pub fn create_accu<T: Pixel>(
22✔
529
    input_geo_transform: GeoTransform,
22✔
530
    _output_geo_transform: GeoTransform,
22✔
531
    tile_info: TileInformation,
22✔
532
    _query_rect: &RasterQueryRectangle,
22✔
533
    pool: Arc<ThreadPool>,
22✔
534
    _tiling_specification: TilingSpecification,
22✔
535
) -> impl Future<Output = Result<DownsampleAccu<T>>> + use<T> {
22✔
536
    crate::util::spawn_blocking(move || {
22✔
537
        DownsampleAccu::new(
22✔
538
            tile_info,
22✔
539
            input_geo_transform,
22✔
540
            None,
22✔
541
            CacheHint::max_duration(),
22✔
542
            pool.clone(),
22✔
543
        )
544
    })
22✔
545
    .map_err(From::from)
22✔
546
}
22✔
547

548
pub fn fold_future<T>(
102✔
549
    accu: DownsampleAccu<T>,
102✔
550
    tile: RasterTile2D<T>,
102✔
551
) -> impl Future<Output = Result<DownsampleAccu<T>>>
102✔
552
where
102✔
553
    T: Pixel,
102✔
554
{
555
    crate::util::spawn_blocking_with_thread_pool(accu.pool.clone(), || fold_impl(accu, tile)).then(
102✔
556
        |x| async move {
102✔
557
            match x {
102✔
558
                Ok(r) => Ok(r),
102✔
559
                Err(e) => Err(e.into()),
×
560
            }
561
        },
204✔
562
    )
563
}
102✔
564

565
pub fn fold_impl<T>(mut accu: DownsampleAccu<T>, tile: RasterTile2D<T>) -> DownsampleAccu<T>
102✔
566
where
102✔
567
    T: Pixel,
102✔
568
{
569
    // get the time now because it is not known when the accu was created
570
    accu.set_time(tile.time);
102✔
571
    accu.cache_hint.merge_with(&tile.cache_hint);
102✔
572

573
    // TODO: add a skip if both tiles are empty?
574
    if tile.is_empty() {
102✔
575
        // TODO: and ignore no-data.
576
        return accu;
64✔
577
    }
38✔
578

579
    // copy all input tiles into the accu to have all data for interpolation
580
    let mut accu_tile = accu.output_grid.into_materialized_masked_grid();
38✔
581
    let in_tile_grid = tile.into_inner_positioned_grid();
38✔
582
    let accu_geo_transform = accu.output_tile_info.global_geo_transform;
38✔
583
    let in_geo_transform = accu.input_global_geo_transform;
38✔
584

585
    let map_fn = |grid_idx: GridIdx2D, current_value: Option<T>| -> Option<T> {
8,119,441✔
586
        let accu_pixel_coord = accu_geo_transform.grid_idx_to_pixel_center_coordinate_2d(grid_idx); // use center coordinate similar to ArcGIS
8,119,441✔
587
        let source_pixel_idx = in_geo_transform.coordinate_to_grid_idx_2d(accu_pixel_coord);
8,119,441✔
588

589
        if in_tile_grid.contains(&source_pixel_idx) {
8,119,441✔
590
            in_tile_grid.get_at_grid_index_unchecked(source_pixel_idx)
3,143,720✔
591
        } else {
592
            current_value
4,975,721✔
593
        }
594
    };
8,119,441✔
595

596
    accu_tile.update_indexed_elements_parallel(map_fn);
38✔
597

598
    accu.output_grid = accu_tile.into();
38✔
599

600
    accu
38✔
601
}
102✔
602

603
#[cfg(test)]
604
mod tests {
605
    use super::*;
606
    use crate::engine::{
607
        ChunkByteSize, MockExecutionContext, RasterBandDescriptors, SpatialGridDescriptor,
608
        TimeDescriptor,
609
    };
610
    use crate::mock::{MockRasterSource, MockRasterSourceParams};
611
    use futures::StreamExt;
612
    use geoengine_datatypes::primitives::TimeStep;
613
    use geoengine_datatypes::raster::{Grid, GridShape2D, RasterDataType};
614
    use geoengine_datatypes::spatial_reference::SpatialReference;
615
    use geoengine_datatypes::util::test::TestDefault;
616

617
    #[allow(clippy::too_many_lines)]
618
    #[tokio::test]
619
    async fn nearest_neighbor_4() {
1✔
620
        // In this test, 2x2 tiles with 4x4 pixels are downsampled using nearest neighbor to one tile with 4x4 pixels. The resolution is now 1/2 of the original resolution.
621
        // The test uses the following input:
622
        //
623
        // _1, _2, _3, _4 | 21, 22, 23, 24
624
        // _5, _6, _7, _8 | 25, 26, 27, 28
625
        // _9, 10, 11, 12 | 29, 30, 31, 32
626
        // 13, 14, 15, 16 | 33, 34, 35, 36
627
        // ---------------+---------------
628
        // 41, 42, 43, 44 | 61, 62, 63, 64
629
        // 45, 46, 47, 48 | 65, 66, 67, 68
630
        // 49, 50, 51, 52 | 69, 70, 71, 72
631
        // 53, 54, 55, 56 | 73, 74, 75, 76
632
        //
633
        // The input is downsampled to:
634
        //
635
        // _6. _8, 26, 28
636
        // 14, 16, 33, 36
637
        // 46, 48, 66, 68
638
        // 54, 56, 74, 76
639
        //
640
        // The center of each pixel is mapped to a coordinate. Then, for this coordinate the nearest pixel center is selected.
641
        // In this case, we have the special case that the pixel center of the target pixel hits the edge between two original pixels.
642
        // The pixel which "owns" the edge is selected because pixels are defiend from the upper left edge.
643
        // E.g. _6 is selected for the first pixel since it owns the edge between _1, _2, _5_ and _6.
644

645
        let in_geo_transform = GeoTransform::new(Coordinate2D::new(0.0, 0.0), 1.0, -1.0);
1✔
646
        let out_geo_transform = GeoTransform::new(Coordinate2D::new(0.0, 0.0), 2.0, -2.0);
1✔
647
        let tile_size_in_pixels = GridShape2D {
1✔
648
            shape_array: [4, 4],
1✔
649
        };
1✔
650

651
        let exe_ctx = MockExecutionContext::new_with_tiling_spec_and_thread_count(
1✔
652
            TilingSpecification::new(tile_size_in_pixels),
1✔
653
            8,
654
        );
655

656
        let data: Vec<RasterTile2D<u8>> = vec![
1✔
657
            RasterTile2D {
1✔
658
                time: TimeInterval::new_unchecked(0, 5),
1✔
659
                tile_position: [0, 0].into(),
1✔
660
                band: 0,
1✔
661
                global_geo_transform: in_geo_transform,
1✔
662
                grid_array: Grid::new(
1✔
663
                    tile_size_in_pixels,
1✔
664
                    vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
1✔
665
                )
1✔
666
                .unwrap()
1✔
667
                .into(),
1✔
668
                properties: Default::default(),
1✔
669
                cache_hint: CacheHint::default(),
1✔
670
            },
1✔
671
            RasterTile2D {
1✔
672
                time: TimeInterval::new_unchecked(0, 5),
1✔
673
                tile_position: [0, 1].into(),
1✔
674
                band: 0,
1✔
675
                global_geo_transform: in_geo_transform,
1✔
676
                grid_array: Grid::new(
1✔
677
                    tile_size_in_pixels,
1✔
678
                    vec![
1✔
679
                        21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36,
1✔
680
                    ],
1✔
681
                )
1✔
682
                .unwrap()
1✔
683
                .into(),
1✔
684
                properties: Default::default(),
1✔
685
                cache_hint: CacheHint::default(),
1✔
686
            },
1✔
687
            RasterTile2D {
1✔
688
                time: TimeInterval::new_unchecked(0, 5),
1✔
689
                tile_position: [1, 0].into(),
1✔
690
                band: 0,
1✔
691
                global_geo_transform: in_geo_transform,
1✔
692
                grid_array: Grid::new(
1✔
693
                    tile_size_in_pixels,
1✔
694
                    vec![
1✔
695
                        41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56,
1✔
696
                    ],
1✔
697
                )
1✔
698
                .unwrap()
1✔
699
                .into(),
1✔
700
                properties: Default::default(),
1✔
701
                cache_hint: CacheHint::default(),
1✔
702
            },
1✔
703
            RasterTile2D {
1✔
704
                time: TimeInterval::new_unchecked(0, 5),
1✔
705
                tile_position: [1, 1].into(),
1✔
706
                band: 0,
1✔
707
                global_geo_transform: in_geo_transform,
1✔
708
                grid_array: Grid::new(
1✔
709
                    tile_size_in_pixels,
1✔
710
                    vec![
1✔
711
                        61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76,
1✔
712
                    ],
1✔
713
                )
1✔
714
                .unwrap()
1✔
715
                .into(),
1✔
716
                properties: Default::default(),
1✔
717
                cache_hint: CacheHint::default(),
1✔
718
            },
1✔
719
        ];
720

721
        let result_descriptor = RasterResultDescriptor {
1✔
722
            data_type: RasterDataType::U8,
1✔
723
            spatial_reference: SpatialReference::epsg_4326().into(),
1✔
724
            time: TimeDescriptor::new_regular_with_epoch(
1✔
725
                Some(
1✔
726
                    TimeInterval::new(
1✔
727
                        data.first().unwrap().time.start(),
1✔
728
                        data.last().unwrap().time.end(),
1✔
729
                    )
1✔
730
                    .unwrap(),
1✔
731
                ),
1✔
732
                TimeStep::millis(5).unwrap(),
1✔
733
            ),
1✔
734
            spatial_grid: SpatialGridDescriptor::source_from_parts(
1✔
735
                in_geo_transform,
1✔
736
                GridBoundingBox2D::new_min_max(0, 7, 0, 7).unwrap(),
1✔
737
            ),
1✔
738
            bands: RasterBandDescriptors::new_single_band(),
1✔
739
        };
1✔
740

741
        let mrs1 = MockRasterSource {
1✔
742
            params: MockRasterSourceParams {
1✔
743
                data: data.clone(),
1✔
744
                result_descriptor: result_descriptor.clone(),
1✔
745
            },
1✔
746
        }
1✔
747
        .boxed();
1✔
748

749
        let downsampler = Downsampling {
1✔
750
            params: DownsamplingParams {
1✔
751
                sampling_method: DownsamplingMethod::NearestNeighbor,
1✔
752
                output_origin_reference: None,
1✔
753
                output_resolution: DownsamplingResolution::Resolution(SpatialResolution {
1✔
754
                    x: out_geo_transform.x_pixel_size(),
1✔
755
                    y: out_geo_transform.y_pixel_size().abs(),
1✔
756
                }),
1✔
757
            },
1✔
758
            sources: SingleRasterSource { raster: mrs1 },
1✔
759
        }
1✔
760
        .boxed();
1✔
761

762
        let query_rect = RasterQueryRectangle::new(
1✔
763
            GridBoundingBox2D::new_min_max(0, 3, 0, 3).unwrap(),
1✔
764
            TimeInterval::new_unchecked(0, 5),
1✔
765
            [0].try_into().unwrap(),
1✔
766
        );
767

768
        let query_ctx = exe_ctx.mock_query_context(ChunkByteSize::test_default());
1✔
769

770
        let op = downsampler
1✔
771
            .initialize(WorkflowOperatorPath::initialize_root(), &exe_ctx)
1✔
772
            .await
1✔
773
            .unwrap();
1✔
774

775
        let qp = op.query_processor().unwrap().get_u8().unwrap();
1✔
776

777
        let result = qp
1✔
778
            .raster_query(query_rect, &query_ctx)
1✔
779
            .await
1✔
780
            .unwrap()
1✔
781
            .collect::<Vec<_>>()
1✔
782
            .await;
1✔
783

784
        assert_eq!(result.len(), 1);
1✔
785

786
        let tile = result[0].as_ref().unwrap();
1✔
787
        let grid = tile.grid_array.clone().into_materialized_masked_grid();
1✔
788
        // _6. _8, 26, 28
789
        // 14, 16, 33, 36
790
        // 46, 48, 66, 68
791
        // 54, 56, 74, 76
792
        assert_eq!(
1✔
793
            grid.inner_grid.data,
1✔
794
            &[6, 8, 26, 28, 14, 16, 34, 36, 46, 48, 66, 68, 54, 56, 74, 76]
1✔
795
        );
1✔
796
    }
1✔
797

798
    #[allow(clippy::too_many_lines)]
799
    #[tokio::test]
800
    async fn nearest_neighbor_3() {
1✔
801
        // In this test, 3x3 tiles with 3x3 pixels are downsampled using nearest neighbor to one tile with 3x3 pixels. The resolution is now 1/3 of the original resolution.
802
        // The test uses the following input:
803
        //
804
        // _0, _1, _2 | 10, 11, 12 | 20, 21, 22
805
        // _3, _4, _5 | 13, 14, 15 | 23, 24, 25
806
        // _6, _7, _8 | 16, 17, 18 | 26, 27, 28
807
        // -----------+------------+-----------
808
        // 30, 31, 32 | 40, 41, 42 | 50, 51, 52
809
        // 33, 34, 35 | 43, 44, 45 | 53, 54, 55
810
        // 36, 37, 38 | 46, 47, 48 | 56, 57, 58
811
        // -----------+------------+-----------
812
        // 60, 61, 62 | 70, 71, 72 | 80, 81, 82
813
        // 63, 64, 65 | 73, 74, 75 | 83, 84, 85
814
        // 66, 67, 68 | 76, 77, 78 | 86, 87, 88
815
        //
816
        // The input is downsampled to:
817
        //
818
        // _4. 14, 24
819
        // 34, 44, 54
820
        // 64, 74, 84
821
        //
822
        // The center of each pixel is mapped to a coordinate. Then, for this coordinate the nearest pixel center is selected.
823
        // In this case, each pixel corresponds to the center of the corresponding tile.
824
        // E.g. _4 is selected for the first pixel since it is in the center of the tile.
825

826
        let in_geo_transform = GeoTransform::new(Coordinate2D::new(0.0, 0.0), 1.0, -1.0);
1✔
827
        let out_geo_transform = GeoTransform::new(Coordinate2D::new(0.0, 0.0), 3.0, -3.0);
1✔
828
        let tile_size_in_pixels = GridShape2D {
1✔
829
            shape_array: [3, 3],
1✔
830
        };
1✔
831

832
        let exe_ctx = MockExecutionContext::new_with_tiling_spec_and_thread_count(
1✔
833
            TilingSpecification::new(tile_size_in_pixels),
1✔
834
            8,
835
        );
836

837
        let data: Vec<RasterTile2D<u8>> = vec![
1✔
838
            RasterTile2D {
1✔
839
                time: TimeInterval::new_unchecked(0, 5),
1✔
840
                tile_position: [0, 0].into(),
1✔
841
                band: 0,
1✔
842
                global_geo_transform: in_geo_transform,
1✔
843
                grid_array: Grid::new(tile_size_in_pixels, vec![0, 1, 2, 3, 4, 5, 6, 7, 8])
1✔
844
                    .unwrap()
1✔
845
                    .into(),
1✔
846
                properties: Default::default(),
1✔
847
                cache_hint: CacheHint::default(),
1✔
848
            },
1✔
849
            RasterTile2D {
1✔
850
                time: TimeInterval::new_unchecked(0, 5),
1✔
851
                tile_position: [0, 1].into(),
1✔
852
                band: 0,
1✔
853
                global_geo_transform: in_geo_transform,
1✔
854
                grid_array: Grid::new(
1✔
855
                    tile_size_in_pixels,
1✔
856
                    vec![10, 11, 12, 13, 14, 15, 16, 17, 18],
1✔
857
                )
1✔
858
                .unwrap()
1✔
859
                .into(),
1✔
860
                properties: Default::default(),
1✔
861
                cache_hint: CacheHint::default(),
1✔
862
            },
1✔
863
            RasterTile2D {
1✔
864
                time: TimeInterval::new_unchecked(0, 5),
1✔
865
                tile_position: [0, 2].into(),
1✔
866
                band: 0,
1✔
867
                global_geo_transform: in_geo_transform,
1✔
868
                grid_array: Grid::new(
1✔
869
                    tile_size_in_pixels,
1✔
870
                    vec![20, 21, 22, 23, 24, 25, 26, 27, 28],
1✔
871
                )
1✔
872
                .unwrap()
1✔
873
                .into(),
1✔
874
                properties: Default::default(),
1✔
875
                cache_hint: CacheHint::default(),
1✔
876
            },
1✔
877
            RasterTile2D {
1✔
878
                time: TimeInterval::new_unchecked(0, 5),
1✔
879
                tile_position: [1, 0].into(),
1✔
880
                band: 0,
1✔
881
                global_geo_transform: in_geo_transform,
1✔
882
                grid_array: Grid::new(
1✔
883
                    tile_size_in_pixels,
1✔
884
                    vec![30, 31, 32, 33, 34, 35, 36, 37, 38],
1✔
885
                )
1✔
886
                .unwrap()
1✔
887
                .into(),
1✔
888
                properties: Default::default(),
1✔
889
                cache_hint: CacheHint::default(),
1✔
890
            },
1✔
891
            RasterTile2D {
1✔
892
                time: TimeInterval::new_unchecked(0, 5),
1✔
893
                tile_position: [1, 1].into(),
1✔
894
                band: 0,
1✔
895
                global_geo_transform: in_geo_transform,
1✔
896
                grid_array: Grid::new(
1✔
897
                    tile_size_in_pixels,
1✔
898
                    vec![40, 41, 42, 43, 44, 45, 46, 47, 48],
1✔
899
                )
1✔
900
                .unwrap()
1✔
901
                .into(),
1✔
902
                properties: Default::default(),
1✔
903
                cache_hint: CacheHint::default(),
1✔
904
            },
1✔
905
            RasterTile2D {
1✔
906
                time: TimeInterval::new_unchecked(0, 5),
1✔
907
                tile_position: [1, 2].into(),
1✔
908
                band: 0,
1✔
909
                global_geo_transform: in_geo_transform,
1✔
910
                grid_array: Grid::new(
1✔
911
                    tile_size_in_pixels,
1✔
912
                    vec![50, 51, 52, 53, 54, 55, 56, 57, 58],
1✔
913
                )
1✔
914
                .unwrap()
1✔
915
                .into(),
1✔
916
                properties: Default::default(),
1✔
917
                cache_hint: CacheHint::default(),
1✔
918
            },
1✔
919
            RasterTile2D {
1✔
920
                time: TimeInterval::new_unchecked(0, 5),
1✔
921
                tile_position: [2, 0].into(),
1✔
922
                band: 0,
1✔
923
                global_geo_transform: in_geo_transform,
1✔
924
                grid_array: Grid::new(
1✔
925
                    tile_size_in_pixels,
1✔
926
                    vec![60, 61, 62, 63, 64, 65, 66, 67, 68],
1✔
927
                )
1✔
928
                .unwrap()
1✔
929
                .into(),
1✔
930
                properties: Default::default(),
1✔
931
                cache_hint: CacheHint::default(),
1✔
932
            },
1✔
933
            RasterTile2D {
1✔
934
                time: TimeInterval::new_unchecked(0, 5),
1✔
935
                tile_position: [2, 1].into(),
1✔
936
                band: 0,
1✔
937
                global_geo_transform: in_geo_transform,
1✔
938
                grid_array: Grid::new(
1✔
939
                    tile_size_in_pixels,
1✔
940
                    vec![70, 71, 72, 73, 74, 75, 76, 77, 78],
1✔
941
                )
1✔
942
                .unwrap()
1✔
943
                .into(),
1✔
944
                properties: Default::default(),
1✔
945
                cache_hint: CacheHint::default(),
1✔
946
            },
1✔
947
            RasterTile2D {
1✔
948
                time: TimeInterval::new_unchecked(0, 5),
1✔
949
                tile_position: [2, 2].into(),
1✔
950
                band: 0,
1✔
951
                global_geo_transform: in_geo_transform,
1✔
952
                grid_array: Grid::new(
1✔
953
                    tile_size_in_pixels,
1✔
954
                    vec![80, 81, 82, 83, 84, 85, 86, 87, 88],
1✔
955
                )
1✔
956
                .unwrap()
1✔
957
                .into(),
1✔
958
                properties: Default::default(),
1✔
959
                cache_hint: CacheHint::default(),
1✔
960
            },
1✔
961
        ];
962

963
        let result_descriptor = RasterResultDescriptor {
1✔
964
            data_type: RasterDataType::U8,
1✔
965
            spatial_reference: SpatialReference::epsg_4326().into(),
1✔
966
            time: TimeDescriptor::new_regular_with_epoch(
1✔
967
                Some(
1✔
968
                    TimeInterval::new(
1✔
969
                        data.first().unwrap().time.start(),
1✔
970
                        data.last().unwrap().time.end(),
1✔
971
                    )
1✔
972
                    .unwrap(),
1✔
973
                ),
1✔
974
                TimeStep::millis(5).unwrap(),
1✔
975
            ),
1✔
976
            spatial_grid: SpatialGridDescriptor::source_from_parts(
1✔
977
                in_geo_transform,
1✔
978
                GridBoundingBox2D::new_min_max(0, 8, 0, 8).unwrap(),
1✔
979
            ),
1✔
980
            bands: RasterBandDescriptors::new_single_band(),
1✔
981
        };
1✔
982

983
        let mrs1 = MockRasterSource {
1✔
984
            params: MockRasterSourceParams {
1✔
985
                data: data.clone(),
1✔
986
                result_descriptor: result_descriptor.clone(),
1✔
987
            },
1✔
988
        }
1✔
989
        .boxed();
1✔
990

991
        let downsampler = Downsampling {
1✔
992
            params: DownsamplingParams {
1✔
993
                sampling_method: DownsamplingMethod::NearestNeighbor,
1✔
994
                output_origin_reference: None,
1✔
995
                output_resolution: DownsamplingResolution::Resolution(SpatialResolution {
1✔
996
                    x: out_geo_transform.x_pixel_size(),
1✔
997
                    y: out_geo_transform.y_pixel_size().abs(),
1✔
998
                }),
1✔
999
            },
1✔
1000
            sources: SingleRasterSource { raster: mrs1 },
1✔
1001
        }
1✔
1002
        .boxed();
1✔
1003

1004
        let query_rect = RasterQueryRectangle::new(
1✔
1005
            GridBoundingBox2D::new_min_max(0, 2, 0, 2).unwrap(),
1✔
1006
            TimeInterval::new_unchecked(0, 5),
1✔
1007
            [0].try_into().unwrap(),
1✔
1008
        );
1009

1010
        let query_ctx = exe_ctx.mock_query_context(ChunkByteSize::test_default());
1✔
1011

1012
        let op = downsampler
1✔
1013
            .initialize(WorkflowOperatorPath::initialize_root(), &exe_ctx)
1✔
1014
            .await
1✔
1015
            .unwrap();
1✔
1016

1017
        let qp = op.query_processor().unwrap().get_u8().unwrap();
1✔
1018

1019
        let result = qp
1✔
1020
            .raster_query(query_rect, &query_ctx)
1✔
1021
            .await
1✔
1022
            .unwrap()
1✔
1023
            .collect::<Vec<_>>()
1✔
1024
            .await;
1✔
1025

1026
        assert_eq!(result.len(), 1);
1✔
1027

1028
        let tile = result[0].as_ref().unwrap();
1✔
1029
        let grid = tile.grid_array.clone().into_materialized_masked_grid();
1✔
1030
        // _4. 14, 24
1031
        // 34, 44, 54
1032
        // 64, 74, 84
1033

1034
        assert_eq!(grid.inner_grid.data, &[4, 14, 24, 34, 44, 54, 64, 74, 84]);
1✔
1035
    }
1✔
1036
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc