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

geo-engine / geoengine / 30619099966

31 Jul 2026 08:41AM UTC coverage: 87.684% (+0.04%) from 87.647%
30619099966

push

github

web-flow
feat: add VectorExpression operator to processing graph api (#1221)

* feat: add VectorExpression operator to processing graph api

* fix: update code blocks in documentation to ignore syntax highlighting

* fix: remove double type in MetaDataDefinition

* fix: update TypedResultDescriptor to use untagged serde representation

* fix: data id type

* fix: examples and default for VectorParams

250 of 259 new or added lines in 4 files covered. (96.53%)

124739 of 142260 relevant lines covered (87.68%)

476924.86 hits per line

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

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

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

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

172
impl TryFrom<Expression> for OperatorsExpression {
173
    type Error = anyhow::Error;
174

175
    fn try_from(value: Expression) -> Result<Self, Self::Error> {
2✔
176
        Ok(OperatorsExpression {
177
            params: OperatorsExpressionParameters {
2✔
178
                expression: value.params.expression,
2✔
179
                output_type: value.params.output_type.into(),
2✔
180
                output_band: value.params.output_band.map(Into::into),
2✔
181
                map_no_data: value.params.map_no_data,
2✔
182
            },
2✔
183
            sources: (*value.sources).try_into()?,
2✔
184
        })
185
    }
2✔
186
}
187

188
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
189
#[serde(rename_all = "camelCase", tag = "type", content = "values")]
190
pub enum RenameBands {
191
    #[schema(title = "Default")]
192
    Default,
193
    #[schema(title = "Suffix")]
194
    Suffix(Vec<String>),
195
    #[schema(title = "Rename")]
196
    Rename(Vec<String>),
197
}
198

199
impl From<RenameBands> for geoengine_datatypes::raster::RenameBands {
200
    fn from(value: RenameBands) -> Self {
6✔
201
        match value {
6✔
202
            RenameBands::Default => Self::Default,
1✔
203
            RenameBands::Suffix(values) => Self::Suffix(values),
1✔
204
            RenameBands::Rename(values) => Self::Rename(values),
4✔
205
        }
206
    }
6✔
207
}
208

209
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
210
#[serde(untagged)]
211
pub enum BandsByNameOrIndex {
212
    /// Select bands by their names.
213
    Name(Vec<String>),
214
    /// Select bands by zero-based band indices.
215
    Index(Vec<usize>),
216
}
217

218
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, ToSchema, Default)]
219
#[serde(rename_all = "camelCase")]
220
pub enum DeriveOutRasterSpecsSource {
221
    /// Derive output bounds from source data bounds.
222
    DataBounds,
223
    /// Derive output bounds from the target projection bounds.
224
    #[default]
225
    ProjectionBounds,
226
}
227

228
impl From<DeriveOutRasterSpecsSource> for OperatorsDeriveOutRasterSpecsSource {
229
    fn from(value: DeriveOutRasterSpecsSource) -> Self {
16✔
230
        match value {
16✔
231
            DeriveOutRasterSpecsSource::DataBounds => Self::DataBounds,
14✔
232
            DeriveOutRasterSpecsSource::ProjectionBounds => Self::ProjectionBounds,
2✔
233
        }
234
    }
16✔
235
}
236

237
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, ToSchema)]
238
#[serde(rename_all = "camelCase", tag = "type")]
239
pub enum InterpolationResolution {
240
    #[schema(title = "Resolution")]
241
    /// Explicit output resolution (`x`, `y`) in target coordinates.
242
    Resolution { x: f64, y: f64 },
243
    #[schema(title = "Fraction")]
244
    /// Scaling factor in x/y direction.
245
    Fraction { x: f64, y: f64 },
246
}
247

248
impl From<InterpolationResolution> for OperatorsInterpolationResolution {
249
    fn from(value: InterpolationResolution) -> Self {
4✔
250
        match value {
4✔
251
            InterpolationResolution::Resolution { x, y } => {
×
252
                Self::Resolution(geoengine_datatypes::primitives::SpatialResolution { x, y })
×
253
            }
254
            InterpolationResolution::Fraction { x, y } => {
4✔
255
                Self::Fraction(OperatorsFraction { x, y })
4✔
256
            }
257
        }
258
    }
4✔
259
}
260

261
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, ToSchema)]
262
#[serde(rename_all = "camelCase")]
263
pub enum InterpolationMethod {
264
    /// Nearest-neighbor interpolation.
265
    NearestNeighbor,
266
    /// Bilinear interpolation.
267
    BiLinear,
268
}
269

270
impl From<InterpolationMethod> for OperatorsInterpolationMethod {
271
    fn from(value: InterpolationMethod) -> Self {
4✔
272
        match value {
4✔
273
            InterpolationMethod::NearestNeighbor => Self::NearestNeighbor,
4✔
274
            InterpolationMethod::BiLinear => Self::BiLinear,
×
275
        }
276
    }
4✔
277
}
278

279
/// Aggregation methods for `TemporalRasterAggregation`.
280
///
281
/// Available variants are `min`, `max`, `first`, `last`, `mean`, `sum`, `count`, and `percentileEstimate`.
282
/// Encountering NO DATA makes the aggregation result NO DATA unless `ignoreNoData` is `true`.
283
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, ToSchema)]
284
#[serde(rename_all = "camelCase", tag = "type")]
285
pub enum Aggregation {
286
    #[schema(title = "MinAggregation")]
287
    #[serde(rename_all = "camelCase")]
288
    Min { ignore_no_data: bool },
289
    #[schema(title = "MaxAggregation")]
290
    #[serde(rename_all = "camelCase")]
291
    Max { ignore_no_data: bool },
292
    #[schema(title = "FirstAggregation")]
293
    #[serde(rename_all = "camelCase")]
294
    First { ignore_no_data: bool },
295
    #[schema(title = "LastAggregation")]
296
    #[serde(rename_all = "camelCase")]
297
    Last { ignore_no_data: bool },
298
    #[schema(title = "MeanAggregation")]
299
    #[serde(rename_all = "camelCase")]
300
    Mean { ignore_no_data: bool },
301
    #[schema(title = "SumAggregation")]
302
    #[serde(rename_all = "camelCase")]
303
    Sum { ignore_no_data: bool },
304
    #[schema(title = "CountAggregation")]
305
    #[serde(rename_all = "camelCase")]
306
    Count { ignore_no_data: bool },
307
    #[schema(title = "PercentileEstimateAggregation")]
308
    #[serde(rename_all = "camelCase")]
309
    PercentileEstimate {
310
        ignore_no_data: bool,
311
        percentile: f64,
312
    },
313
}
314

315
impl From<Aggregation> for OperatorsAggregation {
316
    fn from(value: Aggregation) -> Self {
2✔
317
        match value {
2✔
318
            Aggregation::Min { ignore_no_data } => Self::Min { ignore_no_data },
×
319
            Aggregation::Max { ignore_no_data } => Self::Max { ignore_no_data },
×
320
            Aggregation::First { ignore_no_data } => Self::First { ignore_no_data },
1✔
321
            Aggregation::Last { ignore_no_data } => Self::Last { ignore_no_data },
×
322
            Aggregation::Mean { ignore_no_data } => Self::Mean { ignore_no_data },
1✔
323
            Aggregation::Sum { ignore_no_data } => Self::Sum { ignore_no_data },
×
324
            Aggregation::Count { ignore_no_data } => Self::Count { ignore_no_data },
×
325
            Aggregation::PercentileEstimate {
326
                ignore_no_data,
×
327
                percentile,
×
328
            } => Self::PercentileEstimate {
×
329
                ignore_no_data,
×
330
                percentile,
×
331
            },
×
332
        }
333
    }
2✔
334
}
335

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

387
/// Parameters for the `Reprojection` operator.
388
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
389
#[serde(rename_all = "camelCase")]
390
pub struct ReprojectionParameters {
391
    /// Target spatial reference system.
392
    #[schema(value_type = String, examples("EPSG:32632"))]
393
    pub target_spatial_reference: SpatialReference,
394
    /// Controls how raster output bounds are derived.
395
    ///
396
    /// The default `projectionBounds` usually keeps a projection-aligned target grid,
397
    /// while `dataBounds` derives it directly from source data bounds.
398
    #[schema(examples("projectionBounds"))]
399
    #[serde(default)]
400
    pub derive_out_spec: DeriveOutRasterSpecsSource,
401
}
402

403
impl TryFrom<Reprojection> for OperatorsReprojection {
404
    type Error = anyhow::Error;
405

406
    fn try_from(value: Reprojection) -> Result<Self, Self::Error> {
16✔
407
        Ok(OperatorsReprojection {
408
            params: OperatorsReprojectionParameters {
16✔
409
                target_spatial_reference: value.params.target_spatial_reference.into(),
16✔
410
                derive_out_spec: value.params.derive_out_spec.into(),
16✔
411
            },
16✔
412
            sources: (*value.sources).try_into()?,
16✔
413
        })
414
    }
16✔
415
}
416

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

466
/// Parameters for the `TemporalRasterAggregation` operator.
467
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
468
#[serde(rename_all = "camelCase")]
469
pub struct TemporalRasterAggregationParameters {
470
    /// Aggregation method for values within each time window.
471
    ///
472
    /// Encountering NO DATA makes the aggregation result NO DATA unless
473
    /// `ignoreNoData` is `true` for the selected aggregation variant.
474
    #[schema(examples(json!({ "type": "mean", "ignoreNoData": true })))]
475
    pub aggregation: Aggregation,
476
    /// Window size and granularity for the output time series.
477
    #[schema(examples(json!({ "granularity": "months", "step": 1 })))]
478
    pub window: TimeStep,
479
    /// Optional reference timestamp used as the anchor for window boundaries.
480
    ///
481
    /// If omitted, windows are anchored at `1970-01-01T00:00:00Z`.
482
    #[schema(examples("2020-01-01T00:00:00.000Z"))]
483
    pub window_reference: Option<TimeInstance>,
484
    /// Optional output raster data type.
485
    #[schema(examples("F32"))]
486
    pub output_type: Option<RasterDataType>,
487
}
488

489
impl TryFrom<TemporalRasterAggregation> for OperatorsTemporalRasterAggregation {
490
    type Error = anyhow::Error;
491

492
    fn try_from(value: TemporalRasterAggregation) -> Result<Self, Self::Error> {
2✔
493
        Ok(OperatorsTemporalRasterAggregation {
494
            params: OperatorsTemporalRasterAggregationParameters {
2✔
495
                aggregation: value.params.aggregation.into(),
2✔
496
                window: value.params.window.into(),
2✔
497
                window_reference: value.params.window_reference.map(Into::into),
2✔
498
                output_type: value.params.output_type.map(Into::into),
2✔
499
            },
2✔
500
            sources: (*value.sources).try_into()?,
2✔
501
        })
502
    }
2✔
503
}
504

505
/// The `RasterStacker` stacks all of its inputs into a single raster time series.
506
/// It queries all inputs and combines them by band, space, and then time.
507
///
508
/// The output raster has as many bands as the sum of all input bands.
509
/// Tiles are automatically temporally aligned.
510
///
511
/// All inputs must have the same data type and spatial reference.
512
///
513
/// ## Inputs
514
///
515
/// The `RasterStacker` operator expects multiple raster inputs.
516
#[api_operator(
90✔
517
    title = "Raster Stacker",
90✔
518
    examples(json!({
90✔
519
        "type": "RasterStacker",
520
        "params": {
521
            "renameBands": { "type": "default" }
522
        },
523
        "sources": {
524
            "rasters": [
525
                {
526
                    "type": "GdalSource",
527
                    "params": { "data": "example-a" }
528
                },
529
                {
530
                    "type": "GdalSource",
531
                    "params": { "data": "example-b" }
532
                }
533
            ]
534
        }
535
    }))
536
)]
537
pub struct RasterStacker {
538
    pub params: RasterStackerParameters,
539
    pub sources: Box<MultipleRasterSources>,
540
}
541

542
/// Parameters for the `RasterStacker` operator.
543
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
544
#[serde(rename_all = "camelCase")]
545
pub struct RasterStackerParameters {
546
    /// Strategy for deriving output band names.
547
    ///
548
    /// - `default`: appends ` (n)` with the smallest `n` that avoids a conflict.
549
    /// - `suffix`: appends one suffix per input.
550
    /// - `rename`: explicitly provides names for all resulting bands.
551
    #[schema(examples(json!({ "type": "default" }), json!({ "type": "suffix", "values": ["_a", "_b"] })))]
552
    pub rename_bands: RenameBands,
553
}
554

555
impl TryFrom<RasterStacker> for OperatorsRasterStacker {
556
    type Error = anyhow::Error;
557

558
    fn try_from(value: RasterStacker) -> Result<Self, Self::Error> {
6✔
559
        Ok(OperatorsRasterStacker {
560
            params: OperatorsRasterStackerParameters {
6✔
561
                rename_bands: value.params.rename_bands.into(),
6✔
562
            },
6✔
563
            sources: (*value.sources).try_into()?,
6✔
564
        })
565
    }
6✔
566
}
567

568
/// The `RasterTypeConversion` operator changes the data type of raster pixels.
569
///
570
/// Applying this conversion may cause precision loss.
571
/// For example, converting `F32` value `3.1` to `U8` results in `3`.
572
///
573
/// If a value is outside of the range of the target data type,
574
/// it is clipped to the valid range of that type.
575
/// For example, converting `F32` value `300.0` to `U8` results in `255`.
576
///
577
/// ## Inputs
578
///
579
/// The `RasterTypeConversion` operator expects exactly one _raster_ input.
580
#[api_operator(
90✔
581
    title = "Raster Type Conversion",
90✔
582
    examples(json!({
90✔
583
        "type": "RasterTypeConversion",
584
        "params": {
585
            "outputDataType": "U16"
586
        },
587
        "sources": {
588
            "raster": {
589
                "type": "GdalSource",
590
                "params": { "data": "example" }
591
            }
592
        }
593
    }))
594
)]
595
pub struct RasterTypeConversion {
596
    pub params: RasterTypeConversionParameters,
597
    pub sources: Box<SingleRasterSource>,
598
}
599

600
/// Parameters for the `RasterTypeConversion` operator.
601
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
602
#[serde(rename_all = "camelCase")]
603
pub struct RasterTypeConversionParameters {
604
    /// Output raster data type.
605
    #[schema(examples("U16"))]
606
    pub output_data_type: RasterDataType,
607
}
608

609
impl TryFrom<RasterTypeConversion> for OperatorsRasterTypeConversion {
610
    type Error = anyhow::Error;
611

612
    fn try_from(value: RasterTypeConversion) -> Result<Self, Self::Error> {
2✔
613
        Ok(OperatorsRasterTypeConversion {
614
            params: OperatorsRasterTypeConversionParameters {
2✔
615
                output_data_type: value.params.output_data_type.into(),
2✔
616
            },
2✔
617
            sources: (*value.sources).try_into()?,
2✔
618
        })
619
    }
2✔
620
}
621

622
/// The `Interpolation` operator increases raster resolution by interpolating values of an input raster.
623
///
624
/// If queried with a resolution that is coarser than the input resolution,
625
/// interpolation is not applicable and an error is returned.
626
///
627
/// ## Inputs
628
///
629
/// The `Interpolation` operator expects exactly one _raster_ input.
630
///
631
/// ## Resolution
632
///
633
/// The target resolution can be specified either as an explicit `Resolution` (in pixel units)
634
/// or as a `Fraction` that scales the input resolution.
635
///
636
/// ```rust,ignore
637
/// // Scale the input resolution by a factor of 2 in both x and y directions
638
/// InterpolationResolution::Fraction(Fraction { x: 2.0, y: 2.0 })
639
/// ```
640
///
641
/// ```rust,ignore
642
/// // Use an explicit resolution of 50×50 pixel units
643
/// InterpolationResolution::Resolution(SpatialResolution { x: 50.0, y: 50.0 })
644
/// ```
645
#[api_operator(
90✔
646
    title = "Interpolation",
90✔
647
    examples(json!({
90✔
648
        "type": "Interpolation",
649
        "params": {
650
            "interpolation": "nearestNeighbor",
651
            "outputResolution": {
652
                "type": "fraction",
653
                "x": 2.0,
654
                "y": 2.0
655
            }
656
        },
657
        "sources": {
658
            "raster": {
659
                "type": "MultiBandGdalSource",
660
                "params": { "data": "sentinel-2-l2a_EPSG32632_U8_20" }
661
            }
662
        }
663
    }))
664
)]
665
pub struct Interpolation {
666
    pub params: InterpolationParameters,
667
    pub sources: Box<SingleRasterSource>,
668
}
669

670
/// Parameters for the `Interpolation` operator.
671
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
672
#[serde(rename_all = "camelCase")]
673
pub struct InterpolationParameters {
674
    /// Interpolation method.
675
    #[schema(examples("nearestNeighbor"))]
676
    pub interpolation: InterpolationMethod,
677
    /// Target output resolution.
678
    #[schema(examples(json!({ "type": "fraction", "x": 2.0, "y": 2.0 })))]
679
    pub output_resolution: InterpolationResolution,
680
    /// Optional reference point used to align the output grid origin.
681
    #[schema(examples(json!({ "x": 0.0, "y": 0.0 })))]
682
    pub output_origin_reference: Option<crate::api::model::datatypes::Coordinate2D>,
683
}
684

685
impl TryFrom<Interpolation> for OperatorsInterpolation {
686
    type Error = anyhow::Error;
687

688
    fn try_from(value: Interpolation) -> Result<Self, Self::Error> {
4✔
689
        Ok(OperatorsInterpolation {
690
            params: OperatorsInterpolationParameters {
4✔
691
                interpolation: value.params.interpolation.into(),
4✔
692
                output_resolution: value.params.output_resolution.into(),
4✔
693
                output_origin_reference: value.params.output_origin_reference.map(Into::into),
4✔
694
            },
4✔
695
            sources: (*value.sources).try_into()?,
4✔
696
        })
697
    }
4✔
698
}
699

700
/// The `Downsampling` operator decreases raster resolution by sampling values of an input raster.
701
///
702
/// If queried with a resolution that is finer than the input resolution,
703
/// downsampling is not applicable and an error is returned.
704
///
705
/// ## Inputs
706
///
707
/// The `Downsampling` operator expects exactly one _raster_ input.
708
///
709
/// ## Resolution
710
///
711
/// The target resolution can be specified either as an explicit `Resolution` (in pixel units)
712
/// or as a `Fraction` that scales the input resolution.
713
///
714
/// ```rust,ignore
715
/// // Scale the input resolution by a factor of 2 in both x and y directions
716
/// DownsamplingResolution::Fraction(Fraction { x: 2.0, y: 2.0 })
717
/// ```
718
///
719
/// ```rust,ignore
720
/// // Use an explicit resolution of 200×200 pixel units
721
/// DownsamplingResolution::Resolution(SpatialResolution { x: 200.0, y: 200.0 })
722
/// ```
723
#[api_operator(
90✔
724
    title = "Downsampling",
90✔
725
    examples(json!({
90✔
726
        "type": "Downsampling",
727
        "params": {
728
            "samplingMethod": "nearestNeighbor",
729
            "outputResolution": {
730
                "type": "fraction",
731
                "x": 2.0,
732
                "y": 2.0
733
            }
734
        },
735
        "sources": {
736
            "raster": {
737
                "type": "MultiBandGdalSource",
738
                "params": { "data": "sentinel-2-l2a_EPSG32632_U8_20" }
739
            }
740
        }
741
    }))
742
)]
743
pub struct Downsampling {
744
    pub params: DownsamplingParameters,
745
    pub sources: Box<SingleRasterSource>,
746
}
747

748
/// Parameters for the `Downsampling` operator.
749
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
750
#[serde(rename_all = "camelCase")]
751
pub struct DownsamplingParameters {
752
    /// Downsampling method.
753
    #[schema(examples("nearestNeighbor"))]
754
    pub sampling_method: DownsamplingMethod,
755
    /// Target output resolution.
756
    #[schema(examples(json!({ "type": "fraction", "x": 2.0, "y": 2.0 })))]
757
    pub output_resolution: DownsamplingResolution,
758
    /// Optional reference point used to align the output grid origin.
759
    #[schema(examples(json!({ "x": 0.0, "y": 0.0 })))]
760
    pub output_origin_reference: Option<crate::api::model::datatypes::Coordinate2D>,
761
}
762

763
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, ToSchema)]
764
#[serde(rename_all = "camelCase")]
765
pub enum DownsamplingMethod {
766
    /// Nearest-neighbor downsampling.
767
    NearestNeighbor,
768
}
769

770
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, ToSchema)]
771
#[serde(rename_all = "camelCase", tag = "type")]
772
pub enum DownsamplingResolution {
773
    #[schema(title = "Resolution")]
774
    /// Explicit output resolution (`x`, `y`) in target coordinates.
775
    Resolution { x: f64, y: f64 },
776
    #[schema(title = "Fraction")]
777
    /// Scaling factor in x/y direction.
778
    Fraction { x: f64, y: f64 },
779
}
780

781
impl From<DownsamplingResolution> for OperatorsDownsamplingResolution {
782
    fn from(value: DownsamplingResolution) -> Self {
1✔
783
        match value {
1✔
784
            DownsamplingResolution::Resolution { x, y } => {
×
785
                Self::Resolution(geoengine_datatypes::primitives::SpatialResolution { x, y })
×
786
            }
787
            DownsamplingResolution::Fraction { x, y } => Self::Fraction(OperatorsFraction { x, y }),
1✔
788
        }
789
    }
1✔
790
}
791

792
impl From<DownsamplingMethod> for OperatorsDownsamplingMethod {
793
    fn from(value: DownsamplingMethod) -> Self {
1✔
794
        match value {
1✔
795
            DownsamplingMethod::NearestNeighbor => Self::NearestNeighbor,
1✔
796
        }
797
    }
1✔
798
}
799

800
impl TryFrom<Downsampling> for OperatorsDownsampling {
801
    type Error = anyhow::Error;
802

803
    fn try_from(value: Downsampling) -> Result<Self, Self::Error> {
1✔
804
        Ok(OperatorsDownsampling {
805
            params: OperatorsDownsamplingParameters {
1✔
806
                sampling_method: value.params.sampling_method.into(),
1✔
807
                output_resolution: value.params.output_resolution.into(),
1✔
808
                output_origin_reference: value.params.output_origin_reference.map(Into::into),
1✔
809
            },
1✔
810
            sources: (*value.sources).try_into()?,
1✔
811
        })
812
    }
1✔
813
}
814

815
/// The `BandFilter` operator selects bands from a raster source by band names or band indices.
816
///
817
/// It removes all non-selected bands while preserving the original order of remaining bands.
818
///
819
/// ## Inputs
820
///
821
/// The `BandFilter` operator expects exactly one _raster_ input.
822
///
823
/// ## Errors
824
///
825
/// The operator returns an error if no bands are selected or if selected band names/indices
826
/// cannot be mapped to existing input bands.
827
#[api_operator(
90✔
828
    title = "Band Filter",
90✔
829
    examples(json!({
90✔
830
        "type": "BandFilter",
831
        "params": {
832
            "bands": ["nir", "red"]
833
        },
834
        "sources": {
835
            "raster": {
836
                "type": "MultiBandGdalSource",
837
                "params": { "data": "sentinel-2-l2a_EPSG32632_U16_10" }
838
            }
839
        }
840
    }))
841
)]
842
pub struct BandFilter {
843
    pub params: BandFilterParameters,
844
    pub sources: Box<SingleRasterSource>,
845
}
846

847
/// Parameters for the `BandFilter` operator.
848
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)]
849
#[serde(rename_all = "camelCase")]
850
pub struct BandFilterParameters {
851
    /// Selected bands either by names (e.g. `["nir", "red"]`) or indices (e.g. `[0, 2]`).
852
    #[schema(examples(json!(["nir", "red"]), json!([0, 2])))]
853
    pub bands: BandsByNameOrIndex,
854
}
855

856
impl TryFrom<BandFilter> for OperatorsBandFilter {
857
    type Error = anyhow::Error;
858

859
    fn try_from(value: BandFilter) -> Result<Self, Self::Error> {
3✔
860
        let params = serde_json::from_value::<OperatorsBandFilterParameters>(
3✔
861
            serde_json::to_value(value.params)?,
3✔
862
        )?;
×
863

864
        Ok(OperatorsBandFilter {
865
            params,
3✔
866
            sources: (*value.sources).try_into()?,
3✔
867
        })
868
    }
3✔
869
}
870

871
/// The `RasterVectorJoin` operator allows combining a single vector input and multiple raster inputs.
872
/// For each raster input, a new column is added to the collection from the vector input.
873
/// The new column contains the value of the raster at the location of the vector feature.
874
/// For features covering multiple pixels like `MultiPoints` or `MultiPolygons`, the value is calculated using an aggregation function selected by the user.
875
/// The same is true if the temporal extent of a vector feature covers multiple raster time steps.
876
/// More details are described below.
877
///
878
/// **Example**:
879
/// You have a collection of agricultural fields (`Polygons`) and a collection of raster images containing each pixel's monthly NDVI value.
880
/// For your application, you want to know the NDVI value of each field.
881
/// The `RasterVectorJoin` operator allows you to combine the vector and raster data and offers multiple spatial and temporal aggregation strategies.
882
/// For example, you can use the `first` aggregation function to get the NDVI value of the first pixel that intersects with each field.
883
/// This is useful for exploratory analysis since the computation is very fast.
884
/// To calculate the mean NDVI value of all pixels that intersect with the field you should use the `mean` aggregation function.
885
/// Since the NDVI data is a monthly time series, you have to specify the temporal aggregation function as well.
886
/// The default is `none` which will create a new feature for each month.
887
/// Other options are `first` and `mean` which will calculate the first or mean NDVI value for each field over time.
888
///
889
/// ## Inputs
890
///
891
/// The `RasterVectorJoin` operator expects one _vector_ input and one or more _raster_ inputs.
892
///
893
/// | Parameter | Type                                |
894
/// | --------- | ----------------------------------- |
895
/// | `sources` | `SingleVectorMultipleRasterSources` |
896
///
897
/// ## Errors
898
///
899
/// If the length of `names` is not equal to the number of raster inputs, an error is thrown.
900
///
901
#[api_operator(
90✔
902
    title = "Raster Vector Join",
90✔
903
    examples(json!({
90✔
904
        "type": "RasterVectorJoin",
905
        "params": {
906
            "names": ["NDVI"],
907
            "featureAggregation": "first",
908
            "temporalAggregation": "mean",
909
            "temporalAggregationIgnoreNoData": true
910
        },
911
        "sources": {
912
            "vector": {
913
                "type": "OgrSource",
914
                "params": {
915
                    "data": "places"
916
                }
917
            },
918
            "rasters": [{
919
                "type": "GdalSource",
920
                "params": {
921
                "data": "ndvi"
922
                }
923
            }]
924
        }
925
    }))
926
)]
927
pub struct RasterVectorJoin {
928
    pub params: RasterVectorJoinParameters,
929
    pub sources: Box<SingleVectorMultipleRasterSources>,
930
}
931

932
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
933
#[serde(rename_all = "camelCase")]
934
pub struct RasterVectorJoinParameters {
935
    /// Specify how the new column names are derived from the raster band names.
936
    ///
937
    /// The `ColumnNames` type is used to specify how the new column names are derived from the raster band names.
938
    ///
939
    /// - **default**: Appends " (n)" to the band name with the smallest `n` that avoids a conflict.
940
    /// - **suffix**: Specifies a suffix for each input, to be appended to the band names.
941
    /// - **rename**: A list of names for each new column.
942
    ///
943
    #[schema(examples(
944
        json!({"type": "default"}),
945
        json!({"type": "suffix", "values": ["_sentinel2"]}),
946
        json!({"type": "rename", "values": ["red", "green", "blue"]}),
947
    ))]
948
    pub names: ColumnNames,
949
    /// The aggregation function to use for features covering multiple pixels.
950
    #[schema(examples("first"))]
951
    pub feature_aggregation: FeatureAggregationMethod,
952
    /// Whether to ignore no data values in the aggregation. Defaults to `false`.
953
    #[serde(default)]
954
    #[schema(examples(true))]
955
    pub feature_aggregation_ignore_no_data: bool,
956
    /// The aggregation function to use for features covering multiple (raster) time steps.
957
    #[schema(examples("mean"))]
958
    pub temporal_aggregation: TemporalAggregationMethod,
959
    /// Whether to ignore no data values in the aggregation. Defaults to `false`.
960
    #[serde(default)]
961
    #[schema(examples(true))]
962
    pub temporal_aggregation_ignore_no_data: bool,
963
}
964

965
impl TryFrom<RasterVectorJoin> for OperatorsRasterVectorJoin {
966
    type Error = anyhow::Error;
967

968
    fn try_from(value: RasterVectorJoin) -> Result<Self, Self::Error> {
2✔
969
        Ok(OperatorsRasterVectorJoin {
970
            params: OperatorsRasterVectorJoinParameters {
2✔
971
                names: value.params.names.into(),
2✔
972
                feature_aggregation: value.params.feature_aggregation.into(),
2✔
973
                feature_aggregation_ignore_no_data: value.params.feature_aggregation_ignore_no_data,
2✔
974
                temporal_aggregation: value.params.temporal_aggregation.into(),
2✔
975
                temporal_aggregation_ignore_no_data: value
2✔
976
                    .params
2✔
977
                    .temporal_aggregation_ignore_no_data,
2✔
978
            },
2✔
979
            sources: (*value.sources).try_into()?,
2✔
980
        })
981
    }
2✔
982
}
983

984
/// The `VectorExpression` operator performs a feature-wise expression function on a feature collection of a vector source.
985
/// The expression is specified as a user-defined script in a very simple language.
986
/// The output is a feature collection with the result of the expression and with time intervals that are the same as for the inputs.
987
/// Users can either add a new column or replace the geometry column with the outputs of the expression.
988
/// Internally, the expression is evaluated using floating-point numbers.
989
///
990
/// An example usage scenario is to calculate a population density from an `area` and a `population_size` column.
991
/// The expression uses a feature collection with two columns, referred to with their column names `area` and a `population_size`, and calculates the formula `area / population_size`.
992
/// The output feature collection contains the result of the density expression in a new column.
993
///
994
/// Another example is to calculate the centroid of a polygon geometry.
995
/// The expression uses a feature collection with a geometry column and calculates the formula `centroid(geom)`.
996
/// The output feature collection contains the result of the centroid expression replacing the original geometries.
997
///
998
/// ## Types
999
///
1000
/// The following describes the types used in the parameters.
1001
///
1002
/// ### Expression
1003
///
1004
/// Expressions are simple scripts to perform feature-wise computations.
1005
/// One can refer to the columns with their name, e.g., `area` and a `population_size`.
1006
/// Furthermore, expressions can check with `A IS NODATA`, `B IS NODATA`, etc. for empty or NO DATA values.
1007
/// Finally, the value `NODATA` can be used to output empty or NO DATA.
1008
///
1009
/// Users can think of this implicit function signature for, e.g., two inputs:
1010
///
1011
/// ```rust,ignore
1012
/// fn (A: f64, B: f64) -> f64
1013
/// ```
1014
///
1015
/// As a start, expressions contain algebraic operations and mathematical functions.
1016
///
1017
/// ```rust,ignore
1018
/// (A + B) / 2
1019
/// ```
1020
///
1021
/// In addition, branches can be used to check for conditions.
1022
///
1023
/// ```rust,ignore
1024
/// if A IS NODATA {
1025
///     B
1026
/// } else {
1027
///     A
1028
/// }
1029
/// ```
1030
///
1031
/// To generate more complex expressions, it is possible to have variable assignments.
1032
///
1033
/// ```rust,ignore
1034
/// let mean = (A + B) / 2;
1035
/// let coefficient = 0.357;
1036
/// mean * coefficient
1037
/// ```
1038
///
1039
/// Note, that all assignments are separated by semicolons.
1040
/// However, the last expression must be without a semicolon.
1041
///
1042
/// #### Numbers
1043
///
1044
/// Function calls can be used to access utility functions.
1045
///
1046
/// ```rust,ignore
1047
/// max(A, 0)
1048
/// ```
1049
///
1050
/// Currently, the following functions are available:
1051
///
1052
/// - `abs(a)`: absolute value
1053
/// - `min(a, b)`, `min(a, b, c)`: minimum value
1054
/// - `max(a, b)`, `max(a, b, c)`: maximum value
1055
/// - `sqrt(a)`: square root
1056
/// - `ln(a)`: natural logarithm
1057
/// - `log10(a)`: base 10 logarithm
1058
/// - `cos(a)`, `sin(a)`, `tan(a)`, `acos(a)`, `asin(a)`, `atan(a)`: trigonometric functions
1059
/// - `pi()`, `e()`: mathematical constants
1060
/// - `round(a)`, `ceil(a)`, `floor(a)`: rounding functions
1061
/// - `mod(a, b)`: division remainder
1062
/// - `to_degrees(a)`, `to_radians(a)`: conversion to degrees or radians
1063
///
1064
/// #### Geometries
1065
///
1066
/// Geometries can be referred to using the `geometryColumnName`, which is `geom` by default.
1067
/// There are several functions to work with geometries:
1068
///
1069
/// - `centroid(geom)`: returns the centroid of the geometry
1070
/// - `area(geom)`: returns the area of the geometry
1071
///
1072
/// An example expression to calculate the centroid of a geometry is:
1073
///
1074
/// ```rust,ignore
1075
/// centroid(geom)
1076
/// ```
1077
///
1078
/// ## Inputs
1079
///
1080
/// The `VectorExpression` operator expects one vector input with at most 8 bands.
1081
///
1082
/// | Parameter | Type                 |
1083
/// | --------- | -------------------- |
1084
/// | `vector`  | `SingleVectorSource` |
1085
///
1086
/// ## Errors
1087
///
1088
/// The parsing of the expression can fail if there are, e.g., syntax errors.
1089
///
1090
#[api_operator(
90✔
1091
    title = "Vector Expression",
90✔
1092
    examples(
90✔
1093
        json!({
90✔
1094
            "type": "VectorExpression",
1095
            "params": {
1096
                "inputColumns": ["area", "population_size"],
1097
                "outputColumn": { "type": "column", "value": "density" },
1098
                "expression": "area /  population_size",
1099
                "outputMeasurement": { "type": "unitless" }
1100
            },
1101
            "sources": {
1102
                "vector": {
1103
                "type": "OgrSource",
1104
                "params": {
1105
                    "data": "areas"
1106
                }
1107
                }
1108
            }
1109
        }),
1110
        json!({
1111
            "type": "VectorExpression",
1112
            "params": {
1113
                "inputColumns": [],
1114
                "outputColumn": { "type": "geometry", "value": "MultiPoint" },
1115
                "expression": "centroid(geom)",
1116
                "geometryColumnName": "geom"
1117
            },
1118
            "sources": {
1119
                "vector": {
1120
                "type": "OgrSource",
1121
                "params": {
1122
                    "data": "areas"
1123
                }
1124
                }
1125
            }
1126
        }),
1127
    )
1128
)]
1129
pub struct VectorExpression {
1130
    pub params: VectorExpressionParameters,
1131
    pub sources: Box<SingleVectorSource>,
1132
}
1133

1134
/// Parameters for the `VectorExpression` operator.
1135
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
1136
#[serde(rename_all = "camelCase")]
1137
pub struct VectorExpressionParameters {
1138
    /// The columns to use as variables in the expression.
1139
    ///
1140
    /// For usage in the expression, all special characters are replaced by underscores.
1141
    /// E.g., `precipitation.cm` becomes `precipitation_cm`.
1142
    /// If the column name starts with a number, an underscore is prepended.
1143
    /// E.g., `1column` becomes `_1column`.
1144
    #[schema(examples(json!(["temperature", "humidity"])))]
1145
    pub input_columns: Vec<String>,
1146

1147
    /// The expression to evaluate.
1148
    #[schema(examples("temperature * (1 + humidity / 100)"))]
1149
    pub expression: String,
1150

1151
    /// The type and name of the new column.
1152
    #[schema(examples(
1153
        json!({"type": "column", "value": "adjusted_temperature"}),
1154
        json!({"type": "geometry", "value": "MultiPolygon"}),
1155
    ))]
1156
    pub output_column: OutputColumn,
1157

1158
    /// The variable name of the geometry column.
1159
    /// The default is `geom`.
1160
    #[serde(default = "geometry_default_column_name")]
1161
    #[schema(examples(json!("geom")))]
1162
    pub geometry_column_name: String,
1163

1164
    /// The measurement of the new column.
1165
    /// The default is unitless.
1166
    #[schema(examples(
1167
        json!({"type": "unitless"}),
1168
        json!({"type": "continuous", "measurement": "length", "unit": "m"}),
1169
        json!({"type": "classification", "measurement": "severity", "classes": ["low", "medium", "high"]}),
1170
    ))]
1171
    #[serde(default = "Measurement::unitless")]
1172
    pub output_measurement: Measurement,
1173
}
1174

NEW
1175
fn geometry_default_column_name() -> String {
×
NEW
1176
    "geom".into()
×
NEW
1177
}
×
1178

1179
impl TryFrom<VectorExpression> for OperatorsVectorExpression {
1180
    type Error = anyhow::Error;
1181

1182
    fn try_from(value: VectorExpression) -> Result<Self, Self::Error> {
5✔
1183
        // Convert the API OutputColumn to the operators OutputColumn via JSON serialization
1184
        let output_column_json = serde_json::to_value(&value.params.output_column)?;
5✔
1185
        let output_column = serde_json::from_value(output_column_json)?;
5✔
1186

1187
        Ok(OperatorsVectorExpression {
1188
            params: OperatorsVectorExpressionParameters {
5✔
1189
                input_columns: value.params.input_columns,
5✔
1190
                expression: value.params.expression,
5✔
1191
                output_column,
5✔
1192
                geometry_column_name: value.params.geometry_column_name,
5✔
1193
                output_measurement: value.params.output_measurement.into(),
5✔
1194
            },
5✔
1195
            sources: (*value.sources).try_into()?,
5✔
1196
        })
1197
    }
5✔
1198
}
1199

1200
#[cfg(test)]
1201
mod tests {
1202

1203
    use super::*;
1204
    use crate::api::model::{
1205
        datatypes::{Coordinate2D, TimeGranularity, VectorDataType},
1206
        processing_graphs::{
1207
            OgrSource, OgrSourceParameters, RasterOperator, SingleRasterOrVectorOperator,
1208
            VectorOperator,
1209
            parameters::{ClassificationMeasurement, ContinuousMeasurement, SpatialBoundsDerive},
1210
            source::{
1211
                GdalSource, GdalSourceParameters, MockPointSource, MockPointSourceParameters,
1212
                MultiBandGdalSource,
1213
            },
1214
        },
1215
    };
1216
    use serde_json::json;
1217

1218
    #[test]
1219
    fn it_converts_expressions() {
1✔
1220
        let api = Expression {
1✔
1221
            r#type: Default::default(),
1✔
1222
            params: ExpressionParameters {
1✔
1223
                expression: "2 * A + B".to_string(),
1✔
1224
                output_type: RasterDataType::F32,
1✔
1225
                output_band: None,
1✔
1226
                map_no_data: true,
1✔
1227
            },
1✔
1228
            sources: Box::new(SingleRasterSource {
1✔
1229
                raster: RasterOperator::GdalSource(GdalSource {
1✔
1230
                    r#type: Default::default(),
1✔
1231
                    params: GdalSourceParameters {
1✔
1232
                        data: "example_data".to_string(),
1✔
1233
                        overview_level: None,
1✔
1234
                    },
1✔
1235
                }),
1✔
1236
            }),
1✔
1237
        };
1✔
1238

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

1241
        assert_eq!(ops.params.expression, "2 * A + B");
1✔
1242
        assert_eq!(
1✔
1243
            ops.params.output_type,
1244
            geoengine_datatypes::raster::RasterDataType::F32
1245
        );
1246
        assert!(ops.params.output_band.is_none());
1✔
1247
        assert!(ops.params.map_no_data);
1✔
1248
    }
1✔
1249

1250
    #[test]
1251
    fn it_converts_raster_vector_join_params() {
1✔
1252
        let api = RasterVectorJoin {
1✔
1253
            r#type: Default::default(),
1✔
1254
            params: RasterVectorJoinParameters {
1✔
1255
                names: ColumnNames::Names {
1✔
1256
                    values: vec!["a".to_string(), "b".to_string()],
1✔
1257
                },
1✔
1258
                feature_aggregation: FeatureAggregationMethod::First,
1✔
1259
                feature_aggregation_ignore_no_data: true,
1✔
1260
                temporal_aggregation: TemporalAggregationMethod::Mean,
1✔
1261
                temporal_aggregation_ignore_no_data: false,
1✔
1262
            },
1✔
1263
            sources: Box::new(SingleVectorMultipleRasterSources {
1✔
1264
                vector: VectorOperator::MockPointSource(MockPointSource {
1✔
1265
                    r#type: Default::default(),
1✔
1266
                    params: MockPointSourceParameters {
1✔
1267
                        points: vec![Coordinate2D { x: 0.0, y: 0.0 }],
1✔
1268
                        spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
1269
                    },
1✔
1270
                }),
1✔
1271
                rasters: vec![RasterOperator::GdalSource(GdalSource {
1✔
1272
                    r#type: Default::default(),
1✔
1273
                    params: GdalSourceParameters {
1✔
1274
                        data: "example_data".to_string(),
1✔
1275
                        overview_level: None,
1✔
1276
                    },
1✔
1277
                })],
1✔
1278
            }),
1✔
1279
        };
1✔
1280

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

1283
        assert!(matches!(
1✔
1284
            ops_params.params.names,
1✔
1285
            geoengine_operators::processing::ColumnNames::Names(_)
1286
        ));
1287
        assert_eq!(
1✔
1288
            ops_params.params.feature_aggregation,
1289
            geoengine_operators::processing::FeatureAggregationMethod::First
1290
        );
1291
        assert!(ops_params.params.feature_aggregation_ignore_no_data);
1✔
1292
        assert_eq!(
1✔
1293
            ops_params.params.temporal_aggregation,
1294
            geoengine_operators::processing::TemporalAggregationMethod::Mean
1295
        );
1296
        assert!(!ops_params.params.temporal_aggregation_ignore_no_data);
1✔
1297
    }
1✔
1298

1299
    #[test]
1300
    fn it_converts_reprojection_params() {
1✔
1301
        let api = Reprojection {
1✔
1302
            r#type: Default::default(),
1✔
1303
            params: ReprojectionParameters {
1✔
1304
                target_spatial_reference: "EPSG:32632".parse().expect("valid srs"),
1✔
1305
                derive_out_spec: DeriveOutRasterSpecsSource::ProjectionBounds,
1✔
1306
            },
1✔
1307
            sources: Box::new(SingleRasterOrVectorSource {
1✔
1308
                source: SingleRasterOrVectorOperator::Vector(VectorOperator::MockPointSource(
1✔
1309
                    MockPointSource {
1✔
1310
                        r#type: Default::default(),
1✔
1311
                        params: MockPointSourceParameters {
1✔
1312
                            points: vec![Coordinate2D { x: 0.0, y: 0.0 }],
1✔
1313
                            spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
1314
                        },
1✔
1315
                    },
1✔
1316
                )),
1✔
1317
            }),
1✔
1318
        };
1✔
1319

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

1322
        assert_eq!(
1✔
1323
            ops.params.target_spatial_reference.to_string(),
1✔
1324
            "EPSG:32632"
1325
        );
1326
        assert!(matches!(
1✔
1327
            ops.params.derive_out_spec,
1✔
1328
            geoengine_operators::processing::DeriveOutRasterSpecsSource::ProjectionBounds
1329
        ));
1330
    }
1✔
1331

1332
    #[test]
1333
    fn it_converts_temporal_raster_aggregation_params() {
1✔
1334
        let api = TemporalRasterAggregation {
1✔
1335
            r#type: Default::default(),
1✔
1336
            params: TemporalRasterAggregationParameters {
1✔
1337
                aggregation: Aggregation::Mean {
1✔
1338
                    ignore_no_data: true,
1✔
1339
                },
1✔
1340
                window: crate::api::model::datatypes::TimeStep {
1✔
1341
                    granularity: TimeGranularity::Months,
1✔
1342
                    step: 1,
1✔
1343
                },
1✔
1344
                window_reference: None,
1✔
1345
                output_type: None,
1✔
1346
            },
1✔
1347
            sources: Box::new(SingleRasterSource {
1✔
1348
                raster: RasterOperator::GdalSource(GdalSource {
1✔
1349
                    r#type: Default::default(),
1✔
1350
                    params: GdalSourceParameters {
1✔
1351
                        data: "example_data".to_string(),
1✔
1352
                        overview_level: None,
1✔
1353
                    },
1✔
1354
                }),
1✔
1355
            }),
1✔
1356
        };
1✔
1357

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

1360
        assert!(matches!(
1✔
1361
            ops.params.aggregation,
1✔
1362
            geoengine_operators::processing::Aggregation::Mean {
1363
                ignore_no_data: true
1364
            }
1365
        ));
1366
        assert_eq!(ops.params.window.step, 1);
1✔
1367
        assert!(ops.params.window_reference.is_none());
1✔
1368
        assert!(ops.params.output_type.is_none());
1✔
1369
    }
1✔
1370

1371
    #[test]
1372
    fn it_converts_raster_stacker_params() {
1✔
1373
        let api = RasterStacker {
1✔
1374
            r#type: Default::default(),
1✔
1375
            params: RasterStackerParameters {
1✔
1376
                rename_bands: RenameBands::Suffix(vec!["_a".to_string(), "_b".to_string()]),
1✔
1377
            },
1✔
1378
            sources: Box::new(MultipleRasterSources {
1✔
1379
                rasters: vec![
1✔
1380
                    RasterOperator::GdalSource(GdalSource {
1✔
1381
                        r#type: Default::default(),
1✔
1382
                        params: GdalSourceParameters {
1✔
1383
                            data: "example_data_a".to_string(),
1✔
1384
                            overview_level: None,
1✔
1385
                        },
1✔
1386
                    }),
1✔
1387
                    RasterOperator::GdalSource(GdalSource {
1✔
1388
                        r#type: Default::default(),
1✔
1389
                        params: GdalSourceParameters {
1✔
1390
                            data: "example_data_b".to_string(),
1✔
1391
                            overview_level: None,
1✔
1392
                        },
1✔
1393
                    }),
1✔
1394
                ],
1✔
1395
            }),
1✔
1396
        };
1✔
1397

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

1400
        assert_eq!(
1✔
1401
            ops.params.rename_bands,
1402
            geoengine_datatypes::raster::RenameBands::Suffix(vec![
1✔
1403
                "_a".to_string(),
1✔
1404
                "_b".to_string()
1✔
1405
            ])
1✔
1406
        );
1407
        assert_eq!(ops.sources.rasters.len(), 2);
1✔
1408
    }
1✔
1409

1410
    #[test]
1411
    fn it_converts_raster_type_conversion_params() {
1✔
1412
        let api = RasterTypeConversion {
1✔
1413
            r#type: Default::default(),
1✔
1414
            params: RasterTypeConversionParameters {
1✔
1415
                output_data_type: RasterDataType::U16,
1✔
1416
            },
1✔
1417
            sources: Box::new(SingleRasterSource {
1✔
1418
                raster: RasterOperator::GdalSource(GdalSource {
1✔
1419
                    r#type: Default::default(),
1✔
1420
                    params: GdalSourceParameters {
1✔
1421
                        data: "example_data".to_string(),
1✔
1422
                        overview_level: None,
1✔
1423
                    },
1✔
1424
                }),
1✔
1425
            }),
1✔
1426
        };
1✔
1427

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

1430
        assert_eq!(
1✔
1431
            ops.params.output_data_type,
1432
            geoengine_datatypes::raster::RasterDataType::U16
1433
        );
1434
    }
1✔
1435

1436
    #[test]
1437
    fn it_converts_interpolation_params() {
1✔
1438
        let api = Interpolation {
1✔
1439
            r#type: Default::default(),
1✔
1440
            params: InterpolationParameters {
1✔
1441
                interpolation: InterpolationMethod::NearestNeighbor,
1✔
1442
                output_resolution: InterpolationResolution::Fraction { x: 2.0, y: 2.0 },
1✔
1443
                output_origin_reference: None,
1✔
1444
            },
1✔
1445
            sources: Box::new(SingleRasterSource {
1✔
1446
                raster: RasterOperator::MultiBandGdalSource(MultiBandGdalSource {
1✔
1447
                    r#type: Default::default(),
1✔
1448
                    params: GdalSourceParameters {
1✔
1449
                        data: "example_data".to_string(),
1✔
1450
                        overview_level: None,
1✔
1451
                    },
1✔
1452
                }),
1✔
1453
            }),
1✔
1454
        };
1✔
1455

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

1458
        assert!(matches!(
1✔
1459
            ops.params.interpolation,
1✔
1460
            geoengine_operators::processing::InterpolationMethod::NearestNeighbor
1461
        ));
1462
        assert!(matches!(
1✔
1463
            ops.params.output_resolution,
1✔
1464
            geoengine_operators::processing::InterpolationResolution::Fraction(
1465
                geoengine_operators::processing::Fraction { x, y }
1✔
1466
            )
1467
            if (x - 2.0).abs() < f64::EPSILON && (y - 2.0).abs() < f64::EPSILON
1✔
1468
        ));
1469
        assert!(ops.params.output_origin_reference.is_none());
1✔
1470
    }
1✔
1471

1472
    #[test]
1473
    fn it_converts_downsampling_params() {
1✔
1474
        let api = Downsampling {
1✔
1475
            r#type: Default::default(),
1✔
1476
            params: DownsamplingParameters {
1✔
1477
                sampling_method: DownsamplingMethod::NearestNeighbor,
1✔
1478
                output_resolution: DownsamplingResolution::Fraction { x: 2.0, y: 2.0 },
1✔
1479
                output_origin_reference: Some(crate::api::model::datatypes::Coordinate2D {
1✔
1480
                    x: 0.0,
1✔
1481
                    y: 0.0,
1✔
1482
                }),
1✔
1483
            },
1✔
1484
            sources: Box::new(SingleRasterSource {
1✔
1485
                raster: RasterOperator::MultiBandGdalSource(MultiBandGdalSource {
1✔
1486
                    r#type: Default::default(),
1✔
1487
                    params: GdalSourceParameters {
1✔
1488
                        data: "example_data".to_string(),
1✔
1489
                        overview_level: None,
1✔
1490
                    },
1✔
1491
                }),
1✔
1492
            }),
1✔
1493
        };
1✔
1494

1495
        let ops = OperatorsDownsampling::try_from(api).expect("conversion failed");
1✔
1496

1497
        assert!(matches!(
1✔
1498
            ops.params.sampling_method,
1✔
1499
            geoengine_operators::processing::DownsamplingMethod::NearestNeighbor
1500
        ));
1501
        assert!(matches!(
1✔
1502
            ops.params.output_resolution,
1✔
1503
            geoengine_operators::processing::DownsamplingResolution::Fraction(
1504
                geoengine_operators::processing::Fraction { x, y }
1✔
1505
            )
1506
            if (x - 2.0).abs() < f64::EPSILON && (y - 2.0).abs() < f64::EPSILON
1✔
1507
        ));
1508
        assert_eq!(
1✔
1509
            ops.params.output_origin_reference,
1510
            Some(geoengine_datatypes::primitives::Coordinate2D::new(0.0, 0.0))
1✔
1511
        );
1512
    }
1✔
1513

1514
    #[test]
1515
    fn it_converts_band_filter_params() {
1✔
1516
        let api = BandFilter {
1✔
1517
            r#type: Default::default(),
1✔
1518
            params: BandFilterParameters {
1✔
1519
                bands: BandsByNameOrIndex::Name(vec!["nir".to_string(), "red".to_string()]),
1✔
1520
            },
1✔
1521
            sources: Box::new(SingleRasterSource {
1✔
1522
                raster: RasterOperator::MultiBandGdalSource(MultiBandGdalSource {
1✔
1523
                    r#type: Default::default(),
1✔
1524
                    params: GdalSourceParameters {
1✔
1525
                        data: "example_data".to_string(),
1✔
1526
                        overview_level: None,
1✔
1527
                    },
1✔
1528
                }),
1✔
1529
            }),
1✔
1530
        };
1✔
1531

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

1534
        assert_eq!(
1✔
1535
            serde_json::to_value(ops.params).expect("params should serialize"),
1✔
1536
            json!({
1✔
1537
                "bands": ["nir", "red"]
1✔
1538
            })
1539
        );
1540
    }
1✔
1541

1542
    #[test]
1543
    #[allow(clippy::too_many_lines, reason = "test covers multiple cases")]
1544
    fn it_converts_vector_expression_params() {
1✔
1545
        // Test with column output and default geometry column
1546
        let api_column = VectorExpression {
1✔
1547
            r#type: Default::default(),
1✔
1548
            params: VectorExpressionParameters {
1✔
1549
                input_columns: vec!["temperature".to_string(), "humidity".to_string()],
1✔
1550
                expression: "temperature * (1 + humidity / 100)".to_string(),
1✔
1551
                output_column: OutputColumn::Column("adjusted_temperature".to_string()),
1✔
1552
                geometry_column_name: "geom".to_string(),
1✔
1553
                output_measurement: Measurement::Unitless(Default::default()),
1✔
1554
            },
1✔
1555
            sources: Box::new(SingleVectorSource {
1✔
1556
                vector: VectorOperator::MockPointSource(MockPointSource {
1✔
1557
                    r#type: Default::default(),
1✔
1558
                    params: MockPointSourceParameters {
1✔
1559
                        points: vec![Coordinate2D { x: 0.0, y: 0.0 }],
1✔
1560
                        spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
1561
                    },
1✔
1562
                }),
1✔
1563
            }),
1✔
1564
        };
1✔
1565

1566
        let json_column = json!({
1✔
1567
            "type": "VectorExpression",
1✔
1568
            "params": {
1✔
1569
                "inputColumns": ["temperature", "humidity"],
1✔
1570
                "expression": "temperature * (1 + humidity / 100)",
1✔
1571
                "outputColumn": {
1✔
1572
                    "type": "column",
1✔
1573
                    "value": "adjusted_temperature"
1✔
1574
                },
1575
                "geometryColumnName": "geom",
1✔
1576
                "outputMeasurement": {
1✔
1577
                    "type": "unitless"
1✔
1578
                }
1579
            },
1580
            "sources": {
1✔
1581
                "vector": {
1✔
1582
                    "type": "MockPointSource",
1✔
1583
                    "params": {
1✔
1584
                        "points": [{"x": 0.0, "y": 0.0}],
1✔
1585
                        "spatialBounds": {"type": "derive"}
1✔
1586
                    }
1587
                }
1588
            }
1589
        });
1590

1591
        assert_eq!(serde_json::to_value(&api_column).unwrap(), json_column);
1✔
1592
        assert_eq!(
1✔
1593
            serde_json::from_value::<VectorExpression>(json_column).unwrap(),
1✔
1594
            api_column
1595
        );
1596
        OperatorsVectorExpression::try_from(api_column).expect("it converts to operator pendant");
1✔
1597

1598
        // Test with geometry output
1599
        let api_geometry = VectorExpression {
1✔
1600
            r#type: Default::default(),
1✔
1601
            params: VectorExpressionParameters {
1✔
1602
                input_columns: vec!["x".to_string(), "y".to_string()],
1✔
1603
                expression: "create_point(x, y)".to_string(),
1✔
1604
                output_column: OutputColumn::Geometry(VectorDataType::MultiPolygon),
1✔
1605
                geometry_column_name: "geom".to_string(),
1✔
1606
                output_measurement: Measurement::Unitless(Default::default()),
1✔
1607
            },
1✔
1608
            sources: Box::new(SingleVectorSource {
1✔
1609
                vector: VectorOperator::OgrSource(OgrSource {
1✔
1610
                    r#type: Default::default(),
1✔
1611
                    params: OgrSourceParameters {
1✔
1612
                        data: "weather_stations".to_string(),
1✔
1613
                        attribute_projection: None,
1✔
1614
                    },
1✔
1615
                }),
1✔
1616
            }),
1✔
1617
        };
1✔
1618

1619
        let json_geometry = json!({
1✔
1620
            "type": "VectorExpression",
1✔
1621
            "params": {
1✔
1622
                "inputColumns": ["x", "y"],
1✔
1623
                "expression": "create_point(x, y)",
1✔
1624
                "outputColumn": {
1✔
1625
                    "type": "geometry",
1✔
1626
                    "value": "MultiPolygon"
1✔
1627
                },
1628
                "geometryColumnName": "geom",
1✔
1629
                "outputMeasurement": {
1✔
1630
                    "type": "unitless"
1✔
1631
                }
1632
            },
1633
            "sources": {
1✔
1634
                "vector": {
1✔
1635
                    "type": "OgrSource",
1✔
1636
                    "params": {
1✔
1637
                        "data": "weather_stations",
1✔
1638
                        "attributeProjection": null
1✔
1639
                    }
1640
                }
1641
            }
1642
        });
1643

1644
        assert_eq!(serde_json::to_value(&api_geometry).unwrap(), json_geometry);
1✔
1645
        assert_eq!(
1✔
1646
            serde_json::from_value::<VectorExpression>(json_geometry).unwrap(),
1✔
1647
            api_geometry
1648
        );
1649
        OperatorsVectorExpression::try_from(api_geometry).expect("it converts to operator pendant");
1✔
1650

1651
        // Test with custom geometry column
1652
        let api_custom_geom = VectorExpression {
1✔
1653
            r#type: Default::default(),
1✔
1654
            params: VectorExpressionParameters {
1✔
1655
                input_columns: vec!["value".to_string()],
1✔
1656
                expression: "value * 2".to_string(),
1✔
1657
                output_column: OutputColumn::Column("doubled".to_string()),
1✔
1658
                geometry_column_name: "my_geometry".to_string(),
1✔
1659
                output_measurement: Measurement::Unitless(Default::default()),
1✔
1660
            },
1✔
1661
            sources: Box::new(SingleVectorSource {
1✔
1662
                vector: VectorOperator::MockPointSource(MockPointSource {
1✔
1663
                    r#type: Default::default(),
1✔
1664
                    params: MockPointSourceParameters {
1✔
1665
                        points: vec![Coordinate2D { x: 1.0, y: 2.0 }],
1✔
1666
                        spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
1667
                    },
1✔
1668
                }),
1✔
1669
            }),
1✔
1670
        };
1✔
1671

1672
        let json_custom_geom = json!({
1✔
1673
            "type": "VectorExpression",
1✔
1674
            "params": {
1✔
1675
                "inputColumns": ["value"],
1✔
1676
                "expression": "value * 2",
1✔
1677
                "outputColumn": {
1✔
1678
                    "type": "column",
1✔
1679
                    "value": "doubled"
1✔
1680
                },
1681
                "geometryColumnName": "my_geometry",
1✔
1682
                "outputMeasurement": {
1✔
1683
                    "type": "unitless"
1✔
1684
                }
1685
            },
1686
            "sources": {
1✔
1687
                "vector": {
1✔
1688
                    "type": "MockPointSource",
1✔
1689
                    "params": {
1✔
1690
                        "points": [{"x": 1.0, "y": 2.0}],
1✔
1691
                        "spatialBounds": {"type": "derive"}
1✔
1692
                    }
1693
                }
1694
            }
1695
        });
1696

1697
        assert_eq!(
1✔
1698
            serde_json::to_value(&api_custom_geom).unwrap(),
1✔
1699
            json_custom_geom
1700
        );
1701
        assert_eq!(
1✔
1702
            serde_json::from_value::<VectorExpression>(json_custom_geom).unwrap(),
1✔
1703
            api_custom_geom
1704
        );
1705
        OperatorsVectorExpression::try_from(api_custom_geom)
1✔
1706
            .expect("it converts to operator pendant");
1✔
1707

1708
        // Test with continuous measurement
1709
        let api_continuous = VectorExpression {
1✔
1710
            r#type: Default::default(),
1✔
1711
            params: VectorExpressionParameters {
1✔
1712
                input_columns: vec!["rainfall".to_string()],
1✔
1713
                expression: "rainfall / 10".to_string(),
1✔
1714
                output_column: OutputColumn::Column("rainfall_mm".to_string()),
1✔
1715
                geometry_column_name: "geom".to_string(),
1✔
1716
                output_measurement: Measurement::Continuous(ContinuousMeasurement {
1✔
1717
                    r#type: Default::default(),
1✔
1718
                    measurement: "precipitation".to_string(),
1✔
1719
                    unit: Some("mm".to_string()),
1✔
1720
                }),
1✔
1721
            },
1✔
1722
            sources: Box::new(SingleVectorSource {
1✔
1723
                vector: VectorOperator::MockPointSource(MockPointSource {
1✔
1724
                    r#type: Default::default(),
1✔
1725
                    params: MockPointSourceParameters {
1✔
1726
                        points: vec![Coordinate2D { x: 0.0, y: 0.0 }],
1✔
1727
                        spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
1728
                    },
1✔
1729
                }),
1✔
1730
            }),
1✔
1731
        };
1✔
1732

1733
        let json_continuous = json!({
1✔
1734
            "type": "VectorExpression",
1✔
1735
            "params": {
1✔
1736
                "inputColumns": ["rainfall"],
1✔
1737
                "expression": "rainfall / 10",
1✔
1738
                "outputColumn": {
1✔
1739
                    "type": "column",
1✔
1740
                    "value": "rainfall_mm"
1✔
1741
                },
1742
                "geometryColumnName": "geom",
1✔
1743
                "outputMeasurement": {
1✔
1744
                    "type": "continuous",
1✔
1745
                    "measurement": "precipitation",
1✔
1746
                    "unit": "mm"
1✔
1747
                }
1748
            },
1749
            "sources": {
1✔
1750
                "vector": {
1✔
1751
                    "type": "MockPointSource",
1✔
1752
                    "params": {
1✔
1753
                        "points": [{"x": 0.0, "y": 0.0}],
1✔
1754
                        "spatialBounds": {"type": "derive"}
1✔
1755
                    }
1756
                }
1757
            }
1758
        });
1759

1760
        assert_eq!(
1✔
1761
            serde_json::to_value(&api_continuous).unwrap(),
1✔
1762
            json_continuous
1763
        );
1764
        assert_eq!(
1✔
1765
            serde_json::from_value::<VectorExpression>(json_continuous).unwrap(),
1✔
1766
            api_continuous
1767
        );
1768
        OperatorsVectorExpression::try_from(api_continuous)
1✔
1769
            .expect("it converts to operator pendant");
1✔
1770

1771
        // Test with classification measurement
1772
        let api_classification = VectorExpression {
1✔
1773
            r#type: Default::default(),
1✔
1774
            params: VectorExpressionParameters {
1✔
1775
                input_columns: vec!["severity_score".to_string()],
1✔
1776
                expression:
1✔
1777
                    "if(severity_score > 70, 'high', if(severity_score > 40, 'medium', 'low'))"
1✔
1778
                        .to_string(),
1✔
1779
                output_column: OutputColumn::Column("severity".to_string()),
1✔
1780
                geometry_column_name: "geom".to_string(),
1✔
1781
                output_measurement: Measurement::Classification(ClassificationMeasurement {
1✔
1782
                    r#type: Default::default(),
1✔
1783
                    measurement: "severity".to_string(),
1✔
1784
                    classes: [
1✔
1785
                        (0, "low".to_string()),
1✔
1786
                        (1, "medium".to_string()),
1✔
1787
                        (2, "high".to_string()),
1✔
1788
                    ]
1✔
1789
                    .into_iter()
1✔
1790
                    .collect(),
1✔
1791
                }),
1✔
1792
            },
1✔
1793
            sources: Box::new(SingleVectorSource {
1✔
1794
                vector: VectorOperator::MockPointSource(MockPointSource {
1✔
1795
                    r#type: Default::default(),
1✔
1796
                    params: MockPointSourceParameters {
1✔
1797
                        points: vec![Coordinate2D { x: 0.0, y: 0.0 }],
1✔
1798
                        spatial_bounds: SpatialBoundsDerive::Derive(Default::default()),
1✔
1799
                    },
1✔
1800
                }),
1✔
1801
            }),
1✔
1802
        };
1✔
1803

1804
        let json_classification = json!({
1✔
1805
            "type": "VectorExpression",
1✔
1806
            "params": {
1✔
1807
                "inputColumns": ["severity_score"],
1✔
1808
                "expression": "if(severity_score > 70, 'high', if(severity_score > 40, 'medium', 'low'))",
1✔
1809
                "outputColumn": {
1✔
1810
                    "type": "column",
1✔
1811
                    "value": "severity"
1✔
1812
                },
1813
                "geometryColumnName": "geom",
1✔
1814
                "outputMeasurement": {
1✔
1815
                    "type": "classification",
1✔
1816
                    "measurement": "severity",
1✔
1817
                    "classes": {
1✔
1818
                        "0": "low",
1✔
1819
                        "1": "medium",
1✔
1820
                        "2": "high"
1✔
1821
                    }
1822
                }
1823
            },
1824
            "sources": {
1✔
1825
                "vector": {
1✔
1826
                    "type": "MockPointSource",
1✔
1827
                    "params": {
1✔
1828
                        "points": [{"x": 0.0, "y": 0.0}],
1✔
1829
                        "spatialBounds": {"type": "derive"}
1✔
1830
                    }
1831
                }
1832
            }
1833
        });
1834

1835
        assert_eq!(
1✔
1836
            serde_json::to_value(&api_classification).unwrap(),
1✔
1837
            json_classification
1838
        );
1839
        assert_eq!(
1✔
1840
            serde_json::from_value::<VectorExpression>(json_classification).unwrap(),
1✔
1841
            api_classification
1842
        );
1843
        OperatorsVectorExpression::try_from(api_classification)
1✔
1844
            .expect("it converts to operator pendant");
1✔
1845
    }
1✔
1846
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc