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

geo-engine / geoengine / 23534958972

25 Mar 2026 09:52AM UTC coverage: 87.389%. First build
23534958972

Pull #1114

github

web-flow
Merge f23aa8233 into dccacebf2
Pull Request #1114: feat: STAC dataset import

598 of 1794 new or added lines in 12 files covered. (33.33%)

113703 of 130112 relevant lines covered (87.39%)

497582.25 hits per line

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

94.78
/services/src/api/model/processing_graphs/processing.rs
1
use crate::api::model::datatypes::{SpatialReference, TimeInstance, TimeStep};
2
use crate::api::model::processing_graphs::{
3
    parameters::{
4
        ColumnNames, FeatureAggregationMethod, RasterBandDescriptor, RasterDataType,
5
        TemporalAggregationMethod,
6
    },
7
    source_parameters::{
8
        MultipleRasterSources, SingleRasterOrVectorSource, SingleRasterSource,
9
        SingleVectorMultipleRasterSources,
10
    },
11
};
12
use geoengine_macros::{api_operator, type_tag};
13
use geoengine_operators::processing::{
14
    Aggregation as OperatorsAggregation, BandFilter as OperatorsBandFilter,
15
    BandFilterParams as OperatorsBandFilterParameters,
16
    DeriveOutRasterSpecsSource as OperatorsDeriveOutRasterSpecsSource,
17
    Expression as OperatorsExpression, ExpressionParams as OperatorsExpressionParameters,
18
    Interpolation as OperatorsInterpolation, InterpolationMethod as OperatorsInterpolationMethod,
19
    InterpolationParams as OperatorsInterpolationParameters,
20
    InterpolationResolution as OperatorsInterpolationResolution,
21
    RasterStacker as OperatorsRasterStacker,
22
    RasterStackerParams as OperatorsRasterStackerParameters,
23
    RasterTypeConversion as OperatorsRasterTypeConversion,
24
    RasterTypeConversionParams as OperatorsRasterTypeConversionParameters,
25
    RasterVectorJoin as OperatorsRasterVectorJoin,
26
    RasterVectorJoinParams as OperatorsRasterVectorJoinParameters,
27
    Reprojection as OperatorsReprojection, ReprojectionParams as OperatorsReprojectionParameters,
28
    TemporalRasterAggregation as OperatorsTemporalRasterAggregation,
29
    TemporalRasterAggregationParameters as OperatorsTemporalRasterAggregationParameters,
30
};
31
use serde::{Deserialize, Serialize};
32
use utoipa::ToSchema;
33

34
/// The `Expression` operator performs a pixel-wise mathematical expression on one or more bands of a raster source.
35
/// The expression is specified as a user-defined script in a very simple language.
36
/// The output is a raster time series with the result of the expression and with time intervals that are the same as for the inputs.
37
/// Users can specify an output data type.
38
/// Internally, the expression is evaluated using floating-point numbers.
39
///
40
/// An example usage scenario is to calculate NDVI for a red and a near-infrared raster channel.
41
/// The expression uses a raster source with two bands, referred to as A and B, and calculates the formula `(A - B) / (A + B)`.
42
/// When the temporal resolution is months, our output NDVI will also be a monthly time series.
43
///
44
/// ## Types
45
///
46
/// The following describes the types used in the parameters.
47
///
48
/// ### Expression
49
///
50
/// Expressions are simple scripts to perform pixel-wise computations.
51
/// One can refer to the raster inputs as `A` for the first raster band, `B` for the second, and so on.
52
/// Furthermore, expressions can check with `A IS NODATA`, `B IS NODATA`, etc. for NO DATA values.
53
/// This is important if `mapNoData` is set to true.
54
/// Otherwise, NO DATA values are mapped automatically to the output NO DATA value.
55
/// Finally, the value `NODATA` can be used to output NO DATA.
56
///
57
/// Users can think of this implicit function signature for, e.g., two inputs:
58
///
59
/// ```Rust
60
/// fn (A: f64, B: f64) -> f64
61
/// ```
62
///
63
/// As a start, expressions contain algebraic operations and mathematical functions.
64
///
65
/// ```Rust
66
/// (A + B) / 2
67
/// ```
68
///
69
/// In addition, branches can be used to check for conditions.
70
///
71
/// ```Rust
72
/// if A IS NODATA {
73
///     B
74
/// } else {
75
///     A
76
/// }
77
/// ```
78
///
79
/// Function calls can be used to access utility functions.
80
///
81
/// ```Rust
82
/// max(A, 0)
83
/// ```
84
///
85
/// Currently, the following functions are available:
86
///
87
/// - `abs(a)`: absolute value
88
/// - `min(a, b)`, `min(a, b, c)`: minimum value
89
/// - `max(a, b)`, `max(a, b, c)`: maximum value
90
/// - `sqrt(a)`: square root
91
/// - `ln(a)`: natural logarithm
92
/// - `log10(a)`: base 10 logarithm
93
/// - `cos(a)`, `sin(a)`, `tan(a)`, `acos(a)`, `asin(a)`, `atan(a)`: trigonometric functions
94
/// - `pi()`, `e()`: mathematical constants
95
/// - `round(a)`, `ceil(a)`, `floor(a)`: rounding functions
96
/// - `mod(a, b)`: division remainder
97
/// - `to_degrees(a)`, `to_radians(a)`: conversion to degrees or radians
98
///
99
/// To generate more complex expressions, it is possible to have variable assignments.
100
///
101
/// ```Rust
102
/// let mean = (A + B) / 2;
103
/// let coefficient = 0.357;
104
/// mean * coefficient
105
/// ```
106
///
107
/// Note, that all assignments are separated by semicolons.
108
/// However, the last expression must be without a semicolon.
109
#[api_operator(
90✔
110
    title = "Raster Expression",
90✔
111
    examples(json!({
90✔
112
        "type": "Expression",
113
        "params": {
114
            "expression": "(A - B) / (A + B)",
115
            "outputType": "F32",
116
            "outputBand": {
117
                "name": "NDVI",
118
                "measurement": { "type": "unitless" },
119
            },
120
            "mapNoData": true
121
        },
122
        "sources": {
123
            "raster": {
124
                "type": "GdalSource",
125
                "params": {
126
                    "data": "ndvi"
127
                }
128
            }
129
        }
130
    })),
131
)]
132
pub struct Expression {
133
    pub params: ExpressionParameters,
134
    pub sources: Box<SingleRasterSource>,
135
}
136

137
/// ## Types
138
///
139
/// The following describes the types used in the parameters.
140
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
141
#[serde(rename_all = "camelCase")]
142
pub struct ExpressionParameters {
143
    /// Expression script
144
    ///
145
    /// Example: `"(A - B) / (A + B)"`
146
    #[schema(examples("(A - B) / (A + B)"))]
147
    pub expression: String,
148
    /// A raster data type for the output
149
    #[schema(examples("F32"))]
150
    pub output_type: RasterDataType,
151
    /// Description about the output
152
    #[schema(
153
        nullable = false /* cannot be null, but left out, avoids `Option<Option<_>>` in openapi client  */,
154
        examples(json!({
155
            "name": "NDVI",
156
            "measurement": { "type": "unitless" },
157
        }))
158
    )]
159
    pub output_band: Option<RasterBandDescriptor>,
160
    /// Should NO DATA values be mapped with the `expression`? Otherwise, they are mapped automatically to NO DATA.
161
    #[schema(examples(true))]
162
    pub map_no_data: bool,
163
}
164

165
impl TryFrom<Expression> for OperatorsExpression {
166
    type Error = anyhow::Error;
167

168
    fn try_from(value: Expression) -> Result<Self, Self::Error> {
1✔
169
        Ok(OperatorsExpression {
170
            params: OperatorsExpressionParameters {
1✔
171
                expression: value.params.expression,
1✔
172
                output_type: value.params.output_type.into(),
1✔
173
                output_band: value.params.output_band.map(Into::into),
1✔
174
                map_no_data: value.params.map_no_data,
1✔
175
            },
1✔
176
            sources: (*value.sources).try_into()?,
1✔
177
        })
178
    }
1✔
179
}
180

181
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
182
#[serde(rename_all = "camelCase", tag = "type", content = "values")]
183
pub enum RenameBands {
184
    Default,
185
    Suffix(Vec<String>),
186
    Rename(Vec<String>),
187
}
188

189
impl From<RenameBands> for geoengine_datatypes::raster::RenameBands {
190
    fn from(value: RenameBands) -> Self {
3✔
191
        match value {
3✔
NEW
192
            RenameBands::Default => Self::Default,
×
193
            RenameBands::Suffix(values) => Self::Suffix(values),
1✔
194
            RenameBands::Rename(values) => Self::Rename(values),
2✔
195
        }
196
    }
3✔
197
}
198

199
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
200
#[serde(untagged)]
201
pub enum BandsByNameOrIndex {
202
    Name(Vec<String>),
203
    Index(Vec<usize>),
204
}
205

206
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, ToSchema, Default)]
207
#[serde(rename_all = "camelCase")]
208
pub enum DeriveOutRasterSpecsSource {
209
    DataBounds,
210
    #[default]
211
    ProjectionBounds,
212
}
213

214
impl From<DeriveOutRasterSpecsSource> for OperatorsDeriveOutRasterSpecsSource {
215
    fn from(value: DeriveOutRasterSpecsSource) -> Self {
2✔
216
        match value {
2✔
NEW
217
            DeriveOutRasterSpecsSource::DataBounds => Self::DataBounds,
×
218
            DeriveOutRasterSpecsSource::ProjectionBounds => Self::ProjectionBounds,
2✔
219
        }
220
    }
2✔
221
}
222

223
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, ToSchema)]
224
#[serde(rename_all = "camelCase", tag = "type")]
225
pub enum InterpolationResolution {
226
    Resolution { x: f64, y: f64 },
227
    Fraction { x: f64, y: f64 },
228
}
229

230
impl From<InterpolationResolution> for OperatorsInterpolationResolution {
231
    fn from(value: InterpolationResolution) -> Self {
1✔
232
        match value {
1✔
NEW
233
            InterpolationResolution::Resolution { x, y } => {
×
NEW
234
                Self::Resolution(geoengine_datatypes::primitives::SpatialResolution { x, y })
×
235
            }
236
            InterpolationResolution::Fraction { x, y } => Self::Fraction { x, y },
1✔
237
        }
238
    }
1✔
239
}
240

241
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, ToSchema)]
242
#[serde(rename_all = "camelCase")]
243
pub enum InterpolationMethod {
244
    NearestNeighbor,
245
    BiLinear,
246
}
247

248
impl From<InterpolationMethod> for OperatorsInterpolationMethod {
249
    fn from(value: InterpolationMethod) -> Self {
1✔
250
        match value {
1✔
251
            InterpolationMethod::NearestNeighbor => Self::NearestNeighbor,
1✔
NEW
252
            InterpolationMethod::BiLinear => Self::BiLinear,
×
253
        }
254
    }
1✔
255
}
256

257
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, ToSchema)]
258
#[serde(rename_all = "camelCase", tag = "type")]
259
pub enum Aggregation {
260
    #[serde(rename_all = "camelCase")]
261
    Min { ignore_no_data: bool },
262
    #[serde(rename_all = "camelCase")]
263
    Max { ignore_no_data: bool },
264
    #[serde(rename_all = "camelCase")]
265
    First { ignore_no_data: bool },
266
    #[serde(rename_all = "camelCase")]
267
    Last { ignore_no_data: bool },
268
    #[serde(rename_all = "camelCase")]
269
    Mean { ignore_no_data: bool },
270
    #[serde(rename_all = "camelCase")]
271
    Sum { ignore_no_data: bool },
272
    #[serde(rename_all = "camelCase")]
273
    Count { ignore_no_data: bool },
274
    #[serde(rename_all = "camelCase")]
275
    PercentileEstimate {
276
        ignore_no_data: bool,
277
        percentile: f64,
278
    },
279
}
280

281
impl From<Aggregation> for OperatorsAggregation {
282
    fn from(value: Aggregation) -> Self {
1✔
283
        match value {
1✔
NEW
284
            Aggregation::Min { ignore_no_data } => Self::Min { ignore_no_data },
×
NEW
285
            Aggregation::Max { ignore_no_data } => Self::Max { ignore_no_data },
×
NEW
286
            Aggregation::First { ignore_no_data } => Self::First { ignore_no_data },
×
NEW
287
            Aggregation::Last { ignore_no_data } => Self::Last { ignore_no_data },
×
288
            Aggregation::Mean { ignore_no_data } => Self::Mean { ignore_no_data },
1✔
NEW
289
            Aggregation::Sum { ignore_no_data } => Self::Sum { ignore_no_data },
×
NEW
290
            Aggregation::Count { ignore_no_data } => Self::Count { ignore_no_data },
×
291
            Aggregation::PercentileEstimate {
NEW
292
                ignore_no_data,
×
NEW
293
                percentile,
×
NEW
294
            } => Self::PercentileEstimate {
×
NEW
295
                ignore_no_data,
×
NEW
296
                percentile,
×
NEW
297
            },
×
298
        }
299
    }
1✔
300
}
301

302
/// The `Reprojection` operator reprojects data from one spatial reference system to another.
303
/// It accepts exactly one input which can either be a raster or a vector data stream.
304
/// The operator produces all data that, after reprojection, is contained in the query rectangle.
305
///
306
/// ## Data Type Specifics
307
///
308
/// The concrete behavior depends on the data type.
309
///
310
/// ### Vector Data
311
///
312
/// The operator reprojects all coordinates of the features individually.
313
/// The result contains all features that, after reprojection, are intersected by the query rectangle.
314
///
315
/// ### Raster Data
316
///
317
/// To create tiles in the target projection, the operator loads corresponding tiles in the source projection.
318
/// For each output pixel, the value of the nearest input pixel is used.
319
///
320
/// If parts of a tile are outside of the source extent after projection, the operator produces NO DATA values.
321
///
322
/// ## Parameters
323
///
324
/// - `targetSpatialReference`: target spatial reference system.
325
/// - `deriveOutSpec`: controls how raster output bounds are derived.
326
///   The default `projectionBounds` usually keeps a projection-aligned target grid,
327
///   while `dataBounds` derives it directly from the source data bounds.
328
///
329
/// ## Inputs
330
///
331
/// The `Reprojection` operator expects exactly one _raster_ or _vector_ input.
332
///
333
/// ## Errors
334
///
335
/// The operator returns an error if the target projection is unknown or if input data cannot be reprojected.
336
#[api_operator(
170✔
337
    title = "Reprojection",
170✔
338
    examples(json!({
170✔
339
        "type": "Reprojection",
340
        "params": {
341
            "deriveOutSpec": "projectionBounds",
342
            "targetSpatialReference": "EPSG:32632"
343
        },
344
        "sources": {
345
            "source": {
346
                "type": "MockPointSource",
347
                "params": {
348
                    "points": [{ "x": 8.77069, "y": 50.80904 }],
349
                    "spatialBounds": { "type": "none" }
350
                }
351
            }
352
        }
353
    }))
354
)]
355
pub struct Reprojection {
356
    pub params: ReprojectionParameters,
357
    pub sources: Box<SingleRasterOrVectorSource>,
358
}
359

360
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
361
#[serde(rename_all = "camelCase")]
362
pub struct ReprojectionParameters {
363
    #[schema(value_type = String)]
364
    pub target_spatial_reference: SpatialReference,
365
    #[serde(default)]
366
    pub derive_out_spec: DeriveOutRasterSpecsSource,
367
}
368

369
impl TryFrom<Reprojection> for OperatorsReprojection {
370
    type Error = anyhow::Error;
371

372
    fn try_from(value: Reprojection) -> Result<Self, Self::Error> {
2✔
373
        Ok(OperatorsReprojection {
374
            params: OperatorsReprojectionParameters {
2✔
375
                target_spatial_reference: value.params.target_spatial_reference.into(),
2✔
376
                derive_out_spec: value.params.derive_out_spec.into(),
2✔
377
            },
2✔
378
            sources: (*value.sources).try_into()?,
2✔
379
        })
380
    }
2✔
381
}
382

383
/// The `TemporalRasterAggregation` operator aggregates a raster time series into uniform time windows.
384
/// The output starts with the first window that contains the query start and contains all windows
385
/// that overlap the query interval.
386
///
387
/// Pixel values are computed by aggregating all input rasters that contribute to the current window
388
/// with the selected `aggregation` method.
389
///
390
/// The optional `windowReference` parameter allows specifying a custom anchor point for the windows.
391
/// If omitted, windows are anchored at `1970-01-01T00:00:00Z`.
392
///
393
/// ## Types
394
///
395
/// The following describes the types used in the parameters.
396
///
397
/// ### Aggregation
398
///
399
/// There are different methods that can be used to aggregate raster time series.
400
/// Encountering NO DATA makes the aggregation result NO DATA unless `ignoreNoData` is `true`.
401
///
402
/// - `min`, `max`, `first`, `last`, `mean`, `sum`, `count`
403
/// - `percentileEstimate` with a percentile in `(0, 1)`
404
///
405
/// ## Inputs
406
///
407
/// The `TemporalRasterAggregation` operator expects exactly one _raster_ input.
408
///
409
/// ## Errors
410
///
411
/// If the aggregation method is `first`, `last`, or `mean` and the input raster has no NO DATA value,
412
/// an error is returned.
413
#[api_operator(
90✔
414
    title = "Temporal Raster Aggregation",
90✔
415
    examples(json!({
90✔
416
        "type": "TemporalRasterAggregation",
417
        "params": {
418
            "aggregation": { "type": "mean", "ignoreNoData": true },
419
            "window": { "granularity": "months", "step": 1 }
420
        },
421
        "sources": {
422
            "raster": {
423
                "type": "Expression",
424
                "params": {
425
                    "expression": "(A - B) / (A + B)",
426
                    "outputType": "F32",
427
                    "outputBand": {
428
                        "name": "NDVI",
429
                        "measurement": { "type": "unitless" }
430
                    },
431
                    "mapNoData": false
432
                },
433
                "sources": {
434
                    "raster": {
435
                        "type": "GdalSource",
436
                        "params": { "data": "ndvi" }
437
                    }
438
                }
439
            }
440
        }
441
    }))
442
)]
443
pub struct TemporalRasterAggregation {
444
    pub params: TemporalRasterAggregationParameters,
445
    pub sources: Box<SingleRasterSource>,
446
}
447

448
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
449
#[serde(rename_all = "camelCase")]
450
pub struct TemporalRasterAggregationParameters {
451
    pub aggregation: Aggregation,
452
    pub window: TimeStep,
453
    pub window_reference: Option<TimeInstance>,
454
    pub output_type: Option<RasterDataType>,
455
}
456

457
impl TryFrom<TemporalRasterAggregation> for OperatorsTemporalRasterAggregation {
458
    type Error = anyhow::Error;
459

460
    fn try_from(value: TemporalRasterAggregation) -> Result<Self, Self::Error> {
1✔
461
        Ok(OperatorsTemporalRasterAggregation {
462
            params: OperatorsTemporalRasterAggregationParameters {
1✔
463
                aggregation: value.params.aggregation.into(),
1✔
464
                window: value.params.window.into(),
1✔
465
                window_reference: value.params.window_reference.map(Into::into),
1✔
466
                output_type: value.params.output_type.map(Into::into),
1✔
467
            },
1✔
468
            sources: (*value.sources).try_into()?,
1✔
469
        })
470
    }
1✔
471
}
472

473
/// The `RasterStacker` stacks all of its inputs into a single raster time series.
474
/// It queries all inputs and combines them by band, space, and then time.
475
///
476
/// The output raster has as many bands as the sum of all input bands.
477
/// Tiles are automatically temporally aligned.
478
///
479
/// All inputs must have the same data type and spatial reference.
480
///
481
/// ## Types
482
///
483
/// The following describes the types used in the parameters.
484
///
485
/// ### RenameBands
486
///
487
/// The `RenameBands` type specifies how to rename output bands to avoid naming conflicts:
488
///
489
/// - `default`: appends ` (n)` with the smallest `n` that avoids a conflict.
490
/// - `suffix`: appends one suffix per input.
491
/// - `rename`: explicitly provides names for all resulting bands.
492
///
493
/// ## Inputs
494
///
495
/// The `RasterStacker` operator expects multiple raster inputs.
496
#[api_operator(
90✔
497
    title = "Raster Stacker",
90✔
498
    examples(json!({
90✔
499
        "type": "RasterStacker",
500
        "params": {
501
            "renameBands": { "type": "default" }
502
        },
503
        "sources": {
504
            "rasters": [
505
                {
506
                    "type": "GdalSource",
507
                    "params": { "data": "example-a" }
508
                },
509
                {
510
                    "type": "GdalSource",
511
                    "params": { "data": "example-b" }
512
                }
513
            ]
514
        }
515
    }))
516
)]
517
pub struct RasterStacker {
518
    pub params: RasterStackerParameters,
519
    pub sources: Box<MultipleRasterSources>,
520
}
521

522
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
523
#[serde(rename_all = "camelCase")]
524
pub struct RasterStackerParameters {
525
    pub rename_bands: RenameBands,
526
}
527

528
impl TryFrom<RasterStacker> for OperatorsRasterStacker {
529
    type Error = anyhow::Error;
530

531
    fn try_from(value: RasterStacker) -> Result<Self, Self::Error> {
3✔
532
        Ok(OperatorsRasterStacker {
533
            params: OperatorsRasterStackerParameters {
3✔
534
                rename_bands: value.params.rename_bands.into(),
3✔
535
            },
3✔
536
            sources: (*value.sources).try_into()?,
3✔
537
        })
538
    }
3✔
539
}
540

541
/// The `RasterTypeConversion` operator changes the data type of raster pixels.
542
///
543
/// Applying this conversion may cause precision loss.
544
/// For example, converting `F32` value `3.1` to `U8` results in `3`.
545
///
546
/// If a value is outside of the range of the target data type,
547
/// it is clipped to the valid range of that type.
548
/// For example, converting `F32` value `300.0` to `U8` results in `255`.
549
///
550
/// ## Inputs
551
///
552
/// The `RasterTypeConversion` operator expects exactly one _raster_ input.
553
#[api_operator(
90✔
554
    title = "Raster Type Conversion",
90✔
555
    examples(json!({
90✔
556
        "type": "RasterTypeConversion",
557
        "params": {
558
            "outputDataType": "U16"
559
        },
560
        "sources": {
561
            "raster": {
562
                "type": "GdalSource",
563
                "params": { "data": "example" }
564
            }
565
        }
566
    }))
567
)]
568
pub struct RasterTypeConversion {
569
    pub params: RasterTypeConversionParameters,
570
    pub sources: Box<SingleRasterSource>,
571
}
572

573
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
574
#[serde(rename_all = "camelCase")]
575
pub struct RasterTypeConversionParameters {
576
    pub output_data_type: RasterDataType,
577
}
578

579
impl TryFrom<RasterTypeConversion> for OperatorsRasterTypeConversion {
580
    type Error = anyhow::Error;
581

582
    fn try_from(value: RasterTypeConversion) -> Result<Self, Self::Error> {
1✔
583
        Ok(OperatorsRasterTypeConversion {
584
            params: OperatorsRasterTypeConversionParameters {
1✔
585
                output_data_type: value.params.output_data_type.into(),
1✔
586
            },
1✔
587
            sources: (*value.sources).try_into()?,
1✔
588
        })
589
    }
1✔
590
}
591

592
/// The `Interpolation` operator increases raster resolution by interpolating values of an input raster.
593
///
594
/// If queried with a resolution that is coarser than the input resolution,
595
/// interpolation is not applicable and an error is returned.
596
///
597
/// ## Types
598
///
599
/// The following describes the types used in the parameters.
600
///
601
/// ### `InterpolationMethod`
602
///
603
/// The operator supports the following interpolation methods:
604
///
605
/// - `nearestNeighbor`: nearest-neighbor interpolation
606
/// - `biLinear`: bilinear interpolation
607
///
608
/// ### `InterpolationResolution`
609
///
610
/// The target resolution can be configured as:
611
///
612
/// - `resolution`: explicit output resolution (`x`, `y`)
613
/// - `fraction`: upscale factor relative to input resolution (`x >= 1`, `y >= 1`)
614
///
615
/// ## Parameters
616
///
617
/// - `interpolation`: interpolation method.
618
/// - `outputResolution`: output grid resolution.
619
/// - `outputOriginReference` (optional): reference point to align the output grid origin.
620
///
621
/// ## Inputs
622
///
623
/// The `Interpolation` operator expects exactly one _raster_ input.
624
#[api_operator(
90✔
625
    title = "Interpolation",
90✔
626
    examples(json!({
90✔
627
        "type": "Interpolation",
628
        "params": {
629
            "interpolation": "nearestNeighbor",
630
            "outputResolution": {
631
                "type": "fraction",
632
                "x": 2.0,
633
                "y": 2.0
634
            }
635
        },
636
        "sources": {
637
            "raster": {
638
                "type": "MultiBandGdalSource",
639
                "params": { "data": "sentinel-2-l2a_EPSG32632_U8_20" }
640
            }
641
        }
642
    }))
643
)]
644
pub struct Interpolation {
645
    pub params: InterpolationParameters,
646
    pub sources: Box<SingleRasterSource>,
647
}
648

649
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
650
#[serde(rename_all = "camelCase")]
651
pub struct InterpolationParameters {
652
    pub interpolation: InterpolationMethod,
653
    pub output_resolution: InterpolationResolution,
654
    pub output_origin_reference: Option<crate::api::model::datatypes::Coordinate2D>,
655
}
656

657
impl TryFrom<Interpolation> for OperatorsInterpolation {
658
    type Error = anyhow::Error;
659

660
    fn try_from(value: Interpolation) -> Result<Self, Self::Error> {
1✔
661
        Ok(OperatorsInterpolation {
662
            params: OperatorsInterpolationParameters {
1✔
663
                interpolation: value.params.interpolation.into(),
1✔
664
                output_resolution: value.params.output_resolution.into(),
1✔
665
                output_origin_reference: value.params.output_origin_reference.map(Into::into),
1✔
666
            },
1✔
667
            sources: (*value.sources).try_into()?,
1✔
668
        })
669
    }
1✔
670
}
671

672
/// The `BandFilter` operator selects bands from a raster source by band names or band indices.
673
///
674
/// It removes all non-selected bands while preserving the original order of remaining bands.
675
///
676
/// ## Parameters
677
///
678
/// - `bands`: selected bands either by names (`["nir", "red"]`) or indices (`[0, 2]`).
679
///
680
/// ## Inputs
681
///
682
/// The `BandFilter` operator expects exactly one _raster_ input.
683
///
684
/// ## Errors
685
///
686
/// The operator returns an error if no bands are selected or if selected band names/indices
687
/// cannot be mapped to existing input bands.
688
#[api_operator(
90✔
689
    title = "Band Filter",
90✔
690
    examples(json!({
90✔
691
        "type": "BandFilter",
692
        "params": {
693
            "bands": ["nir", "red"]
694
        },
695
        "sources": {
696
            "raster": {
697
                "type": "MultiBandGdalSource",
698
                "params": { "data": "sentinel-2-l2a_EPSG32632_U16_10" }
699
            }
700
        }
701
    }))
702
)]
703
pub struct BandFilter {
704
    pub params: BandFilterParameters,
705
    pub sources: Box<SingleRasterSource>,
706
}
707

708
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
709
#[serde(rename_all = "camelCase")]
710
pub struct BandFilterParameters {
711
    #[schema(examples(json!(["nir", "red"]), json!([0, 2])))]
712
    pub bands: BandsByNameOrIndex,
713
}
714

715
impl TryFrom<BandFilter> for OperatorsBandFilter {
716
    type Error = anyhow::Error;
717

718
    fn try_from(value: BandFilter) -> Result<Self, Self::Error> {
1✔
719
        let params = serde_json::from_value::<OperatorsBandFilterParameters>(
1✔
720
            serde_json::to_value(value.params)?,
1✔
NEW
721
        )?;
×
722

723
        Ok(OperatorsBandFilter {
724
            params,
1✔
725
            sources: (*value.sources).try_into()?,
1✔
726
        })
727
    }
1✔
728
}
729

730
/// The `RasterVectorJoin` operator allows combining a single vector input and multiple raster inputs.
731
/// For each raster input, a new column is added to the collection from the vector input.
732
/// The new column contains the value of the raster at the location of the vector feature.
733
/// For features covering multiple pixels like `MultiPoints` or `MultiPolygons`, the value is calculated using an aggregation function selected by the user.
734
/// The same is true if the temporal extent of a vector feature covers multiple raster time steps.
735
/// More details are described below.
736
///
737
/// **Example**:
738
/// You have a collection of agricultural fields (`Polygons`) and a collection of raster images containing each pixel's monthly NDVI value.
739
/// For your application, you want to know the NDVI value of each field.
740
/// The `RasterVectorJoin` operator allows you to combine the vector and raster data and offers multiple spatial and temporal aggregation strategies.
741
/// For example, you can use the `first` aggregation function to get the NDVI value of the first pixel that intersects with each field.
742
/// This is useful for exploratory analysis since the computation is very fast.
743
/// To calculate the mean NDVI value of all pixels that intersect with the field you should use the `mean` aggregation function.
744
/// Since the NDVI data is a monthly time series, you have to specify the temporal aggregation function as well.
745
/// The default is `none` which will create a new feature for each month.
746
/// Other options are `first` and `mean` which will calculate the first or mean NDVI value for each field over time.
747
///
748
/// ## Inputs
749
///
750
/// The `RasterVectorJoin` operator expects one _vector_ input and one or more _raster_ inputs.
751
///
752
/// | Parameter | Type                                |
753
/// | --------- | ----------------------------------- |
754
/// | `sources` | `SingleVectorMultipleRasterSources` |
755
///
756
/// ## Errors
757
///
758
/// If the length of `names` is not equal to the number of raster inputs, an error is thrown.
759
///
760
#[api_operator(
90✔
761
    title = "Raster Vector Join",
90✔
762
    examples(json!({
90✔
763
        "type": "RasterVectorJoin",
764
        "params": {
765
            "names": ["NDVI"],
766
            "featureAggregation": "first",
767
            "temporalAggregation": "mean",
768
            "temporalAggregationIgnoreNoData": true
769
        },
770
        "sources": {
771
            "vector": {
772
                "type": "OgrSource",
773
                "params": {
774
                    "data": "places"
775
                }
776
            },
777
            "rasters": [{
778
                "type": "GdalSource",
779
                "params": {
780
                "data": "ndvi"
781
                }
782
            }]
783
        }
784
    }))
785
)]
786
pub struct RasterVectorJoin {
787
    pub params: RasterVectorJoinParameters,
788
    pub sources: Box<SingleVectorMultipleRasterSources>,
789
}
790

791
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
792
#[serde(rename_all = "camelCase")]
793
pub struct RasterVectorJoinParameters {
794
    /// Specify how the new column names are derived from the raster band names.
795
    ///
796
    /// The `ColumnNames` type is used to specify how the new column names are derived from the raster band names.
797
    ///
798
    /// - **default**: Appends " (n)" to the band name with the smallest `n` that avoids a conflict.
799
    /// - **suffix**: Specifies a suffix for each input, to be appended to the band names.
800
    /// - **rename**: A list of names for each new column.
801
    ///
802
    #[schema(examples(
803
        json!({"type": "default"}),
804
        json!({"type": "suffix", "values": ["_sentinel2"]}),
805
        json!({"type": "rename", "values": ["red", "green", "blue"]}),
806
    ))]
807
    pub names: ColumnNames,
808
    /// The aggregation function to use for features covering multiple pixels.
809
    #[schema(examples("first"))]
810
    pub feature_aggregation: FeatureAggregationMethod,
811
    /// Whether to ignore no data values in the aggregation. Defaults to `false`.
812
    #[serde(default)]
813
    #[schema(examples(true))]
814
    pub feature_aggregation_ignore_no_data: bool,
815
    /// The aggregation function to use for features covering multiple (raster) time steps.
816
    #[schema(examples("mean"))]
817
    pub temporal_aggregation: TemporalAggregationMethod,
818
    /// Whether to ignore no data values in the aggregation. Defaults to `false`.
819
    #[serde(default)]
820
    #[schema(examples(true))]
821
    pub temporal_aggregation_ignore_no_data: bool,
822
}
823

824
impl TryFrom<RasterVectorJoin> for OperatorsRasterVectorJoin {
825
    type Error = anyhow::Error;
826

827
    fn try_from(value: RasterVectorJoin) -> Result<Self, Self::Error> {
1✔
828
        Ok(OperatorsRasterVectorJoin {
829
            params: OperatorsRasterVectorJoinParameters {
1✔
830
                names: value.params.names.into(),
1✔
831
                feature_aggregation: value.params.feature_aggregation.into(),
1✔
832
                feature_aggregation_ignore_no_data: value.params.feature_aggregation_ignore_no_data,
1✔
833
                temporal_aggregation: value.params.temporal_aggregation.into(),
1✔
834
                temporal_aggregation_ignore_no_data: value
1✔
835
                    .params
1✔
836
                    .temporal_aggregation_ignore_no_data,
1✔
837
            },
1✔
838
            sources: (*value.sources).try_into()?,
1✔
839
        })
840
    }
1✔
841
}
842

843
#[cfg(test)]
844
mod tests {
845

846
    use super::*;
847
    use crate::api::model::{
848
        datatypes::{Coordinate2D, TimeGranularity},
849
        processing_graphs::{
850
            RasterOperator, SingleRasterOrVectorOperator, VectorOperator,
851
            parameters::SpatialBoundsDerive,
852
            source::{
853
                GdalSource, GdalSourceParameters, MockPointSource, MockPointSourceParameters,
854
                MultiBandGdalSource,
855
            },
856
        },
857
    };
858
    use serde_json::json;
859

860
    #[test]
861
    fn it_converts_expressions() {
1✔
862
        let api = Expression {
1✔
863
            r#type: Default::default(),
1✔
864
            params: ExpressionParameters {
1✔
865
                expression: "2 * A + B".to_string(),
1✔
866
                output_type: RasterDataType::F32,
1✔
867
                output_band: None,
1✔
868
                map_no_data: true,
1✔
869
            },
1✔
870
            sources: Box::new(SingleRasterSource {
1✔
871
                raster: RasterOperator::GdalSource(GdalSource {
1✔
872
                    r#type: Default::default(),
1✔
873
                    params: GdalSourceParameters {
1✔
874
                        data: "example_data".to_string(),
1✔
875
                        overview_level: None,
1✔
876
                    },
1✔
877
                }),
1✔
878
            }),
1✔
879
        };
1✔
880

881
        let ops = OperatorsExpression::try_from(api).expect("conversion failed");
1✔
882

883
        assert_eq!(ops.params.expression, "2 * A + B");
1✔
884
        assert_eq!(
1✔
885
            ops.params.output_type,
886
            geoengine_datatypes::raster::RasterDataType::F32
887
        );
888
        assert!(ops.params.output_band.is_none());
1✔
889
        assert!(ops.params.map_no_data);
1✔
890
    }
1✔
891

892
    #[test]
893
    fn it_converts_raster_vector_join_params() {
1✔
894
        let api = RasterVectorJoin {
1✔
895
            r#type: Default::default(),
1✔
896
            params: RasterVectorJoinParameters {
1✔
897
                names: ColumnNames::Names {
1✔
898
                    values: vec!["a".to_string(), "b".to_string()],
1✔
899
                },
1✔
900
                feature_aggregation: FeatureAggregationMethod::First,
1✔
901
                feature_aggregation_ignore_no_data: true,
1✔
902
                temporal_aggregation: TemporalAggregationMethod::Mean,
1✔
903
                temporal_aggregation_ignore_no_data: false,
1✔
904
            },
1✔
905
            sources: Box::new(SingleVectorMultipleRasterSources {
1✔
906
                vector: VectorOperator::MockPointSource(MockPointSource {
1✔
907
                    r#type: Default::default(),
1✔
908
                    params: MockPointSourceParameters {
1✔
909
                        points: vec![Coordinate2D { x: 0.0, y: 0.0 }],
1✔
910
                        spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
911
                    },
1✔
912
                }),
1✔
913
                rasters: vec![RasterOperator::GdalSource(GdalSource {
1✔
914
                    r#type: Default::default(),
1✔
915
                    params: GdalSourceParameters {
1✔
916
                        data: "example_data".to_string(),
1✔
917
                        overview_level: None,
1✔
918
                    },
1✔
919
                })],
1✔
920
            }),
1✔
921
        };
1✔
922

923
        let ops_params = OperatorsRasterVectorJoin::try_from(api).expect("conversion failed");
1✔
924

925
        assert!(matches!(
1✔
926
            ops_params.params.names,
1✔
927
            geoengine_operators::processing::ColumnNames::Names(_)
928
        ));
929
        assert_eq!(
1✔
930
            ops_params.params.feature_aggregation,
931
            geoengine_operators::processing::FeatureAggregationMethod::First
932
        );
933
        assert!(ops_params.params.feature_aggregation_ignore_no_data);
1✔
934
        assert_eq!(
1✔
935
            ops_params.params.temporal_aggregation,
936
            geoengine_operators::processing::TemporalAggregationMethod::Mean
937
        );
938
        assert!(!ops_params.params.temporal_aggregation_ignore_no_data);
1✔
939
    }
1✔
940

941
    #[test]
942
    fn it_converts_reprojection_params() {
1✔
943
        let api = Reprojection {
1✔
944
            r#type: Default::default(),
1✔
945
            params: ReprojectionParameters {
1✔
946
                target_spatial_reference: "EPSG:32632".parse().expect("valid srs"),
1✔
947
                derive_out_spec: DeriveOutRasterSpecsSource::ProjectionBounds,
1✔
948
            },
1✔
949
            sources: Box::new(SingleRasterOrVectorSource {
1✔
950
                source: SingleRasterOrVectorOperator::Vector(VectorOperator::MockPointSource(
1✔
951
                    MockPointSource {
1✔
952
                        r#type: Default::default(),
1✔
953
                        params: MockPointSourceParameters {
1✔
954
                            points: vec![Coordinate2D { x: 0.0, y: 0.0 }],
1✔
955
                            spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
956
                        },
1✔
957
                    },
1✔
958
                )),
1✔
959
            }),
1✔
960
        };
1✔
961

962
        let ops = OperatorsReprojection::try_from(api).expect("conversion failed");
1✔
963

964
        assert_eq!(
1✔
965
            ops.params.target_spatial_reference.to_string(),
1✔
966
            "EPSG:32632"
967
        );
968
        assert!(matches!(
1✔
969
            ops.params.derive_out_spec,
1✔
970
            geoengine_operators::processing::DeriveOutRasterSpecsSource::ProjectionBounds
971
        ));
972
    }
1✔
973

974
    #[test]
975
    fn it_converts_temporal_raster_aggregation_params() {
1✔
976
        let api = TemporalRasterAggregation {
1✔
977
            r#type: Default::default(),
1✔
978
            params: TemporalRasterAggregationParameters {
1✔
979
                aggregation: Aggregation::Mean {
1✔
980
                    ignore_no_data: true,
1✔
981
                },
1✔
982
                window: crate::api::model::datatypes::TimeStep {
1✔
983
                    granularity: TimeGranularity::Months,
1✔
984
                    step: 1,
1✔
985
                },
1✔
986
                window_reference: None,
1✔
987
                output_type: None,
1✔
988
            },
1✔
989
            sources: Box::new(SingleRasterSource {
1✔
990
                raster: RasterOperator::GdalSource(GdalSource {
1✔
991
                    r#type: Default::default(),
1✔
992
                    params: GdalSourceParameters {
1✔
993
                        data: "example_data".to_string(),
1✔
994
                        overview_level: None,
1✔
995
                    },
1✔
996
                }),
1✔
997
            }),
1✔
998
        };
1✔
999

1000
        let ops = OperatorsTemporalRasterAggregation::try_from(api).expect("conversion failed");
1✔
1001

1002
        assert!(matches!(
1✔
1003
            ops.params.aggregation,
1✔
1004
            geoengine_operators::processing::Aggregation::Mean {
1005
                ignore_no_data: true
1006
            }
1007
        ));
1008
        assert_eq!(ops.params.window.step, 1);
1✔
1009
        assert!(ops.params.window_reference.is_none());
1✔
1010
        assert!(ops.params.output_type.is_none());
1✔
1011
    }
1✔
1012

1013
    #[test]
1014
    fn it_converts_raster_stacker_params() {
1✔
1015
        let api = RasterStacker {
1✔
1016
            r#type: Default::default(),
1✔
1017
            params: RasterStackerParameters {
1✔
1018
                rename_bands: RenameBands::Suffix(vec!["_a".to_string(), "_b".to_string()]),
1✔
1019
            },
1✔
1020
            sources: Box::new(MultipleRasterSources {
1✔
1021
                rasters: vec![
1✔
1022
                    RasterOperator::GdalSource(GdalSource {
1✔
1023
                        r#type: Default::default(),
1✔
1024
                        params: GdalSourceParameters {
1✔
1025
                            data: "example_data_a".to_string(),
1✔
1026
                            overview_level: None,
1✔
1027
                        },
1✔
1028
                    }),
1✔
1029
                    RasterOperator::GdalSource(GdalSource {
1✔
1030
                        r#type: Default::default(),
1✔
1031
                        params: GdalSourceParameters {
1✔
1032
                            data: "example_data_b".to_string(),
1✔
1033
                            overview_level: None,
1✔
1034
                        },
1✔
1035
                    }),
1✔
1036
                ],
1✔
1037
            }),
1✔
1038
        };
1✔
1039

1040
        let ops = OperatorsRasterStacker::try_from(api).expect("conversion failed");
1✔
1041

1042
        assert_eq!(
1✔
1043
            ops.params.rename_bands,
1044
            geoengine_datatypes::raster::RenameBands::Suffix(vec![
1✔
1045
                "_a".to_string(),
1✔
1046
                "_b".to_string()
1✔
1047
            ])
1✔
1048
        );
1049
        assert_eq!(ops.sources.rasters.len(), 2);
1✔
1050
    }
1✔
1051

1052
    #[test]
1053
    fn it_converts_raster_type_conversion_params() {
1✔
1054
        let api = RasterTypeConversion {
1✔
1055
            r#type: Default::default(),
1✔
1056
            params: RasterTypeConversionParameters {
1✔
1057
                output_data_type: RasterDataType::U16,
1✔
1058
            },
1✔
1059
            sources: Box::new(SingleRasterSource {
1✔
1060
                raster: RasterOperator::GdalSource(GdalSource {
1✔
1061
                    r#type: Default::default(),
1✔
1062
                    params: GdalSourceParameters {
1✔
1063
                        data: "example_data".to_string(),
1✔
1064
                        overview_level: None,
1✔
1065
                    },
1✔
1066
                }),
1✔
1067
            }),
1✔
1068
        };
1✔
1069

1070
        let ops = OperatorsRasterTypeConversion::try_from(api).expect("conversion failed");
1✔
1071

1072
        assert_eq!(
1✔
1073
            ops.params.output_data_type,
1074
            geoengine_datatypes::raster::RasterDataType::U16
1075
        );
1076
    }
1✔
1077

1078
    #[test]
1079
    fn it_converts_interpolation_params() {
1✔
1080
        let api = Interpolation {
1✔
1081
            r#type: Default::default(),
1✔
1082
            params: InterpolationParameters {
1✔
1083
                interpolation: InterpolationMethod::NearestNeighbor,
1✔
1084
                output_resolution: InterpolationResolution::Fraction { x: 2.0, y: 2.0 },
1✔
1085
                output_origin_reference: None,
1✔
1086
            },
1✔
1087
            sources: Box::new(SingleRasterSource {
1✔
1088
                raster: RasterOperator::MultiBandGdalSource(MultiBandGdalSource {
1✔
1089
                    r#type: Default::default(),
1✔
1090
                    params: GdalSourceParameters {
1✔
1091
                        data: "example_data".to_string(),
1✔
1092
                        overview_level: None,
1✔
1093
                    },
1✔
1094
                }),
1✔
1095
            }),
1✔
1096
        };
1✔
1097

1098
        let ops = OperatorsInterpolation::try_from(api).expect("conversion failed");
1✔
1099

1100
        assert!(matches!(
1✔
1101
            ops.params.interpolation,
1✔
1102
            geoengine_operators::processing::InterpolationMethod::NearestNeighbor
1103
        ));
1104
        assert!(matches!(
1✔
1105
            ops.params.output_resolution,
1✔
1106
            geoengine_operators::processing::InterpolationResolution::Fraction { x, y }
1✔
1107
            if (x - 2.0).abs() < f64::EPSILON && (y - 2.0).abs() < f64::EPSILON
1✔
1108
        ));
1109
        assert!(ops.params.output_origin_reference.is_none());
1✔
1110
    }
1✔
1111

1112
    #[test]
1113
    fn it_converts_band_filter_params() {
1✔
1114
        let api = BandFilter {
1✔
1115
            r#type: Default::default(),
1✔
1116
            params: BandFilterParameters {
1✔
1117
                bands: BandsByNameOrIndex::Name(vec!["nir".to_string(), "red".to_string()]),
1✔
1118
            },
1✔
1119
            sources: Box::new(SingleRasterSource {
1✔
1120
                raster: RasterOperator::MultiBandGdalSource(MultiBandGdalSource {
1✔
1121
                    r#type: Default::default(),
1✔
1122
                    params: GdalSourceParameters {
1✔
1123
                        data: "example_data".to_string(),
1✔
1124
                        overview_level: None,
1✔
1125
                    },
1✔
1126
                }),
1✔
1127
            }),
1✔
1128
        };
1✔
1129

1130
        let ops = OperatorsBandFilter::try_from(api).expect("conversion failed");
1✔
1131

1132
        assert_eq!(
1✔
1133
            serde_json::to_value(ops.params).expect("params should serialize"),
1✔
1134
            json!({
1✔
1135
                "bands": ["nir", "red"]
1✔
1136
            })
1137
        );
1138
    }
1✔
1139
}
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