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

geo-engine / geoengine / 30090857570

24 Jul 2026 11:48AM UTC coverage: 87.526% (-0.06%) from 87.585%
30090857570

Pull #1221

github

web-flow
Merge 24b5ed733 into aa2cf832f
Pull Request #1221: feat: add VectorExpression operator to processing graph api

250 of 256 new or added lines in 3 files covered. (97.66%)

146 existing lines in 1 file now uncovered.

123447 of 141041 relevant lines covered (87.53%)

480464.18 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
    pub output_measurement: Measurement,
1172
}
1173

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1834
        assert_eq!(
1✔
1835
            serde_json::to_value(&api_classification).unwrap(),
1✔
1836
            json_classification
1837
        );
1838
        assert_eq!(
1✔
1839
            serde_json::from_value::<VectorExpression>(json_classification).unwrap(),
1✔
1840
            api_classification
1841
        );
1842
        OperatorsVectorExpression::try_from(api_classification)
1✔
1843
            .expect("it converts to operator pendant");
1✔
1844
    }
1✔
1845
}
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