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

geo-engine / geoengine / 23499170059

24 Mar 2026 03:58PM UTC coverage: 87.389%. First build
23499170059

Pull #1114

github

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

516 of 1712 new or added lines in 12 files covered. (30.14%)

113705 of 130113 relevant lines covered (87.39%)

497578.48 hits per line

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

94.8
/services/src/api/model/processing_graphs/processing.rs
1
use crate::api::model::processing_graphs::{
2
    parameters::{
3
        ColumnNames, FeatureAggregationMethod, RasterBandDescriptor, RasterDataType,
4
        TemporalAggregationMethod,
5
    },
6
    source_parameters::{
7
        MultipleRasterSources, SingleRasterOrVectorSource, SingleRasterSource,
8
        SingleVectorMultipleRasterSources,
9
    },
10
};
11
use crate::api::model::datatypes::{SpatialReference, TimeInstance, TimeStep};
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,
19
    InterpolationMethod as OperatorsInterpolationMethod,
20
    InterpolationParams as OperatorsInterpolationParameters,
21
    InterpolationResolution as OperatorsInterpolationResolution,
22
    RasterStacker as OperatorsRasterStacker,
23
    RasterStackerParams as OperatorsRasterStackerParameters,
24
    RasterTypeConversion as OperatorsRasterTypeConversion,
25
    RasterTypeConversionParams as OperatorsRasterTypeConversionParameters,
26
    RasterVectorJoin as OperatorsRasterVectorJoin,
27
    RasterVectorJoinParams as OperatorsRasterVectorJoinParameters,
28
    Reprojection as OperatorsReprojection, ReprojectionParams as OperatorsReprojectionParameters,
29
    TemporalRasterAggregation as OperatorsTemporalRasterAggregation,
30
    TemporalRasterAggregationParameters as OperatorsTemporalRasterAggregationParameters,
31
};
32
use serde::{Deserialize, Serialize};
33
use utoipa::ToSchema;
34

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

300
#[api_operator(
170✔
301
    title = "Reprojection",
170✔
302
    examples(json!({
170✔
303
        "type": "Reprojection",
304
        "params": {
305
            "deriveOutSpec": "projectionBounds",
306
            "targetSpatialReference": "EPSG:32632"
307
        },
308
        "sources": {
309
            "source": {
310
                "type": "MockPointSource",
311
                "params": {
312
                    "points": [{ "x": 8.77069, "y": 50.80904 }],
313
                    "spatialBounds": { "type": "none" }
314
                }
315
            }
316
        }
317
    }))
318
)]
319
pub struct Reprojection {
320
    pub params: ReprojectionParameters,
321
    pub sources: Box<SingleRasterOrVectorSource>,
322
}
323

324
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
325
#[serde(rename_all = "camelCase")]
326
pub struct ReprojectionParameters {
327
    #[schema(value_type = String)]
328
    pub target_spatial_reference: SpatialReference,
329
    #[serde(default)]
330
    pub derive_out_spec: DeriveOutRasterSpecsSource,
331
}
332

333
impl TryFrom<Reprojection> for OperatorsReprojection {
334
    type Error = anyhow::Error;
335

336
    fn try_from(value: Reprojection) -> Result<Self, Self::Error> {
2✔
337
        Ok(OperatorsReprojection {
338
            params: OperatorsReprojectionParameters {
2✔
339
                target_spatial_reference: value.params.target_spatial_reference.into(),
2✔
340
                derive_out_spec: value.params.derive_out_spec.into(),
2✔
341
            },
2✔
342
            sources: (*value.sources).try_into()?,
2✔
343
        })
344
    }
2✔
345
}
346

347
#[api_operator(
90✔
348
    title = "Temporal Raster Aggregation",
90✔
349
    examples(json!({
90✔
350
        "type": "TemporalRasterAggregation",
351
        "params": {
352
            "aggregation": { "type": "mean", "ignoreNoData": true },
353
            "window": { "granularity": "months", "step": 1 }
354
        },
355
        "sources": {
356
            "raster": {
357
                "type": "Expression",
358
                "params": {
359
                    "expression": "(A - B) / (A + B)",
360
                    "outputType": "F32",
361
                    "outputBand": {
362
                        "name": "NDVI",
363
                        "measurement": { "type": "unitless" }
364
                    },
365
                    "mapNoData": false
366
                },
367
                "sources": {
368
                    "raster": {
369
                        "type": "GdalSource",
370
                        "params": { "data": "ndvi" }
371
                    }
372
                }
373
            }
374
        }
375
    }))
376
)]
377
pub struct TemporalRasterAggregation {
378
    pub params: TemporalRasterAggregationParameters,
379
    pub sources: Box<SingleRasterSource>,
380
}
381

382
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
383
#[serde(rename_all = "camelCase")]
384
pub struct TemporalRasterAggregationParameters {
385
    pub aggregation: Aggregation,
386
    pub window: TimeStep,
387
    pub window_reference: Option<TimeInstance>,
388
    pub output_type: Option<RasterDataType>,
389
}
390

391
impl TryFrom<TemporalRasterAggregation> for OperatorsTemporalRasterAggregation {
392
    type Error = anyhow::Error;
393

394
    fn try_from(value: TemporalRasterAggregation) -> Result<Self, Self::Error> {
1✔
395
        Ok(OperatorsTemporalRasterAggregation {
396
            params: OperatorsTemporalRasterAggregationParameters {
1✔
397
                aggregation: value.params.aggregation.into(),
1✔
398
                window: value.params.window.into(),
1✔
399
                window_reference: value.params.window_reference.map(Into::into),
1✔
400
                output_type: value.params.output_type.map(Into::into),
1✔
401
            },
1✔
402
            sources: (*value.sources).try_into()?,
1✔
403
        })
404
    }
1✔
405
}
406

407
#[api_operator(
90✔
408
    title = "Raster Stacker",
90✔
409
    examples(json!({
90✔
410
        "type": "RasterStacker",
411
        "params": {
412
            "renameBands": { "type": "default" }
413
        },
414
        "sources": {
415
            "rasters": [
416
                {
417
                    "type": "GdalSource",
418
                    "params": { "data": "example-a" }
419
                },
420
                {
421
                    "type": "GdalSource",
422
                    "params": { "data": "example-b" }
423
                }
424
            ]
425
        }
426
    }))
427
)]
428
pub struct RasterStacker {
429
    pub params: RasterStackerParameters,
430
    pub sources: Box<MultipleRasterSources>,
431
}
432

433
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
434
#[serde(rename_all = "camelCase")]
435
pub struct RasterStackerParameters {
436
    pub rename_bands: RenameBands,
437
}
438

439
impl TryFrom<RasterStacker> for OperatorsRasterStacker {
440
    type Error = anyhow::Error;
441

442
    fn try_from(value: RasterStacker) -> Result<Self, Self::Error> {
3✔
443
        Ok(OperatorsRasterStacker {
444
            params: OperatorsRasterStackerParameters {
3✔
445
                rename_bands: value.params.rename_bands.into(),
3✔
446
            },
3✔
447
            sources: (*value.sources).try_into()?,
3✔
448
        })
449
    }
3✔
450
}
451

452
#[api_operator(
90✔
453
    title = "Raster Type Conversion",
90✔
454
    examples(json!({
90✔
455
        "type": "RasterTypeConversion",
456
        "params": {
457
            "outputDataType": "U16"
458
        },
459
        "sources": {
460
            "raster": {
461
                "type": "GdalSource",
462
                "params": { "data": "example" }
463
            }
464
        }
465
    }))
466
)]
467
pub struct RasterTypeConversion {
468
    pub params: RasterTypeConversionParameters,
469
    pub sources: Box<SingleRasterSource>,
470
}
471

472
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
473
#[serde(rename_all = "camelCase")]
474
pub struct RasterTypeConversionParameters {
475
    pub output_data_type: RasterDataType,
476
}
477

478
impl TryFrom<RasterTypeConversion> for OperatorsRasterTypeConversion {
479
    type Error = anyhow::Error;
480

481
    fn try_from(value: RasterTypeConversion) -> Result<Self, Self::Error> {
1✔
482
        Ok(OperatorsRasterTypeConversion {
483
            params: OperatorsRasterTypeConversionParameters {
1✔
484
                output_data_type: value.params.output_data_type.into(),
1✔
485
            },
1✔
486
            sources: (*value.sources).try_into()?,
1✔
487
        })
488
    }
1✔
489
}
490

491
#[api_operator(
90✔
492
    title = "Interpolation",
90✔
493
    examples(json!({
90✔
494
        "type": "Interpolation",
495
        "params": {
496
            "interpolation": "nearestNeighbor",
497
            "outputResolution": {
498
                "type": "fraction",
499
                "x": 2.0,
500
                "y": 2.0
501
            }
502
        },
503
        "sources": {
504
            "raster": {
505
                "type": "MultiBandGdalSource",
506
                "params": { "data": "sentinel-2-l2a_EPSG32632_U8_20" }
507
            }
508
        }
509
    }))
510
)]
511
pub struct Interpolation {
512
    pub params: InterpolationParameters,
513
    pub sources: Box<SingleRasterSource>,
514
}
515

516
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
517
#[serde(rename_all = "camelCase")]
518
pub struct InterpolationParameters {
519
    pub interpolation: InterpolationMethod,
520
    pub output_resolution: InterpolationResolution,
521
    pub output_origin_reference: Option<crate::api::model::datatypes::Coordinate2D>,
522
}
523

524
impl TryFrom<Interpolation> for OperatorsInterpolation {
525
    type Error = anyhow::Error;
526

527
    fn try_from(value: Interpolation) -> Result<Self, Self::Error> {
1✔
528
        Ok(OperatorsInterpolation {
529
            params: OperatorsInterpolationParameters {
1✔
530
                interpolation: value.params.interpolation.into(),
1✔
531
                output_resolution: value.params.output_resolution.into(),
1✔
532
                output_origin_reference: value.params.output_origin_reference.map(Into::into),
1✔
533
            },
1✔
534
            sources: (*value.sources).try_into()?,
1✔
535
        })
536
    }
1✔
537
}
538

539
#[api_operator(
90✔
540
    title = "Band Filter",
90✔
541
    examples(json!({
90✔
542
        "type": "BandFilter",
543
        "params": {
544
            "bands": ["nir", "red"]
545
        },
546
        "sources": {
547
            "raster": {
548
                "type": "MultiBandGdalSource",
549
                "params": { "data": "sentinel-2-l2a_EPSG32632_U16_10" }
550
            }
551
        }
552
    }))
553
)]
554
pub struct BandFilter {
555
    pub params: BandFilterParameters,
556
    pub sources: Box<SingleRasterSource>,
557
}
558

559
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
560
#[serde(rename_all = "camelCase")]
561
pub struct BandFilterParameters {
562
    #[schema(examples(json!(["nir", "red"]), json!([0, 2])))]
563
    pub bands: BandsByNameOrIndex,
564
}
565

566
impl TryFrom<BandFilter> for OperatorsBandFilter {
567
    type Error = anyhow::Error;
568

569
    fn try_from(value: BandFilter) -> Result<Self, Self::Error> {
1✔
570
        let params = serde_json::from_value::<OperatorsBandFilterParameters>(serde_json::to_value(
1✔
571
            value.params,
1✔
NEW
572
        )?)?;
×
573

574
        Ok(OperatorsBandFilter {
575
            params,
1✔
576
            sources: (*value.sources).try_into()?,
1✔
577
        })
578
    }
1✔
579
}
580

581
/// The `RasterVectorJoin` operator allows combining a single vector input and multiple raster inputs.
582
/// For each raster input, a new column is added to the collection from the vector input.
583
/// The new column contains the value of the raster at the location of the vector feature.
584
/// For features covering multiple pixels like `MultiPoints` or `MultiPolygons`, the value is calculated using an aggregation function selected by the user.
585
/// The same is true if the temporal extent of a vector feature covers multiple raster time steps.
586
/// More details are described below.
587
///
588
/// **Example**:
589
/// You have a collection of agricultural fields (`Polygons`) and a collection of raster images containing each pixel's monthly NDVI value.
590
/// For your application, you want to know the NDVI value of each field.
591
/// The `RasterVectorJoin` operator allows you to combine the vector and raster data and offers multiple spatial and temporal aggregation strategies.
592
/// For example, you can use the `first` aggregation function to get the NDVI value of the first pixel that intersects with each field.
593
/// This is useful for exploratory analysis since the computation is very fast.
594
/// To calculate the mean NDVI value of all pixels that intersect with the field you should use the `mean` aggregation function.
595
/// Since the NDVI data is a monthly time series, you have to specify the temporal aggregation function as well.
596
/// The default is `none` which will create a new feature for each month.
597
/// Other options are `first` and `mean` which will calculate the first or mean NDVI value for each field over time.
598
///
599
/// ## Inputs
600
///
601
/// The `RasterVectorJoin` operator expects one _vector_ input and one or more _raster_ inputs.
602
///
603
/// | Parameter | Type                                |
604
/// | --------- | ----------------------------------- |
605
/// | `sources` | `SingleVectorMultipleRasterSources` |
606
///
607
/// ## Errors
608
///
609
/// If the length of `names` is not equal to the number of raster inputs, an error is thrown.
610
///
611
#[api_operator(
90✔
612
    title = "Raster Vector Join",
90✔
613
    examples(json!({
90✔
614
        "type": "RasterVectorJoin",
615
        "params": {
616
            "names": ["NDVI"],
617
            "featureAggregation": "first",
618
            "temporalAggregation": "mean",
619
            "temporalAggregationIgnoreNoData": true
620
        },
621
        "sources": {
622
            "vector": {
623
                "type": "OgrSource",
624
                "params": {
625
                    "data": "places"
626
                }
627
            },
628
            "rasters": [{
629
                "type": "GdalSource",
630
                "params": {
631
                "data": "ndvi"
632
                }
633
            }]
634
        }
635
    }))
636
)]
637
pub struct RasterVectorJoin {
638
    pub params: RasterVectorJoinParameters,
639
    pub sources: Box<SingleVectorMultipleRasterSources>,
640
}
641

642
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
643
#[serde(rename_all = "camelCase")]
644
pub struct RasterVectorJoinParameters {
645
    /// Specify how the new column names are derived from the raster band names.
646
    ///
647
    /// The `ColumnNames` type is used to specify how the new column names are derived from the raster band names.
648
    ///
649
    /// - **default**: Appends " (n)" to the band name with the smallest `n` that avoids a conflict.
650
    /// - **suffix**: Specifies a suffix for each input, to be appended to the band names.
651
    /// - **rename**: A list of names for each new column.
652
    ///
653
    #[schema(examples(
654
        json!({"type": "default"}),
655
        json!({"type": "suffix", "values": ["_sentinel2"]}),
656
        json!({"type": "rename", "values": ["red", "green", "blue"]}),
657
    ))]
658
    pub names: ColumnNames,
659
    /// The aggregation function to use for features covering multiple pixels.
660
    #[schema(examples("first"))]
661
    pub feature_aggregation: FeatureAggregationMethod,
662
    /// Whether to ignore no data values in the aggregation. Defaults to `false`.
663
    #[serde(default)]
664
    #[schema(examples(true))]
665
    pub feature_aggregation_ignore_no_data: bool,
666
    /// The aggregation function to use for features covering multiple (raster) time steps.
667
    #[schema(examples("mean"))]
668
    pub temporal_aggregation: TemporalAggregationMethod,
669
    /// Whether to ignore no data values in the aggregation. Defaults to `false`.
670
    #[serde(default)]
671
    #[schema(examples(true))]
672
    pub temporal_aggregation_ignore_no_data: bool,
673
}
674

675
impl TryFrom<RasterVectorJoin> for OperatorsRasterVectorJoin {
676
    type Error = anyhow::Error;
677

678
    fn try_from(value: RasterVectorJoin) -> Result<Self, Self::Error> {
1✔
679
        Ok(OperatorsRasterVectorJoin {
680
            params: OperatorsRasterVectorJoinParameters {
1✔
681
                names: value.params.names.into(),
1✔
682
                feature_aggregation: value.params.feature_aggregation.into(),
1✔
683
                feature_aggregation_ignore_no_data: value.params.feature_aggregation_ignore_no_data,
1✔
684
                temporal_aggregation: value.params.temporal_aggregation.into(),
1✔
685
                temporal_aggregation_ignore_no_data: value
1✔
686
                    .params
1✔
687
                    .temporal_aggregation_ignore_no_data,
1✔
688
            },
1✔
689
            sources: (*value.sources).try_into()?,
1✔
690
        })
691
    }
1✔
692
}
693

694
#[cfg(test)]
695
mod tests {
696

697
    use super::*;
698
    use crate::api::model::{
699
        datatypes::{Coordinate2D, TimeGranularity},
700
        processing_graphs::{
701
            SingleRasterOrVectorOperator,
702
            RasterOperator, VectorOperator,
703
            parameters::SpatialBoundsDerive,
704
            source::{
705
                GdalSource, GdalSourceParameters, MockPointSource, MockPointSourceParameters,
706
                MultiBandGdalSource,
707
            },
708
        },
709
    };
710
    use serde_json::json;
711

712
    #[test]
713
    fn it_converts_expressions() {
1✔
714
        let api = Expression {
1✔
715
            r#type: Default::default(),
1✔
716
            params: ExpressionParameters {
1✔
717
                expression: "2 * A + B".to_string(),
1✔
718
                output_type: RasterDataType::F32,
1✔
719
                output_band: None,
1✔
720
                map_no_data: true,
1✔
721
            },
1✔
722
            sources: Box::new(SingleRasterSource {
1✔
723
                raster: RasterOperator::GdalSource(GdalSource {
1✔
724
                    r#type: Default::default(),
1✔
725
                    params: GdalSourceParameters {
1✔
726
                        data: "example_data".to_string(),
1✔
727
                        overview_level: None,
1✔
728
                    },
1✔
729
                }),
1✔
730
            }),
1✔
731
        };
1✔
732

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

735
        assert_eq!(ops.params.expression, "2 * A + B");
1✔
736
        assert_eq!(
1✔
737
            ops.params.output_type,
738
            geoengine_datatypes::raster::RasterDataType::F32
739
        );
740
        assert!(ops.params.output_band.is_none());
1✔
741
        assert!(ops.params.map_no_data);
1✔
742
    }
1✔
743

744
    #[test]
745
    fn it_converts_raster_vector_join_params() {
1✔
746
        let api = RasterVectorJoin {
1✔
747
            r#type: Default::default(),
1✔
748
            params: RasterVectorJoinParameters {
1✔
749
                names: ColumnNames::Names {
1✔
750
                    values: vec!["a".to_string(), "b".to_string()],
1✔
751
                },
1✔
752
                feature_aggregation: FeatureAggregationMethod::First,
1✔
753
                feature_aggregation_ignore_no_data: true,
1✔
754
                temporal_aggregation: TemporalAggregationMethod::Mean,
1✔
755
                temporal_aggregation_ignore_no_data: false,
1✔
756
            },
1✔
757
            sources: Box::new(SingleVectorMultipleRasterSources {
1✔
758
                vector: VectorOperator::MockPointSource(MockPointSource {
1✔
759
                    r#type: Default::default(),
1✔
760
                    params: MockPointSourceParameters {
1✔
761
                        points: vec![Coordinate2D { x: 0.0, y: 0.0 }],
1✔
762
                        spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
763
                    },
1✔
764
                }),
1✔
765
                rasters: vec![RasterOperator::GdalSource(GdalSource {
1✔
766
                    r#type: Default::default(),
1✔
767
                    params: GdalSourceParameters {
1✔
768
                        data: "example_data".to_string(),
1✔
769
                        overview_level: None,
1✔
770
                    },
1✔
771
                })],
1✔
772
            }),
1✔
773
        };
1✔
774

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

777
        assert!(matches!(
1✔
778
            ops_params.params.names,
1✔
779
            geoengine_operators::processing::ColumnNames::Names(_)
780
        ));
781
        assert_eq!(
1✔
782
            ops_params.params.feature_aggregation,
783
            geoengine_operators::processing::FeatureAggregationMethod::First
784
        );
785
        assert!(ops_params.params.feature_aggregation_ignore_no_data);
1✔
786
        assert_eq!(
1✔
787
            ops_params.params.temporal_aggregation,
788
            geoengine_operators::processing::TemporalAggregationMethod::Mean
789
        );
790
        assert!(!ops_params.params.temporal_aggregation_ignore_no_data);
1✔
791
    }
1✔
792

793
    #[test]
794
    fn it_converts_reprojection_params() {
1✔
795
        let api = Reprojection {
1✔
796
            r#type: Default::default(),
1✔
797
            params: ReprojectionParameters {
1✔
798
                target_spatial_reference: "EPSG:32632".parse().expect("valid srs"),
1✔
799
                derive_out_spec: DeriveOutRasterSpecsSource::ProjectionBounds,
1✔
800
            },
1✔
801
            sources: Box::new(SingleRasterOrVectorSource {
1✔
802
                source: SingleRasterOrVectorOperator::Vector(VectorOperator::MockPointSource(
1✔
803
                    MockPointSource {
1✔
804
                        r#type: Default::default(),
1✔
805
                        params: MockPointSourceParameters {
1✔
806
                            points: vec![Coordinate2D { x: 0.0, y: 0.0 }],
1✔
807
                            spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
808
                        },
1✔
809
                    },
1✔
810
                )),
1✔
811
            }),
1✔
812
        };
1✔
813

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

816
        assert_eq!(
1✔
817
            ops.params.target_spatial_reference.to_string(),
1✔
818
            "EPSG:32632"
819
        );
820
        assert!(matches!(
1✔
821
            ops.params.derive_out_spec,
1✔
822
            geoengine_operators::processing::DeriveOutRasterSpecsSource::ProjectionBounds
823
        ));
824
    }
1✔
825

826
    #[test]
827
    fn it_converts_temporal_raster_aggregation_params() {
1✔
828
        let api = TemporalRasterAggregation {
1✔
829
            r#type: Default::default(),
1✔
830
            params: TemporalRasterAggregationParameters {
1✔
831
                aggregation: Aggregation::Mean {
1✔
832
                    ignore_no_data: true,
1✔
833
                },
1✔
834
                window: crate::api::model::datatypes::TimeStep {
1✔
835
                    granularity: TimeGranularity::Months,
1✔
836
                    step: 1,
1✔
837
                },
1✔
838
                window_reference: None,
1✔
839
                output_type: None,
1✔
840
            },
1✔
841
            sources: Box::new(SingleRasterSource {
1✔
842
                raster: RasterOperator::GdalSource(GdalSource {
1✔
843
                    r#type: Default::default(),
1✔
844
                    params: GdalSourceParameters {
1✔
845
                        data: "example_data".to_string(),
1✔
846
                        overview_level: None,
1✔
847
                    },
1✔
848
                }),
1✔
849
            }),
1✔
850
        };
1✔
851

852
        let ops =
1✔
853
            OperatorsTemporalRasterAggregation::try_from(api).expect("conversion failed");
1✔
854

855
        assert!(matches!(
1✔
856
            ops.params.aggregation,
1✔
857
            geoengine_operators::processing::Aggregation::Mean {
858
                ignore_no_data: true
859
            }
860
        ));
861
        assert_eq!(ops.params.window.step, 1);
1✔
862
        assert!(ops.params.window_reference.is_none());
1✔
863
        assert!(ops.params.output_type.is_none());
1✔
864
    }
1✔
865

866
    #[test]
867
    fn it_converts_raster_stacker_params() {
1✔
868
        let api = RasterStacker {
1✔
869
            r#type: Default::default(),
1✔
870
            params: RasterStackerParameters {
1✔
871
                rename_bands: RenameBands::Suffix(vec!["_a".to_string(), "_b".to_string()]),
1✔
872
            },
1✔
873
            sources: Box::new(MultipleRasterSources {
1✔
874
                rasters: vec![
1✔
875
                    RasterOperator::GdalSource(GdalSource {
1✔
876
                        r#type: Default::default(),
1✔
877
                        params: GdalSourceParameters {
1✔
878
                            data: "example_data_a".to_string(),
1✔
879
                            overview_level: None,
1✔
880
                        },
1✔
881
                    }),
1✔
882
                    RasterOperator::GdalSource(GdalSource {
1✔
883
                        r#type: Default::default(),
1✔
884
                        params: GdalSourceParameters {
1✔
885
                            data: "example_data_b".to_string(),
1✔
886
                            overview_level: None,
1✔
887
                        },
1✔
888
                    }),
1✔
889
                ],
1✔
890
            }),
1✔
891
        };
1✔
892

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

895
        assert_eq!(
1✔
896
            ops.params.rename_bands,
897
            geoengine_datatypes::raster::RenameBands::Suffix(vec![
1✔
898
                "_a".to_string(),
1✔
899
                "_b".to_string()
1✔
900
            ])
1✔
901
        );
902
        assert_eq!(ops.sources.rasters.len(), 2);
1✔
903
    }
1✔
904

905
    #[test]
906
    fn it_converts_raster_type_conversion_params() {
1✔
907
        let api = RasterTypeConversion {
1✔
908
            r#type: Default::default(),
1✔
909
            params: RasterTypeConversionParameters {
1✔
910
                output_data_type: RasterDataType::U16,
1✔
911
            },
1✔
912
            sources: Box::new(SingleRasterSource {
1✔
913
                raster: 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 = OperatorsRasterTypeConversion::try_from(api).expect("conversion failed");
1✔
924

925
        assert_eq!(
1✔
926
            ops.params.output_data_type,
927
            geoengine_datatypes::raster::RasterDataType::U16
928
        );
929
    }
1✔
930

931
    #[test]
932
    fn it_converts_interpolation_params() {
1✔
933
        let api = Interpolation {
1✔
934
            r#type: Default::default(),
1✔
935
            params: InterpolationParameters {
1✔
936
                interpolation: InterpolationMethod::NearestNeighbor,
1✔
937
                output_resolution: InterpolationResolution::Fraction { x: 2.0, y: 2.0 },
1✔
938
                output_origin_reference: None,
1✔
939
            },
1✔
940
            sources: Box::new(SingleRasterSource {
1✔
941
                raster: RasterOperator::MultiBandGdalSource(MultiBandGdalSource {
1✔
942
                    r#type: Default::default(),
1✔
943
                    params: GdalSourceParameters {
1✔
944
                        data: "example_data".to_string(),
1✔
945
                        overview_level: None,
1✔
946
                    },
1✔
947
                }),
1✔
948
            }),
1✔
949
        };
1✔
950

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

953
        assert!(matches!(
1✔
954
            ops.params.interpolation,
1✔
955
            geoengine_operators::processing::InterpolationMethod::NearestNeighbor
956
        ));
957
        assert!(matches!(
1✔
958
            ops.params.output_resolution,
1✔
959
            geoengine_operators::processing::InterpolationResolution::Fraction { x, y }
1✔
960
            if (x - 2.0).abs() < f64::EPSILON && (y - 2.0).abs() < f64::EPSILON
1✔
961
        ));
962
        assert!(ops.params.output_origin_reference.is_none());
1✔
963
    }
1✔
964

965
    #[test]
966
    fn it_converts_band_filter_params() {
1✔
967
        let api = BandFilter {
1✔
968
            r#type: Default::default(),
1✔
969
            params: BandFilterParameters {
1✔
970
                bands: BandsByNameOrIndex::Name(vec!["nir".to_string(), "red".to_string()]),
1✔
971
            },
1✔
972
            sources: Box::new(SingleRasterSource {
1✔
973
                raster: RasterOperator::MultiBandGdalSource(MultiBandGdalSource {
1✔
974
                    r#type: Default::default(),
1✔
975
                    params: GdalSourceParameters {
1✔
976
                        data: "example_data".to_string(),
1✔
977
                        overview_level: None,
1✔
978
                    },
1✔
979
                }),
1✔
980
            }),
1✔
981
        };
1✔
982

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

985
        assert_eq!(
1✔
986
            serde_json::to_value(ops.params).expect("params should serialize"),
1✔
987
            json!({
1✔
988
                "bands": ["nir", "red"]
1✔
989
            })
990
        );
991
    }
1✔
992
}
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