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

geo-engine / geoengine / 23550417448

25 Mar 2026 03:52PM UTC coverage: 87.389%. First build
23550417448

Pull #1114

github

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

598 of 1795 new or added lines in 12 files covered. (33.31%)

113704 of 130113 relevant lines covered (87.39%)

497604.14 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
    /// Select bands by their names.
203
    Name(Vec<String>),
204
    /// Select bands by zero-based band indices.
205
    Index(Vec<usize>),
206
}
207

208
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, ToSchema, Default)]
209
#[serde(rename_all = "camelCase")]
210
pub enum DeriveOutRasterSpecsSource {
211
    /// Derive output bounds from source data bounds.
212
    DataBounds,
213
    /// Derive output bounds from the target projection bounds.
214
    #[default]
215
    ProjectionBounds,
216
}
217

218
impl From<DeriveOutRasterSpecsSource> for OperatorsDeriveOutRasterSpecsSource {
219
    fn from(value: DeriveOutRasterSpecsSource) -> Self {
2✔
220
        match value {
2✔
NEW
221
            DeriveOutRasterSpecsSource::DataBounds => Self::DataBounds,
×
222
            DeriveOutRasterSpecsSource::ProjectionBounds => Self::ProjectionBounds,
2✔
223
        }
224
    }
2✔
225
}
226

227
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, ToSchema)]
228
#[serde(rename_all = "camelCase", tag = "type")]
229
pub enum InterpolationResolution {
230
    /// Explicit output resolution (`x`, `y`) in target coordinates.
231
    Resolution { x: f64, y: f64 },
232
    /// Upscale factor relative to input resolution (`x >= 1`, `y >= 1`).
233
    Fraction { x: f64, y: f64 },
234
}
235

236
impl From<InterpolationResolution> for OperatorsInterpolationResolution {
237
    fn from(value: InterpolationResolution) -> Self {
1✔
238
        match value {
1✔
NEW
239
            InterpolationResolution::Resolution { x, y } => {
×
NEW
240
                Self::Resolution(geoengine_datatypes::primitives::SpatialResolution { x, y })
×
241
            }
242
            InterpolationResolution::Fraction { x, y } => Self::Fraction { x, y },
1✔
243
        }
244
    }
1✔
245
}
246

247
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, ToSchema)]
248
#[serde(rename_all = "camelCase")]
249
pub enum InterpolationMethod {
250
    /// Nearest-neighbor interpolation.
251
    NearestNeighbor,
252
    /// Bilinear interpolation.
253
    BiLinear,
254
}
255

256
impl From<InterpolationMethod> for OperatorsInterpolationMethod {
257
    fn from(value: InterpolationMethod) -> Self {
1✔
258
        match value {
1✔
259
            InterpolationMethod::NearestNeighbor => Self::NearestNeighbor,
1✔
NEW
260
            InterpolationMethod::BiLinear => Self::BiLinear,
×
261
        }
262
    }
1✔
263
}
264

265
/// Aggregation methods for `TemporalRasterAggregation`.
266
///
267
/// Available variants are `min`, `max`, `first`, `last`, `mean`, `sum`, `count`, and `percentileEstimate`.
268
/// Encountering NO DATA makes the aggregation result NO DATA unless `ignoreNoData` is `true`.
269
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, ToSchema)]
270
#[serde(rename_all = "camelCase", tag = "type")]
271
pub enum Aggregation {
272
    #[schema(title = "MinAggregation")]
273
    #[serde(rename_all = "camelCase")]
274
    Min { ignore_no_data: bool },
275
    #[schema(title = "MaxAggregation")]
276
    #[serde(rename_all = "camelCase")]
277
    Max { ignore_no_data: bool },
278
    #[schema(title = "FirstAggregation")]
279
    #[serde(rename_all = "camelCase")]
280
    First { ignore_no_data: bool },
281
    #[schema(title = "LastAggregation")]
282
    #[serde(rename_all = "camelCase")]
283
    Last { ignore_no_data: bool },
284
    #[schema(title = "MeanAggregation")]
285
    #[serde(rename_all = "camelCase")]
286
    Mean { ignore_no_data: bool },
287
    #[schema(title = "SumAggregation")]
288
    #[serde(rename_all = "camelCase")]
289
    Sum { ignore_no_data: bool },
290
    #[schema(title = "CountAggregation")]
291
    #[serde(rename_all = "camelCase")]
292
    Count { ignore_no_data: bool },
293
    #[schema(title = "PercentileEstimateAggregation")]
294
    #[serde(rename_all = "camelCase")]
295
    PercentileEstimate {
296
        ignore_no_data: bool,
297
        percentile: f64,
298
    },
299
}
300

301
impl From<Aggregation> for OperatorsAggregation {
302
    fn from(value: Aggregation) -> Self {
1✔
303
        match value {
1✔
NEW
304
            Aggregation::Min { ignore_no_data } => Self::Min { ignore_no_data },
×
NEW
305
            Aggregation::Max { ignore_no_data } => Self::Max { ignore_no_data },
×
NEW
306
            Aggregation::First { ignore_no_data } => Self::First { ignore_no_data },
×
NEW
307
            Aggregation::Last { ignore_no_data } => Self::Last { ignore_no_data },
×
308
            Aggregation::Mean { ignore_no_data } => Self::Mean { ignore_no_data },
1✔
NEW
309
            Aggregation::Sum { ignore_no_data } => Self::Sum { ignore_no_data },
×
NEW
310
            Aggregation::Count { ignore_no_data } => Self::Count { ignore_no_data },
×
311
            Aggregation::PercentileEstimate {
NEW
312
                ignore_no_data,
×
NEW
313
                percentile,
×
NEW
314
            } => Self::PercentileEstimate {
×
NEW
315
                ignore_no_data,
×
NEW
316
                percentile,
×
NEW
317
            },
×
318
        }
319
    }
1✔
320
}
321

322
/// The `Reprojection` operator reprojects data from one spatial reference system to another.
323
/// It accepts exactly one input which can either be a raster or a vector data stream.
324
/// The operator produces all data that, after reprojection, is contained in the query rectangle.
325
///
326
/// ## Data Type Specifics
327
///
328
/// The concrete behavior depends on the data type.
329
///
330
/// ### Vector Data
331
///
332
/// The operator reprojects all coordinates of the features individually.
333
/// The result contains all features that, after reprojection, are intersected by the query rectangle.
334
///
335
/// ### Raster Data
336
///
337
/// To create tiles in the target projection, the operator loads corresponding tiles in the source projection.
338
/// For each output pixel, the value of the nearest input pixel is used.
339
///
340
/// If parts of a tile are outside of the source extent after projection, the operator produces NO DATA values.
341
///
342
/// ## Inputs
343
///
344
/// The `Reprojection` operator expects exactly one _raster_ or _vector_ input.
345
///
346
/// ## Errors
347
///
348
/// The operator returns an error if the target projection is unknown or if input data cannot be reprojected.
349
#[api_operator(
170✔
350
    title = "Reprojection",
170✔
351
    examples(json!({
170✔
352
        "type": "Reprojection",
353
        "params": {
354
            "deriveOutSpec": "projectionBounds",
355
            "targetSpatialReference": "EPSG:32632"
356
        },
357
        "sources": {
358
            "source": {
359
                "type": "MockPointSource",
360
                "params": {
361
                    "points": [{ "x": 8.77069, "y": 50.80904 }],
362
                    "spatialBounds": { "type": "none" }
363
                }
364
            }
365
        }
366
    }))
367
)]
368
pub struct Reprojection {
369
    pub params: ReprojectionParameters,
370
    pub sources: Box<SingleRasterOrVectorSource>,
371
}
372

373
/// Parameters for the `Reprojection` operator.
374
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
375
#[serde(rename_all = "camelCase")]
376
pub struct ReprojectionParameters {
377
    /// Target spatial reference system.
378
    #[schema(value_type = String, examples("EPSG:32632"))]
379
    pub target_spatial_reference: SpatialReference,
380
    /// Controls how raster output bounds are derived.
381
    ///
382
    /// The default `projectionBounds` usually keeps a projection-aligned target grid,
383
    /// while `dataBounds` derives it directly from source data bounds.
384
    #[schema(examples("projectionBounds"))]
385
    #[serde(default)]
386
    pub derive_out_spec: DeriveOutRasterSpecsSource,
387
}
388

389
impl TryFrom<Reprojection> for OperatorsReprojection {
390
    type Error = anyhow::Error;
391

392
    fn try_from(value: Reprojection) -> Result<Self, Self::Error> {
2✔
393
        Ok(OperatorsReprojection {
394
            params: OperatorsReprojectionParameters {
2✔
395
                target_spatial_reference: value.params.target_spatial_reference.into(),
2✔
396
                derive_out_spec: value.params.derive_out_spec.into(),
2✔
397
            },
2✔
398
            sources: (*value.sources).try_into()?,
2✔
399
        })
400
    }
2✔
401
}
402

403
/// The `TemporalRasterAggregation` operator aggregates a raster time series into uniform time windows.
404
/// The output starts with the first window that contains the query start and contains all windows
405
/// that overlap the query interval.
406
///
407
/// Pixel values are computed by aggregating all input rasters that contribute to the current window.
408
///
409
/// ## Inputs
410
///
411
/// The `TemporalRasterAggregation` operator expects exactly one _raster_ input.
412
///
413
/// ## Errors
414
///
415
/// If the aggregation method is `first`, `last`, or `mean` and the input raster has no NO DATA value,
416
/// an error is returned.
417
#[api_operator(
90✔
418
    title = "Temporal Raster Aggregation",
90✔
419
    examples(json!({
90✔
420
        "type": "TemporalRasterAggregation",
421
        "params": {
422
            "aggregation": { "type": "mean", "ignoreNoData": true },
423
            "window": { "granularity": "months", "step": 1 }
424
        },
425
        "sources": {
426
            "raster": {
427
                "type": "Expression",
428
                "params": {
429
                    "expression": "(A - B) / (A + B)",
430
                    "outputType": "F32",
431
                    "outputBand": {
432
                        "name": "NDVI",
433
                        "measurement": { "type": "unitless" }
434
                    },
435
                    "mapNoData": false
436
                },
437
                "sources": {
438
                    "raster": {
439
                        "type": "GdalSource",
440
                        "params": { "data": "ndvi" }
441
                    }
442
                }
443
            }
444
        }
445
    }))
446
)]
447
pub struct TemporalRasterAggregation {
448
    pub params: TemporalRasterAggregationParameters,
449
    pub sources: Box<SingleRasterSource>,
450
}
451

452
/// Parameters for the `TemporalRasterAggregation` operator.
453
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
454
#[serde(rename_all = "camelCase")]
455
pub struct TemporalRasterAggregationParameters {
456
    /// Aggregation method for values within each time window.
457
    ///
458
    /// Encountering NO DATA makes the aggregation result NO DATA unless
459
    /// `ignoreNoData` is `true` for the selected aggregation variant.
460
    #[schema(examples(json!({ "type": "mean", "ignoreNoData": true })))]
461
    pub aggregation: Aggregation,
462
    /// Window size and granularity for the output time series.
463
    #[schema(examples(json!({ "granularity": "months", "step": 1 })))]
464
    pub window: TimeStep,
465
    /// Optional reference timestamp used as the anchor for window boundaries.
466
    ///
467
    /// If omitted, windows are anchored at `1970-01-01T00:00:00Z`.
468
    #[schema(examples("2020-01-01T00:00:00.000Z"))]
469
    pub window_reference: Option<TimeInstance>,
470
    /// Optional output raster data type.
471
    #[schema(examples("F32"))]
472
    pub output_type: Option<RasterDataType>,
473
}
474

475
impl TryFrom<TemporalRasterAggregation> for OperatorsTemporalRasterAggregation {
476
    type Error = anyhow::Error;
477

478
    fn try_from(value: TemporalRasterAggregation) -> Result<Self, Self::Error> {
1✔
479
        Ok(OperatorsTemporalRasterAggregation {
480
            params: OperatorsTemporalRasterAggregationParameters {
1✔
481
                aggregation: value.params.aggregation.into(),
1✔
482
                window: value.params.window.into(),
1✔
483
                window_reference: value.params.window_reference.map(Into::into),
1✔
484
                output_type: value.params.output_type.map(Into::into),
1✔
485
            },
1✔
486
            sources: (*value.sources).try_into()?,
1✔
487
        })
488
    }
1✔
489
}
490

491
/// The `RasterStacker` stacks all of its inputs into a single raster time series.
492
/// It queries all inputs and combines them by band, space, and then time.
493
///
494
/// The output raster has as many bands as the sum of all input bands.
495
/// Tiles are automatically temporally aligned.
496
///
497
/// All inputs must have the same data type and spatial reference.
498
///
499
/// ## Inputs
500
///
501
/// The `RasterStacker` operator expects multiple raster inputs.
502
#[api_operator(
90✔
503
    title = "Raster Stacker",
90✔
504
    examples(json!({
90✔
505
        "type": "RasterStacker",
506
        "params": {
507
            "renameBands": { "type": "default" }
508
        },
509
        "sources": {
510
            "rasters": [
511
                {
512
                    "type": "GdalSource",
513
                    "params": { "data": "example-a" }
514
                },
515
                {
516
                    "type": "GdalSource",
517
                    "params": { "data": "example-b" }
518
                }
519
            ]
520
        }
521
    }))
522
)]
523
pub struct RasterStacker {
524
    pub params: RasterStackerParameters,
525
    pub sources: Box<MultipleRasterSources>,
526
}
527

528
/// Parameters for the `RasterStacker` operator.
529
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
530
#[serde(rename_all = "camelCase")]
531
pub struct RasterStackerParameters {
532
    /// Strategy for deriving output band names.
533
    ///
534
    /// - `default`: appends ` (n)` with the smallest `n` that avoids a conflict.
535
    /// - `suffix`: appends one suffix per input.
536
    /// - `rename`: explicitly provides names for all resulting bands.
537
    #[schema(examples(json!({ "type": "default" }), json!({ "type": "suffix", "values": ["_a", "_b"] })))]
538
    pub rename_bands: RenameBands,
539
}
540

541
impl TryFrom<RasterStacker> for OperatorsRasterStacker {
542
    type Error = anyhow::Error;
543

544
    fn try_from(value: RasterStacker) -> Result<Self, Self::Error> {
3✔
545
        Ok(OperatorsRasterStacker {
546
            params: OperatorsRasterStackerParameters {
3✔
547
                rename_bands: value.params.rename_bands.into(),
3✔
548
            },
3✔
549
            sources: (*value.sources).try_into()?,
3✔
550
        })
551
    }
3✔
552
}
553

554
/// The `RasterTypeConversion` operator changes the data type of raster pixels.
555
///
556
/// Applying this conversion may cause precision loss.
557
/// For example, converting `F32` value `3.1` to `U8` results in `3`.
558
///
559
/// If a value is outside of the range of the target data type,
560
/// it is clipped to the valid range of that type.
561
/// For example, converting `F32` value `300.0` to `U8` results in `255`.
562
///
563
/// ## Inputs
564
///
565
/// The `RasterTypeConversion` operator expects exactly one _raster_ input.
566
#[api_operator(
90✔
567
    title = "Raster Type Conversion",
90✔
568
    examples(json!({
90✔
569
        "type": "RasterTypeConversion",
570
        "params": {
571
            "outputDataType": "U16"
572
        },
573
        "sources": {
574
            "raster": {
575
                "type": "GdalSource",
576
                "params": { "data": "example" }
577
            }
578
        }
579
    }))
580
)]
581
pub struct RasterTypeConversion {
582
    pub params: RasterTypeConversionParameters,
583
    pub sources: Box<SingleRasterSource>,
584
}
585

586
/// Parameters for the `RasterTypeConversion` operator.
587
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
588
#[serde(rename_all = "camelCase")]
589
pub struct RasterTypeConversionParameters {
590
    /// Output raster data type.
591
    #[schema(examples("U16"))]
592
    pub output_data_type: RasterDataType,
593
}
594

595
impl TryFrom<RasterTypeConversion> for OperatorsRasterTypeConversion {
596
    type Error = anyhow::Error;
597

598
    fn try_from(value: RasterTypeConversion) -> Result<Self, Self::Error> {
1✔
599
        Ok(OperatorsRasterTypeConversion {
600
            params: OperatorsRasterTypeConversionParameters {
1✔
601
                output_data_type: value.params.output_data_type.into(),
1✔
602
            },
1✔
603
            sources: (*value.sources).try_into()?,
1✔
604
        })
605
    }
1✔
606
}
607

608
/// The `Interpolation` operator increases raster resolution by interpolating values of an input raster.
609
///
610
/// If queried with a resolution that is coarser than the input resolution,
611
/// interpolation is not applicable and an error is returned.
612
///
613
/// ## Inputs
614
///
615
/// The `Interpolation` operator expects exactly one _raster_ input.
616
#[api_operator(
90✔
617
    title = "Interpolation",
90✔
618
    examples(json!({
90✔
619
        "type": "Interpolation",
620
        "params": {
621
            "interpolation": "nearestNeighbor",
622
            "outputResolution": {
623
                "type": "fraction",
624
                "x": 2.0,
625
                "y": 2.0
626
            }
627
        },
628
        "sources": {
629
            "raster": {
630
                "type": "MultiBandGdalSource",
631
                "params": { "data": "sentinel-2-l2a_EPSG32632_U8_20" }
632
            }
633
        }
634
    }))
635
)]
636
pub struct Interpolation {
637
    pub params: InterpolationParameters,
638
    pub sources: Box<SingleRasterSource>,
639
}
640

641
/// Parameters for the `Interpolation` operator.
642
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
643
#[serde(rename_all = "camelCase")]
644
pub struct InterpolationParameters {
645
    /// Interpolation method.
646
    #[schema(examples("nearestNeighbor"))]
647
    pub interpolation: InterpolationMethod,
648
    /// Target output resolution.
649
    #[schema(examples(json!({ "type": "fraction", "x": 2.0, "y": 2.0 })))]
650
    pub output_resolution: InterpolationResolution,
651
    /// Optional reference point used to align the output grid origin.
652
    #[schema(examples(json!({ "x": 0.0, "y": 0.0 })))]
653
    pub output_origin_reference: Option<crate::api::model::datatypes::Coordinate2D>,
654
}
655

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

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

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

703
/// Parameters for the `BandFilter` operator.
704
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
705
#[serde(rename_all = "camelCase")]
706
pub struct BandFilterParameters {
707
    /// Selected bands either by names (e.g. `["nir", "red"]`) or indices (e.g. `[0, 2]`).
708
    #[schema(examples(json!(["nir", "red"]), json!([0, 2])))]
709
    pub bands: BandsByNameOrIndex,
710
}
711

712
impl TryFrom<BandFilter> for OperatorsBandFilter {
713
    type Error = anyhow::Error;
714

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

720
        Ok(OperatorsBandFilter {
721
            params,
1✔
722
            sources: (*value.sources).try_into()?,
1✔
723
        })
724
    }
1✔
725
}
726

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

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

821
impl TryFrom<RasterVectorJoin> for OperatorsRasterVectorJoin {
822
    type Error = anyhow::Error;
823

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

840
#[cfg(test)]
841
mod tests {
842

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1069
        assert_eq!(
1✔
1070
            ops.params.output_data_type,
1071
            geoengine_datatypes::raster::RasterDataType::U16
1072
        );
1073
    }
1✔
1074

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

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

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

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

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

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